diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java b/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java
index 49409ff02e..f7ed9f2bfc 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java
@@ -155,6 +155,33 @@ private static boolean isRetryableError(Throwable error) {
.retryOn(RETRYABLE_ERRORS)
.build();
+ /**
+ * Sentinel value for {@link #timeout} meaning "no timeout". A negative duration is never
+ * produced by normal usage and is recognised by {@code ToolExecutor.applyTimeout} and
+ * {@link ModelUtils#applyTimeoutAndRetry ModelUtils.applyTimeoutAndRetry}
+ * as "skip the timeout operator entirely".
+ *
+ *
This is the only way to opt out of the timeout that {@link #TOOL_DEFAULTS} and {@link
+ * #MODEL_DEFAULTS} always carry, because {@link #mergeConfigs} treats {@code null} as
+ * "inherit from fallback".
+ *
+ *
Supported paths: The sentinel is honoured on the tool path
+ * ({@code ToolExecutor.applyTimeout}) and the model-flux path
+ * ({@code ModelUtils.applyTimeoutAndRetry}), where it means genuinely unbounded
+ * (no timeout operator is applied). Extension consumers that read
+ * {@link #getTimeout()} directly and pass the value to a framework timeout operator
+ * must apply the same guard via {@link #isTimeoutDisabled()}:
+ *
+ * - {@code EmbeddingUtils.applyTimeoutAndRetry} in {@code rag-simple} — honours the
+ * sentinel by skipping the timeout operator, consistent with tool/model-flux.
+ * - {@code openai-official} provider — the sentinel is recognised but degrades to
+ * the OpenAI SDK's own default request timeout rather than being truly unbounded,
+ * because the SDK client does not accept an unbounded timeout value. A warning is
+ * logged when this occurs.
+ *
+ */
+ public static final Duration NO_TIMEOUT = Duration.ofNanos(-1);
+
/**
* Standard defaults for tool executions.
*
@@ -184,6 +211,20 @@ public Duration getTimeout() {
return timeout;
}
+ /**
+ * Returns true when the configured timeout is the {@link #NO_TIMEOUT} sentinel,
+ * meaning consumers should skip applying any timeout operator.
+ *
+ * Only the exact {@link #NO_TIMEOUT} sentinel is recognised; stray negative
+ * durations (which should be rejected by {@link Builder#timeout(Duration)}) are
+ * not treated as "no timeout".
+ *
+ * @return true if timeout is disabled via {@link #NO_TIMEOUT}
+ */
+ public boolean isTimeoutDisabled() {
+ return NO_TIMEOUT.equals(timeout);
+ }
+
/**
* Gets the maximum number of attempts.
*
@@ -305,14 +346,36 @@ public static class Builder {
/**
* Sets the timeout duration for a single execution.
*
- * @param timeout the timeout duration, or null for no timeout
+ * @param timeout the timeout duration (must be > 0, or {@link #NO_TIMEOUT}),
+ * or null to inherit from fallback; {@code Duration.ZERO} is not a synonym
+ * for {@link #noTimeout()} and is therefore rejected
* @return this builder instance
+ * @throws IllegalArgumentException if timeout is a negative duration other than
+ * {@link #NO_TIMEOUT}, or if timeout is {@code Duration.ZERO}
*/
public Builder timeout(Duration timeout) {
+ if (timeout != null
+ && (timeout.isNegative() || timeout.isZero())
+ && !NO_TIMEOUT.equals(timeout)) {
+ throw new IllegalArgumentException(
+ "timeout must be positive, got "
+ + timeout
+ + "; use NO_TIMEOUT or noTimeout() to disable it");
+ }
this.timeout = timeout;
return this;
}
+ /**
+ * Opt out of timeout entirely for this call. Equivalent to {@code timeout(NO_TIMEOUT)}.
+ * This is the only way to prevent the timeout inherited from {@link #TOOL_DEFAULTS} /
+ * {@link #MODEL_DEFAULTS}, because {@link #mergeConfigs} treats {@code null} as inherit.
+ */
+ public Builder noTimeout() {
+ this.timeout = NO_TIMEOUT;
+ return this;
+ }
+
/**
* Sets the maximum number of attempts (including the initial attempt).
*
diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java
index 17e436a50b..cdae41fc14 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java
@@ -87,7 +87,7 @@ public static Flux applyTimeoutAndRetry(
if (execConfig != null) {
// Apply timeout if configured
Duration timeout = execConfig.getTimeout();
- if (timeout != null) {
+ if (timeout != null && !execConfig.isTimeoutDisabled()) {
responseFlux =
responseFlux.timeout(
timeout,
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java
index 4745ad8ef6..c4df694b18 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java
@@ -155,7 +155,9 @@ private void invokeChunkCallback(
// ==================== Single Tool Execution ====================
/**
- * Execute a single tool call with full infrastructure support.
+ * Execute a single tool call (core execution only: Tracer + {@link #executeCore};
+ * no scheduling, timeout, retry, shutdown guard, or id/name stamping). Use
+ * {@link #executeWithInfrastructure(ToolCallParam, ExecutionConfig)} for the full-infrastructure path.
*
* @param param Tool call parameters
* @return Mono containing execution result
@@ -171,8 +173,9 @@ Mono execute(ToolCallParam param) {
/**
* Execute a single tool call with a per-call tool request config and a per-call internal chunk
- * callback. This is the single core entry point; the no-arg {@link #execute(ToolCallParam)}
- * resolves the request config from its explicit runtime context and uses no internal callback.
+ * callback. This is the single core entry point; the 1-param {@link #execute(ToolCallParam)}
+ * overload resolves the request config from its explicit runtime context and uses no internal
+ * callback.
*/
Mono execute(
ToolCallParam param,
@@ -342,7 +345,10 @@ private Collection resolveActiveGroups(ToolCallParam param) {
// ==================== Batch Tool Execution ====================
/**
- * Execute multiple tool calls with concurrency control, timeout, and retry.
+ * Execute multiple tool calls with concurrency control plus full per-call infrastructure
+ * (scheduling, timeout, retry, shutdown guard, id/name stamping). Each single call is routed
+ * through {@link #executeWithInfrastructure(ToolUseBlock, ExecutionConfig, Agent,
+ * RuntimeContext, ToolRequestConfig, BiConsumer)}.
*
* @param toolCalls List of tool calls to execute
* @param parallel Whether to execute in parallel
@@ -449,16 +455,20 @@ private boolean isConcurrencySafe(ToolUseBlock toolCall, ToolRequestConfig reque
}
/**
- * Execute a single tool call with infrastructure (scheduling, timeout, retry).
+ * Execute a single tool call with infrastructure (scheduling, timeout, retry, shutdown
+ * guard), and stamps the result with the tool call's id/name.
+ *
+ * This overload is used by the batch path ({@link #executeAll(List, boolean,
+ * ExecutionConfig, Agent, RuntimeContext)}), which routes each {@link ToolUseBlock} with
+ * the infrastructure config, per-call request config, and chunk callback.
*/
- private Mono executeWithInfrastructure(
+ Mono executeWithInfrastructure(
ToolUseBlock toolCall,
ExecutionConfig executionConfig,
Agent agent,
RuntimeContext agentRuntimeContext,
ToolRequestConfig requestConfig,
BiConsumer internalChunkCallback) {
- // Build tool call parameter
ToolCallParam param =
ToolCallParam.builder()
.toolUseBlock(toolCall)
@@ -466,16 +476,49 @@ private Mono executeWithInfrastructure(
.runtimeContext(agentRuntimeContext)
.build();
- // Get core execution
Mono execution = execute(param, requestConfig, internalChunkCallback);
- // Apply infrastructure layers
+ return applyInfrastructure(execution, executionConfig, toolCall);
+ }
+
+ /**
+ * Execute a single tool call with full infrastructure, preserving all fields from the
+ * original {@link ToolCallParam} (including input).
+ *
+ * This overload is used by {@code Toolkit.callTool} so that user-supplied fields on the
+ * param object are not silently discarded before reaching {@link #executeCore}.
+ */
+ Mono executeWithInfrastructure(
+ ToolCallParam param, ExecutionConfig executionConfig) {
+ ToolUseBlock toolCall = param.getToolUseBlock();
+
+ Mono execution = execute(param);
+
+ return applyInfrastructure(execution, executionConfig, toolCall);
+ }
+
+ /**
+ * Applies the shared infrastructure pipeline (scheduling, timeout, retry, shutdown guard)
+ * and stamps the result with the tool call's id/name. The four infrastructure layers and
+ * the error-to-result conversion live here so that both entry points (batch and single)
+ * stay in sync when a new layer is added.
+ *
+ * Retry semantics: {@link #applyRetry} only fires for the timeout
+ * {@code RuntimeException} emitted by {@link #applyTimeout}. Tool failures are converted
+ * to normal {@link ToolResultBlock#error} completions inside {@link #executeCore} before
+ * this pipeline runs, and {@link #applyShutdownGuard} runs after retry so
+ * shutdown signals are never seen by {@code retryWhen} either. "Retry" here means
+ * "retry on timeout", nothing else.
+ */
+ private Mono applyInfrastructure(
+ Mono execution,
+ ExecutionConfig executionConfig,
+ ToolUseBlock toolCall) {
execution = applyScheduling(execution);
execution = applyTimeout(execution, executionConfig, toolCall);
execution = applyRetry(execution, executionConfig, toolCall);
execution = applyShutdownGuard(execution);
- // Add tool metadata and error handling
return execution
.map(result -> result.withIdAndName(toolCall.getId(), toolCall.getName()))
.onErrorResume(
@@ -499,7 +542,8 @@ private Mono applyScheduling(Mono execution) {
private Mono applyTimeout(
Mono execution, ExecutionConfig config, ToolUseBlock toolCall) {
- if (config == null || config.getTimeout() == null) {
+ // null = inherit from fallback, NO_TIMEOUT sentinel = explicitly disabled
+ if (config == null || config.getTimeout() == null || config.isTimeoutDisabled()) {
return execution;
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java
index e826e17aac..f382f2e368 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRegistry.java
@@ -28,7 +28,9 @@
* and retrieve tools.
*
* Thread Safety: This class is thread-safe, using {@link ConcurrentHashMap} for internal
- * storage to support concurrent tool registration and lookup operations.
+ * storage to support concurrent tool registration and lookup operations. Tool instance and
+ * registration metadata are stored together in a single compound map entry, so put/remove of the
+ * two are a single atomic operation.
*
*
Key Responsibilities:
*
@@ -39,8 +41,9 @@
*/
class ToolRegistry {
- private final Map tools = new ConcurrentHashMap<>();
- private final Map registeredTools = new ConcurrentHashMap<>();
+ private record Entry(AgentTool tool, RegisteredToolFunction registered) {}
+
+ private final Map entries = new ConcurrentHashMap<>();
/**
* Register a tool with its metadata.
@@ -53,8 +56,7 @@ void registerTool(String toolName, AgentTool tool, RegisteredToolFunction regist
if (toolName == null || toolName.isBlank()) {
throw new IllegalArgumentException("Tool name cannot be null or blank");
}
- tools.put(toolName, tool);
- registeredTools.put(toolName, registered);
+ entries.put(toolName, new Entry(tool, registered));
}
/**
@@ -67,7 +69,8 @@ AgentTool getTool(String name) {
if (name == null || name.isBlank()) {
return null;
}
- return tools.get(name);
+ Entry e = entries.get(name);
+ return e != null ? e.tool() : null;
}
/**
@@ -80,7 +83,8 @@ RegisteredToolFunction getRegisteredTool(String name) {
if (name == null || name.isBlank()) {
return null;
}
- return registeredTools.get(name);
+ Entry e = entries.get(name);
+ return e != null ? e.registered() : null;
}
/**
@@ -89,7 +93,7 @@ RegisteredToolFunction getRegisteredTool(String name) {
* @return Set of tool names
*/
Set getToolNames() {
- return new HashSet<>(tools.keySet());
+ return new HashSet<>(entries.keySet());
}
/**
@@ -98,7 +102,13 @@ Set getToolNames() {
* @return Map of tool name to RegisteredToolFunction
*/
Map getAllRegisteredTools() {
- return new ConcurrentHashMap<>(registeredTools);
+ Map result = new ConcurrentHashMap<>();
+ for (Map.Entry e : entries.entrySet()) {
+ if (e.getValue().registered() != null) {
+ result.put(e.getKey(), e.getValue().registered());
+ }
+ }
+ return result;
}
/**
@@ -110,24 +120,35 @@ void removeTool(String toolName) {
if (toolName == null || toolName.isBlank()) {
throw new IllegalArgumentException("Tool name cannot be null or blank");
}
- tools.remove(toolName);
- registeredTools.remove(toolName);
+ entries.remove(toolName);
}
/**
* Atomically remove a tool only if the current instance matches the expected one.
* Uses {@link ConcurrentHashMap#remove(Object, Object)} to avoid TOCTOU races.
*
+ * Identity semantics: The guard check ({@code existing.tool() == expected})
+ * compares the expected tool by reference ({@code ==}), not via {@link Object#equals}.
+ * Two {@code AgentTool} instances that are {@link Object#equals equal} but not the same
+ * reference will not match — this guards against accidental removal of a tool that was
+ * re-registered under the same name by another caller. The CAS at
+ * {@link ConcurrentHashMap#remove(Object, Object)} additionally depends on the
+ * {@code Entry} record's {@link Object#equals}, which compares both the
+ * {@code AgentTool} and {@code RegisteredToolFunction} fields; callers that
+ * rebuild {@code Entry} objects (e.g. via {@code copyTo}) must ensure
+ * {@code RegisteredToolFunction} equality remains stable across rebuilds.
+ *
* @param toolName Tool name to remove
- * @param expected The expected AgentTool instance (identity comparison)
+ * @param expected The expected {@link AgentTool} instance, compared by reference
+ * ({@code ==}), not by {@link Object#equals}
* @return true if the tool was removed, false if it was already replaced or absent
*/
boolean removeToolIfSame(String toolName, AgentTool expected) {
- boolean removed = tools.remove(toolName, expected);
- if (removed) {
- registeredTools.remove(toolName);
+ Entry existing = entries.get(toolName);
+ if (existing != null && existing.tool() == expected) {
+ return entries.remove(toolName, existing);
}
- return removed;
+ return false;
}
/**
@@ -149,20 +170,20 @@ void removeTools(Set toolNames) {
* @param target The target registry to copy tools to
*/
void copyTo(ToolRegistry target) {
- for (Map.Entry entry : tools.entrySet()) {
- String toolName = entry.getKey();
- AgentTool tool = entry.getValue();
- RegisteredToolFunction registered = registeredTools.get(toolName);
- target.registerTool(
+ for (Map.Entry e : entries.entrySet()) {
+ String toolName = e.getKey();
+ Entry entry = e.getValue();
+ target.entries.put(
toolName,
- tool,
- registered == null
- ? null
- : new RegisteredToolFunction(
- tool,
- registered.getExtendedModel(),
- registered.getMcpClientName(),
- registered.getPresetParameters()));
+ new Entry(
+ entry.tool(),
+ entry.registered() == null
+ ? null
+ : new RegisteredToolFunction(
+ entry.tool(),
+ entry.registered().getExtendedModel(),
+ entry.registered().getMcpClientName(),
+ entry.registered().getPresetParameters())));
}
}
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
index c96bb653c9..1355af70b8 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
@@ -535,6 +535,46 @@ public void setChunkCallback(BiConsumer callback)
/**
* Execute a tool with the given parameters.
*
+ * Execution semantics: This method routes through the same infrastructure as
+ * {@code callTools} — scheduling, timeout, retry, and the global
+ * {@code GracefulShutdownManager} shutdown guard. Previously this overload ran synchronously
+ * on the caller's thread with no timeout or retry.
+ *
+ *
Default timeout: when no {@code ToolkitConfig.executionConfig()} is set (or it
+ * sets no timeout), {@link ExecutionConfig#TOOL_DEFAULTS} applies a 5-minute per-call
+ * timeout. Tools that legitimately run longer (human approval, external execution, sub-agent
+ * delegation) must configure a longer timeout on the toolkit, or supply a per-call
+ * {@link ExecutionConfig} via {@link #callTool(ToolCallParam, ExecutionConfig)}.
+ *
+ *
Retry semantics: retry only fires on the timeout {@code RuntimeException}
+ * emitted by the timeout layer, never on tool failures. Tool exceptions are caught and
+ * converted into a normal {@link ToolResultBlock#error} completion before the retry layer
+ * runs, and the shutdown guard runs after retry so shutdown signals bypass
+ * {@code retryWhen} as well. {@code maxAttempts > 1} has no effect on a failing tool —
+ * only on a timeout. Callers that depend on exactly-once execution should still note that
+ * non-idempotent tools may be re-invoked when a configured timeout fires.
+ *
+ *
Scheduling hop: Execution subscribes on the toolkit's executor (or
+ * {@code Schedulers.boundedElastic()} when none is configured) via {@code subscribeOn}. The
+ * previous implementation ran directly on the caller's thread. Callers that rely on
+ * thread-local state or security context propagated from the calling thread should migrate
+ * those values into the {@code ToolCallParam} or toolkit configuration, as they will no
+ * longer be visible on the execution thread.
+ *
+ *
Exception-as-result contract: Any exception thrown by the tool, timeouts after
+ * retry is exhausted, or the shutdown guard firing — all are caught and materialised as a
+ * normal {@link ToolResultBlock} with {@link ToolResultBlock#error(String)}, never
+ * propagated upstream.
+ *
+ *
Content/input contract: Schema validation reads {@code ToolUseBlock.content},
+ * while execution merges {@code ToolCallParam.input} (if set) over {@code ToolUseBlock.input}.
+ * When you build a {@code ToolCallParam} with only {@code input} populated, also set
+ * {@code ToolUseBlock.content} to the JSON form of that input, or schema validation will
+ * reject the call.
+ *
+ *
Unlike {@code callTools}, this path never sees an agent-level {@code ExecutionConfig}
+ * or runtime context — only the toolkit-level and per-call configs apply.
+ *
*
Example usage:
*
*
{@code
@@ -557,7 +597,44 @@ public void setChunkCallback(BiConsumer callback)
* @return Mono containing execution result
*/
public Mono callTool(ToolCallParam param) {
- return executor.execute(param);
+ return executor.executeWithInfrastructure(param, resolveToolExecutionConfig(null));
+ }
+
+ /**
+ * Execute a tool with a per-call {@link ExecutionConfig} override. Use this when the
+ * toolkit-level defaults are inappropriate for a single invocation — for example, a
+ * long-running approval tool that needs a 30-minute timeout, or a tool that should run
+ * without any timeout (supply {@code ExecutionConfig.builder().noTimeout().build()} — see
+ * {@link ExecutionConfig#NO_TIMEOUT}).
+ *
+ * @param param Tool call parameters containing execution information
+ * @param perCallConfig Execution config to use for this call; takes precedence over the
+ * toolkit-level config on a field-by-field basis (see {@link
+ * ExecutionConfig#mergeConfigs})
+ * @return Mono containing execution result
+ */
+ public Mono callTool(ToolCallParam param, ExecutionConfig perCallConfig) {
+ return executor.executeWithInfrastructure(param, resolveToolExecutionConfig(perCallConfig));
+ }
+
+ /**
+ * Resolve the effective execution config for a tool call: per-call > toolkit-level >
+ * {@link ExecutionConfig#TOOL_DEFAULTS}. A {@code perCallConfig} of {@code null} means
+ * "no per-call override" and the toolkit-level config is used directly (still falling back
+ * to TOOL_DEFAULTS for unset fields).
+ *
+ * Tool-specific, unlike {@link #callTools}'s resolution which also layers agent-level
+ * config — {@code callTool} does not see {@code agentExecutionConfig} and callers that
+ * need to override agent-level values should supply them here explicitly.
+ */
+ private ExecutionConfig resolveToolExecutionConfig(ExecutionConfig perCallConfig) {
+ ExecutionConfig base =
+ ExecutionConfig.mergeConfigs(
+ config.getExecutionConfig(), ExecutionConfig.TOOL_DEFAULTS);
+ if (perCallConfig == null) {
+ return base;
+ }
+ return ExecutionConfig.mergeConfigs(perCallConfig, base);
}
/**
diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ExecutionConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ExecutionConfigTest.java
index 6b6229c8ab..809f6f84df 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/model/ExecutionConfigTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/model/ExecutionConfigTest.java
@@ -15,11 +15,14 @@
*/
package io.agentscope.core.model;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.model.transport.HttpTransportException;
import java.net.SocketException;
+import java.time.Duration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -83,6 +86,74 @@ void shouldRetryModelHttpExceptionWithoutStatusCodeWrappingIoError() {
assertTrue(ExecutionConfig.RETRYABLE_ERRORS.test(wrapped));
}
+ @Test
+ @DisplayName("Should reject non-sentinel negative durations in Builder.timeout()")
+ void shouldRejectNonSentinelNegativeDurations() {
+ IllegalArgumentException ex =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> ExecutionConfig.builder().timeout(Duration.ofMillis(-500)));
+ assertTrue(
+ ex.getMessage().contains("positive"),
+ "Message must indicate the value must be positive");
+ }
+
+ @Test
+ @DisplayName("Should reject Duration.ZERO in Builder.timeout()")
+ void shouldRejectZeroDuration() {
+ IllegalArgumentException ex =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> ExecutionConfig.builder().timeout(Duration.ZERO));
+ assertTrue(
+ ex.getMessage().contains("positive"),
+ "Message must indicate the value must be positive");
+ }
+
+ @Test
+ @DisplayName(
+ "mergeConfigs(noTimeout, withTimeout) must keep the NO_TIMEOUT sentinel in primary"
+ + " position")
+ void mergeConfigsNoTimeoutPrimaryKeepsSentinel() {
+ ExecutionConfig noTimeout = ExecutionConfig.builder().noTimeout().build();
+ ExecutionConfig withTimeout =
+ ExecutionConfig.builder().timeout(Duration.ofSeconds(30)).build();
+
+ ExecutionConfig merged = ExecutionConfig.mergeConfigs(noTimeout, withTimeout);
+
+ assertTrue(merged.isTimeoutDisabled(), "NO_TIMEOUT sentinel must survive merge");
+ }
+
+ @Test
+ @DisplayName(
+ "mergeConfigs(withTimeout, noTimeout) must keep the positive timeout in primary"
+ + " position")
+ void mergeConfigsPositiveTimeoutPrimaryOverridesNoTimeout() {
+ ExecutionConfig withTimeout =
+ ExecutionConfig.builder().timeout(Duration.ofSeconds(30)).build();
+ ExecutionConfig noTimeout = ExecutionConfig.builder().noTimeout().build();
+
+ ExecutionConfig merged = ExecutionConfig.mergeConfigs(withTimeout, noTimeout);
+
+ assertEquals(
+ Duration.ofSeconds(30),
+ merged.getTimeout(),
+ "Positive timeout in primary position must override fallback NO_TIMEOUT");
+ }
+
+ @Test
+ @DisplayName("mergeConfigs(null timeout primary, noTimeout fallback) should inherit NO_TIMEOUT")
+ void mergeConfigsNullTimeoutInheritsNoTimeoutFromFallback() {
+ ExecutionConfig primary = ExecutionConfig.builder().build(); // timeout = null
+ ExecutionConfig fallback = ExecutionConfig.builder().noTimeout().build();
+
+ ExecutionConfig merged = ExecutionConfig.mergeConfigs(primary, fallback);
+
+ assertTrue(
+ merged.isTimeoutDisabled(),
+ "Null (unset) primary should inherit NO_TIMEOUT from fallback");
+ }
+
private static final class TestModelHttpException extends RuntimeException
implements ModelHttpException {
diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java
index beff260958..aef952db8a 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java
@@ -363,6 +363,24 @@ void shouldRetryAfterEmptyContentChunks() {
assertEquals(2, attemptCount.get());
}
+ @Test
+ @DisplayName(
+ "Should skip timeout when NO_TIMEOUT sentinel is configured in production"
+ + " ModelUtils.applyTimeoutAndRetry")
+ void shouldSkipTimeoutWithNoTimeoutSentinelInProductionCode() {
+ Flux slowSource =
+ Flux.just(createMockResponse()).delayElements(Duration.ofMillis(500));
+
+ ExecutionConfig config = ExecutionConfig.builder().noTimeout().build();
+ GenerateOptions options = GenerateOptions.builder().executionConfig(config).build();
+
+ StepVerifier.create(
+ ModelUtils.applyTimeoutAndRetry(
+ slowSource, options, null, "test-model", "test"))
+ .expectNextCount(1)
+ .verifyComplete();
+ }
+
// Helper methods to create test models
/**
@@ -462,7 +480,7 @@ private Flux applyTimeoutAndRetry(
// Apply timeout if configured
Duration timeout = executionConfig.getTimeout();
- if (timeout != null) {
+ if (timeout != null && !executionConfig.isTimeoutDisabled()) {
responseFlux =
responseFlux.timeout(
timeout,
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java
index d85f76fb36..6c46250d3d 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java
@@ -29,6 +29,7 @@
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.model.ExecutionConfig;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import io.agentscope.core.tool.mcp.McpClientWrapperTestSupport;
@@ -38,6 +39,7 @@
import io.agentscope.core.util.JsonUtils;
import io.modelcontextprotocol.spec.McpSchema;
import java.lang.reflect.Type;
+import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -1299,7 +1301,6 @@ void testRegistrationPropagateMetaOverridesWrapper() {
McpClientWrapper mcpClientWrapper =
McpClientWrapperTestSupport.mockWrapper("external-mcp-client", true);
when(mcpClientWrapper.initialize()).thenReturn(Mono.empty());
- // Wrapper explicitly allows propagation; registration-level setting must win
McpSchema.Tool mcpTool = mock(McpSchema.Tool.class);
when(mcpTool.name()).thenReturn("external_tool");
@@ -1309,6 +1310,7 @@ void testRegistrationPropagateMetaOverridesWrapper() {
new McpSchema.JsonSchema("object", Map.of(), List.of(), null, null, null));
when(mcpClientWrapper.listTools()).thenReturn(Mono.just(List.of(mcpTool)));
+ // Wrapper explicitly allows propagation; registration-level setting must win
toolkit.registration().mcpClient(mcpClientWrapper).propagateMeta(false).apply();
AgentTool tool = toolkit.getTool("external_tool");
@@ -1371,4 +1373,268 @@ void testRegistrationPerToolPropagateMetaRejectsNullName() {
IllegalArgumentException.class,
() -> toolkit.registration().propagateMeta(null, false));
}
+
+ @Test
+ @DisplayName(
+ "callTool single should populate id and name on ToolResultBlock (was null before fix)")
+ void testCallToolSinglePopulatesIdAndName() {
+ toolkit.registerTool(sampleTools);
+
+ Map input = Map.of("a", 2, "b", 3);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-001")
+ .name("add")
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build();
+
+ ToolResultBlock result =
+ toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block();
+
+ assertNotNull(result);
+ assertEquals("call-single-001", result.getId());
+ assertEquals("add", result.getName());
+ }
+
+ @Test
+ @DisplayName("callTool single should propagate id and name on error results too")
+ void testCallToolSingleErrorResultAlsoHasIdAndName() {
+ toolkit.registerTool(sampleTools);
+
+ Map errorInput = Map.of("message", "boom");
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-err")
+ .name("error_tool")
+ .input(errorInput)
+ .content(JsonUtils.getJsonCodec().toJson(errorInput))
+ .build();
+
+ ToolResultBlock result =
+ toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block();
+
+ assertNotNull(result);
+ assertEquals("call-single-err", result.getId());
+ assertEquals("error_tool", result.getName());
+ }
+
+ @Test
+ @DisplayName(
+ "callTool single should prefer ToolCallParam.input over ToolUseBlock.input when both"
+ + " are set")
+ void testCallToolSingleParamInputPrecedenceOverToolUseBlock() {
+ toolkit.registerTool(sampleTools);
+
+ Map toolUseInput = Map.of("a", 2, "b", 3);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-param-priority")
+ .name("add")
+ .input(toolUseInput)
+ .content(JsonUtils.getJsonCodec().toJson(toolUseInput))
+ .build();
+
+ ToolCallParam param =
+ ToolCallParam.builder()
+ .toolUseBlock(toolCall)
+ .input(Map.of("a", 100, "b", 200))
+ .build();
+
+ ToolResultBlock result = toolkit.callTool(param).block();
+
+ assertNotNull(result);
+ assertEquals("call-single-param-priority", result.getId());
+ assertEquals("add", result.getName());
+ assertEquals("300", ToolTestUtils.extractContent(result));
+ }
+
+ @Test
+ @DisplayName(
+ "callTool single with param.input but no ToolUseBlock.content should fail"
+ + " schema validation (validation reads content, not merged input)")
+ void testCallToolSingleOnlyParamInputNoContentFailsValidation() {
+ // Pins current behavior, not a desired end state: executeCore validates
+ // ToolUseBlock.content while execution merges ToolCallParam.input, so a
+ // call with only param.input and no content is rejected. When executeCore
+ // is fixed to validate against the merged input, this test must be updated
+ // to assert success instead of failure.
+ toolkit.registerTool(sampleTools);
+
+ Map paramInput = Map.of("a", 100, "b", 200);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-no-content")
+ .name("add")
+ .input(paramInput)
+ .build();
+
+ ToolCallParam param =
+ ToolCallParam.builder().toolUseBlock(toolCall).input(paramInput).build();
+
+ ToolResultBlock result = toolkit.callTool(param).block();
+
+ assertNotNull(result);
+ assertEquals("call-single-no-content", result.getId());
+ assertEquals("add", result.getName());
+ assertTrue(
+ isErrorResult(result), "Expected validation error, got: " + getResultText(result));
+ assertTrue(
+ getResultText(result).contains("Parameter validation failed"),
+ "Expected 'Parameter validation failed', got: " + getResultText(result));
+ }
+
+ @Test
+ @DisplayName("callTool(ToolCallParam, null) should behave same as single-arg callTool")
+ void testCallToolPerCallConfigNullIsSameAsSingleArg() {
+ toolkit.registerTool(sampleTools);
+
+ Map input = Map.of("a", 3, "b", 4);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-null-config")
+ .name("add")
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build();
+
+ ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolCall).input(input).build();
+
+ ToolResultBlock result = toolkit.callTool(param, null).block();
+
+ assertNotNull(result);
+ assertEquals("call-single-null-config", result.getId());
+ assertEquals("add", result.getName());
+ assertEquals("7", ToolTestUtils.extractContent(result));
+ }
+
+ @Test
+ @DisplayName("callTool(ToolCallParam, ExecutionConfig) should respect per-call config")
+ void testCallToolPerCallConfigTakesEffect() {
+ toolkit.registerTool(sampleTools);
+
+ Map input = Map.of("a", 10, "b", 20);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-percall-config")
+ .name("add")
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build();
+
+ ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolCall).input(input).build();
+
+ ExecutionConfig perCallConfig =
+ ExecutionConfig.builder().timeout(Duration.ofMinutes(10)).maxAttempts(1).build();
+
+ ToolResultBlock result = toolkit.callTool(param, perCallConfig).block();
+
+ assertNotNull(result);
+ assertEquals("call-percall-config", result.getId());
+ assertEquals("add", result.getName());
+ assertEquals("30", ToolTestUtils.extractContent(result));
+ }
+
+ @Test
+ @DisplayName(
+ "noTimeout() should override a short toolkit-level timeout so a slow tool completes")
+ void testNoTimeoutOverridesShortToolkitTimeout() {
+ ExecutionConfig toolkitTimeout =
+ ExecutionConfig.builder().timeout(Duration.ofMillis(100)).maxAttempts(1).build();
+ ToolkitConfig config = ToolkitConfig.builder().executionConfig(toolkitTimeout).build();
+ Toolkit shortTimeoutToolkit = new Toolkit(config);
+ shortTimeoutToolkit.registerTool(new SlowTool());
+
+ Map input = Map.of("delayMs", 300);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-no-timeout-ok")
+ .name("slow")
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build();
+ ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolCall).input(input).build();
+
+ ExecutionConfig noTimeoutConfig =
+ ExecutionConfig.builder().noTimeout().maxAttempts(1).build();
+
+ ToolResultBlock result = shortTimeoutToolkit.callTool(param, noTimeoutConfig).block();
+
+ assertNotNull(result);
+ assertEquals("\"done\"", ToolTestUtils.extractContent(result));
+ assertFalse(
+ isErrorResult(result),
+ "NO_TIMEOUT must disable the timeout operator on a 100ms toolkit-level timeout");
+ }
+
+ @Test
+ @DisplayName(
+ "A slow tool must timeout when no noTimeout() overrides a short toolkit-level timeout")
+ void testSlowToolTimesOutUnderShortToolkitTimeoutWithoutNoTimeout() {
+ ExecutionConfig toolkitTimeout =
+ ExecutionConfig.builder().timeout(Duration.ofMillis(100)).maxAttempts(1).build();
+ ToolkitConfig config = ToolkitConfig.builder().executionConfig(toolkitTimeout).build();
+ Toolkit shortTimeoutToolkit = new Toolkit(config);
+ shortTimeoutToolkit.registerTool(new SlowTool());
+
+ Map input = Map.of("delayMs", 300);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-short-timeout")
+ .name("slow")
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build();
+ ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolCall).input(input).build();
+
+ ToolResultBlock result = shortTimeoutToolkit.callTool(param, null).block();
+
+ assertNotNull(result);
+ assertTrue(
+ isErrorResult(result),
+ "100ms timeout must trigger on a 300ms tool without noTimeout()");
+ }
+
+ @Test
+ @DisplayName("isTimeoutDisabled() — null, positive and NO_TIMEOUT")
+ void testIsTimeoutDisabled() {
+ assertFalse(
+ ExecutionConfig.builder().build().isTimeoutDisabled(),
+ "null timeout should not be disabled");
+ assertFalse(
+ ExecutionConfig.builder()
+ .timeout(Duration.ofMinutes(1))
+ .build()
+ .isTimeoutDisabled(),
+ "positive timeout should not be disabled");
+ assertTrue(
+ ExecutionConfig.builder().noTimeout().build().isTimeoutDisabled(),
+ "NO_TIMEOUT should be recognised as disabled");
+ assertTrue(
+ ExecutionConfig.builder()
+ .timeout(ExecutionConfig.NO_TIMEOUT)
+ .build()
+ .isTimeoutDisabled(),
+ "explicit NO_TIMEOUT should be recognised as disabled");
+ }
+
+ @Test
+ @DisplayName("Builder.timeout() should reject non-sentinel negative durations")
+ void testBuilderRejectsStrayNegativeTimeout() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> ExecutionConfig.builder().timeout(Duration.ofSeconds(-30)),
+ "stray negative timeout should be rejected");
+ }
+
+ // Slow tool: returns result after a configurable delay, used to verify
+ // NO_TIMEOUT correctly disables the timeout operator.
+ public static class SlowTool {
+ @io.agentscope.core.tool.Tool(name = "slow", description = "Delay then return 'done'")
+ public Mono slow(
+ @io.agentscope.core.tool.ToolParam(name = "delayMs", description = "Delay in ms")
+ long delayMs) {
+ return Mono.delay(Duration.ofMillis(delayMs)).thenReturn("done");
+ }
+ }
}
diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java
index f346eb812b..e7cc8aac04 100644
--- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java
+++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java
@@ -36,6 +36,7 @@
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.model.transport.ProxyConfig;
import java.io.IOException;
+import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@@ -355,14 +356,28 @@ public OpenAIResponsesChatModel build() {
String resolvedBaseUrl =
ModelProviderSupport.firstNonBlank(
effectiveOptions.getBaseUrl(), System.getenv("OPENAI_BASE_URL"));
+ // When timeout is disabled, pass null so the SDK uses its own default.
+ // This differs from the tool / model-flux paths where noTimeout() is genuinely
+ // unbounded — here it degrades to the SDK's own default request timeout.
+ ExecutionConfig exec = effectiveOptions.getExecutionConfig();
+ Duration clientTimeout = null;
+ if (exec != null) {
+ if (exec.isTimeoutDisabled()) {
+ log.warn(
+ "noTimeout() is set for model '{}' on the openai-official provider. "
+ + "Unlike tool / model-flux paths this is not truly unbounded: "
+ + "the SDK's own default request timeout will apply.",
+ modelName);
+ } else {
+ clientTimeout = exec.getTimeout();
+ }
+ }
OpenAIClient client =
OpenAISdkClientFactory.createClient(
resolvedApiKey,
resolvedBaseUrl,
additionalHeaders,
- effectiveOptions.getExecutionConfig() != null
- ? effectiveOptions.getExecutionConfig().getTimeout()
- : null,
+ clientTimeout,
proxyConfig);
OpenAIResponsesChatModel model =
diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java
index f0ac352f1e..78a435ed1d 100644
--- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java
+++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java
@@ -731,4 +731,65 @@ void nonRetryableErrorNotRetried() {
verify(svc, times(1)).create(any(ResponseCreateParams.class));
}
}
+
+ // ── No-timeout sentinel ─────────────────────────────────────────
+
+ @Nested
+ class NoTimeoutSentinel {
+
+ @Test
+ void noTimeoutDoesNotCrashBuilderAndPreservesNoTimeoutInConfig() {
+ ExecutionConfig noTimeoutExec = ExecutionConfig.builder().noTimeout().build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .apiKey("test-key")
+ .modelName(MODEL_NAME)
+ .executionConfig(noTimeoutExec)
+ .build();
+
+ OpenAIResponsesChatModel model =
+ OpenAIResponsesChatModel.builder()
+ .apiKey("test-key")
+ .modelName(MODEL_NAME)
+ .generateOptions(options)
+ .build();
+
+ assertNotNull(model, "Model must build successfully with noTimeout()");
+ GenerateOptions configured = model.getConfiguredOptions();
+ assertNotNull(configured);
+ ExecutionConfig execConfig = configured.getExecutionConfig();
+ assertNotNull(execConfig);
+ assertTrue(
+ execConfig.isTimeoutDisabled(),
+ "NO_TIMEOUT sentinel must survive the Builder and not crash client"
+ + " construction");
+ }
+
+ @Test
+ void noTimeoutViaBuilderTimeoutNoTimeoutPassesNullToSdkFactory() {
+ ExecutionConfig noTimeoutExec =
+ ExecutionConfig.builder().timeout(ExecutionConfig.NO_TIMEOUT).build();
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .apiKey("test-key")
+ .modelName(MODEL_NAME)
+ .executionConfig(noTimeoutExec)
+ .build();
+
+ OpenAIResponsesChatModel model =
+ OpenAIResponsesChatModel.builder()
+ .apiKey("test-key")
+ .modelName(MODEL_NAME)
+ .generateOptions(options)
+ .build();
+
+ assertNotNull(
+ model,
+ "Model must build when NO_TIMEOUT is set via timeout(NO_TIMEOUT) — the guard"
+ + " must prevent the sentinel from reaching the SDK factory");
+ assertTrue(
+ model.getConfiguredOptions().getExecutionConfig().isTimeoutDisabled(),
+ "isTimeoutDisabled() must return true after building with NO_TIMEOUT");
+ }
+ }
}
diff --git a/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/main/java/io/agentscope/core/embedding/EmbeddingUtils.java b/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/main/java/io/agentscope/core/embedding/EmbeddingUtils.java
index f23b252824..4ed0fc33c5 100644
--- a/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/main/java/io/agentscope/core/embedding/EmbeddingUtils.java
+++ b/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/main/java/io/agentscope/core/embedding/EmbeddingUtils.java
@@ -47,10 +47,13 @@ private EmbeddingUtils() {
*
* Timeout Behavior:
*
- * - If timeout is configured, the entire request will fail if it exceeds the
- * specified duration
+ *
- If timeout is configured (and not disabled), the entire request will fail if
+ * it exceeds the specified duration
*
- Timeout triggers an EmbeddingException with details about the timeout duration
- *
- If no timeout is configured, requests can run indefinitely
+ *
- If no timeout is configured, or the timeout is disabled via
+ * {@link ExecutionConfig#NO_TIMEOUT}, requests can run indefinitely
+ *
- Use {@link ExecutionConfig#isTimeoutDisabled()} to check before passing the
+ * raw duration to downstream consumers that do not understand the sentinel
*
*
* Retry Behavior:
@@ -83,9 +86,9 @@ public static Mono applyTimeoutAndRetry(
return embeddingMono;
}
- // Apply timeout if configured
+ // Apply timeout if configured (skip when NO_TIMEOUT sentinel is set)
Duration timeout = config.getTimeout();
- if (timeout != null) {
+ if (timeout != null && !config.isTimeoutDisabled()) {
embeddingMono =
embeddingMono.timeout(
timeout,
diff --git a/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/test/java/io/agentscope/core/embedding/EmbeddingUtilsTest.java b/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/test/java/io/agentscope/core/embedding/EmbeddingUtilsTest.java
index 57faa49de3..e34f670f24 100644
--- a/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/test/java/io/agentscope/core/embedding/EmbeddingUtilsTest.java
+++ b/agentscope-extensions/agentscope-extensions-rag/agentscope-extensions-rag-simple/src/test/java/io/agentscope/core/embedding/EmbeddingUtilsTest.java
@@ -204,4 +204,42 @@ void testBatchTimeout() {
StepVerifier.create(result).expectError(EmbeddingException.class).verify();
}
+
+ @Test
+ @DisplayName("Should skip timeout when NO_TIMEOUT sentinel overrides a short parent timeout")
+ void testNoTimeoutSentinelSkipsTimeout() {
+ ExecutionConfig shortTimeout =
+ ExecutionConfig.builder().timeout(Duration.ofMillis(100)).build();
+ ExecutionConfig noTimeout = ExecutionConfig.builder().noTimeout().build();
+ ExecutionConfig merged = ExecutionConfig.mergeConfigs(noTimeout, shortTimeout);
+
+ double[] testEmbedding = new double[] {0.1, 0.2, 0.3};
+
+ // A slow Mono that would time out under the 100ms parent timeout
+ Mono slowMono = Mono.just(testEmbedding).delayElement(Duration.ofMillis(300));
+
+ Mono result =
+ EmbeddingUtils.applyTimeoutAndRetry(
+ slowMono, merged, "test-model", "test-provider", log);
+
+ // Must complete successfully despite the delay — NO_TIMEOUT sentinel skips the guard
+ StepVerifier.create(result).expectNext(testEmbedding).verifyComplete();
+ }
+
+ @Test
+ @DisplayName(
+ "Should fail with timeout when 300ms delay exceeds 100ms limit (without NO_TIMEOUT)")
+ void testShortTimeoutExpiresSlowMono() {
+ ExecutionConfig shortTimeout =
+ ExecutionConfig.builder().timeout(Duration.ofMillis(100)).maxAttempts(1).build();
+
+ double[] testEmbedding = new double[] {0.1, 0.2, 0.3};
+ Mono slowMono = Mono.just(testEmbedding).delayElement(Duration.ofMillis(300));
+
+ Mono result =
+ EmbeddingUtils.applyTimeoutAndRetry(
+ slowMono, shortTimeout, "test-model", "test-provider", log);
+
+ StepVerifier.create(result).expectError(EmbeddingException.class).verify();
+ }
}
diff --git a/docs/v2/en/docs/change-log.md b/docs/v2/en/docs/change-log.md
index 99a35848c0..c7522440d9 100644
--- a/docs/v2/en/docs/change-log.md
+++ b/docs/v2/en/docs/change-log.md
@@ -159,6 +159,14 @@ Configure tracing through standard OpenTelemetry components instead:
The middleware reads `GlobalOpenTelemetry`, so the SDK must be registered before the agent uses the middleware. See [Middleware — OtelTracingMiddleware](/v2/en/docs/building-blocks/middleware#oteltracingmiddleware) for the required dependencies and a complete OTLP example with custom authentication headers.
+#### A.9 `ExecutionConfig.Builder.timeout()` validation tightened
+
+`ExecutionConfig.Builder.timeout(Duration)` now rejects `Duration.ZERO` and negative durations (other than the `NO_TIMEOUT` sentinel) with an `IllegalArgumentException`. Values that were previously accepted with undefined behavior are now intercepted at builder time.
+
+Use `.noTimeout()` when you need to disable the timeout.
+
+The validation also applies through `ExecutionConfig.mergeConfigs` — a config with an illegal timeout value that reaches the merge path will also throw.
+
---
### Part B — Recommended (`@Deprecated(forRemoval = true)`, still callable today)
diff --git a/docs/v2/zh/docs/change-log.md b/docs/v2/zh/docs/change-log.md
index 5e9d51db75..e12e47a9c0 100644
--- a/docs/v2/zh/docs/change-log.md
+++ b/docs/v2/zh/docs/change-log.md
@@ -159,6 +159,14 @@ TracerRegistry.register(TelemetryTracer.builder().tracer(tracer).build());
Middleware 从 `GlobalOpenTelemetry` 读取 SDK,因此必须先注册 SDK,再让 agent 使用 middleware。所需依赖、完整 OTLP 配置以及自定义认证 header 示例见 [Middleware — OtelTracingMiddleware](/v2/zh/docs/building-blocks/middleware#oteltracingmiddleware)。
+#### A.9 `ExecutionConfig.Builder.timeout()` 校验收紧
+
+`ExecutionConfig.Builder.timeout(Duration)` 现在拒绝 `Duration.ZERO` 和负值时长(`NO_TIMEOUT` 哨兵除外),直接抛出 `IllegalArgumentException`。之前传入 `Duration.ZERO` 或负值的行为为 undefined,现在这类值在 builder 调用时即被拦截。
+
+需要禁用 timeout 时,请使用 `.noTimeout()` 方法。
+
+该校验同时作用于 `ExecutionConfig.mergeConfigs` 路径——如果合并前的 config 中带有非法的 timeout 值,合并时也会触发异常。
+
---
### Part B —— 推荐迁移(`@Deprecated(forRemoval = true)`,仍可调用)