From aaa99c67ca431d5e82be9f011df9544b9ea33152 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Sun, 13 Sep 2026 14:47:28 +0800
Subject: [PATCH 01/25] fix(Toolkit): route callTool through
executeWithInfrastructure so id/name, timeout, retry and ShutdownGuard are
all applied
callTool(ToolCallParam) was delegating straight to ToolExecutor.execute(param),
a pure-execution path that bypassed executeWithInfrastructure entirely. As a
result the returned ToolResultBlock had null id and null name, and any
ExecutionConfig (timeout, retry) plus the graceful-shutdown guard were
silently ignored for single calls. callTools(...) always went through the
wrapper and worked correctly.
Changes:
- ToolExecutor: added executeWithInfrastructure(ToolCallParam, ExecutionConfig)
overload that preserves every field of the original param (notably input).
The existing 4-param overload (used by the batch path) now delegates to it,
so executeAll behaviour is unchanged. Visibility widened from private to
package-private.
- Toolkit.callTool: merges ExecutionConfig (toolkit-default > TOOL_DEFAULTS)
and routes through the new 2-param overload instead of execute().
- ToolkitTest: added two tests covering success and error paths to assert
id and name are populated on the returned ToolResultBlock.
Refs: #3114
# Conflicts:
# agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java
---
.../io/agentscope/core/tool/ToolExecutor.java | 39 ++++++++--
.../java/io/agentscope/core/tool/Toolkit.java | 6 +-
.../io/agentscope/core/tool/ToolkitTest.java | 71 ++++++++++++++++++-
3 files changed, 109 insertions(+), 7 deletions(-)
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..2466c8372e 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
@@ -450,15 +450,17 @@ private boolean isConcurrencySafe(ToolUseBlock toolCall, ToolRequestConfig reque
/**
* Execute a single tool call with infrastructure (scheduling, timeout, retry).
+ *
+ *
This overload is used by the batch path ({@code executeAll}) where only the tool use
+ * block, agent, and runtime context are available.
*/
- 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 +468,43 @@ private Mono executeWithInfrastructure(
.runtimeContext(agentRuntimeContext)
.build();
- // Get core execution
Mono execution = execute(param, requestConfig, internalChunkCallback);
- // Apply infrastructure layers
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(
+ e -> {
+ logger.warn("Tool call failed: {}", toolCall.getName(), e);
+ String errorMsg = ExceptionUtils.getErrorMessage(e);
+ return Mono.just(
+ ToolResultBlock.error("Tool execution failed: " + errorMsg)
+ .withIdAndName(toolCall.getId(), toolCall.getName()));
+ });
+ }
+
+ /**
+ * 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);
+
+ execution = applyScheduling(execution);
+ execution = applyTimeout(execution, executionConfig, toolCall);
+ execution = applyRetry(execution, executionConfig, toolCall);
+ execution = applyShutdownGuard(execution);
+
return execution
.map(result -> result.withIdAndName(toolCall.getId(), toolCall.getName()))
.onErrorResume(
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..086deed3a1 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
@@ -557,7 +557,11 @@ public void setChunkCallback(BiConsumer callback)
* @return Mono containing execution result
*/
public Mono callTool(ToolCallParam param) {
- return executor.execute(param);
+ ExecutionConfig effectiveConfig =
+ ExecutionConfig.mergeConfigs(
+ config.getExecutionConfig(), ExecutionConfig.TOOL_DEFAULTS);
+
+ return executor.executeWithInfrastructure(param, effectiveConfig);
}
/**
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..9e426d6e93 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
@@ -1299,7 +1299,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 +1308,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 +1371,73 @@ 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);
+
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-001")
+ .name("add")
+ .input(Map.of("a", 2, "b", 3))
+ .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);
+
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-err")
+ .name("error_tool")
+ .input(Map.of("message", "boom"))
+ .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);
+
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-single-param-priority")
+ .name("add")
+ .input(Map.of("a", 2, "b", 3))
+ .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));
+ }
}
From c36285587b09adac5920a29a23e2d1b7985f708c Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Fri, 25 Sep 2026 12:42:05 +0800
Subject: [PATCH 02/25] fix(test): add ToolUseBlock.content to pass upstream
schema validation
Upstream PR #3283 added ToolValidator.validateInput which uses
toolCall.getContent() for schema validation. The three new callTool
single-param tests only set .input(Map) but omitted .content(String),
causing silent validation failures (error ToolResultBlocks still get
id/name attached by executeWithInfrastructure, so the first two tests
passed by accident). Align ToolUseBlock construction with upstream
convention by setting .content(JsonUtils.getJsonCodec().toJson(input)).
---
.../java/io/agentscope/core/tool/ToolkitTest.java | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
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 9e426d6e93..c829908784 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
@@ -1378,11 +1378,13 @@ void testRegistrationPerToolPropagateMetaRejectsNullName() {
void testCallToolSinglePopulatesIdAndName() {
toolkit.registerTool(sampleTools);
+ Map input = Map.of("a", 2, "b", 3);
ToolUseBlock toolCall =
ToolUseBlock.builder()
.id("call-single-001")
.name("add")
- .input(Map.of("a", 2, "b", 3))
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
.build();
ToolResultBlock result =
@@ -1398,11 +1400,13 @@ void testCallToolSinglePopulatesIdAndName() {
void testCallToolSingleErrorResultAlsoHasIdAndName() {
toolkit.registerTool(sampleTools);
+ Map errorInput = Map.of("message", "boom");
ToolUseBlock toolCall =
ToolUseBlock.builder()
.id("call-single-err")
.name("error_tool")
- .input(Map.of("message", "boom"))
+ .input(errorInput)
+ .content(JsonUtils.getJsonCodec().toJson(errorInput))
.build();
ToolResultBlock result =
@@ -1420,11 +1424,13 @@ void testCallToolSingleErrorResultAlsoHasIdAndName() {
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(Map.of("a", 2, "b", 3))
+ .input(toolUseInput)
+ .content(JsonUtils.getJsonCodec().toJson(toolUseInput))
.build();
ToolCallParam param =
From cbdfcfeffd49a1bef4c36d09b8df6369c4d31de5 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Fri, 25 Sep 2026 12:49:14 +0800
Subject: [PATCH 03/25] docs(Toolkit): document callTool(ToolCallParam)
execution semantics
Routing callTool through executeWithInfrastructure now inherits the
Toolkit's ExecutionConfig (timeout, retry, shutdown guard). This is a
semantic change from before where the single-param overload had no
timeout or retry. Document the blast radius in javadoc so callers can
see the change from the API surface. Note that the default TOOL_DEFAULTS
uses maxAttempts(1), so retry is a no-op for standard toolkits.
---
.../src/main/java/io/agentscope/core/tool/Toolkit.java | 9 +++++++++
1 file changed, 9 insertions(+)
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 086deed3a1..cb96a62f55 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,15 @@ public void setChunkCallback(BiConsumer callback)
/**
* Execute a tool with the given parameters.
*
+ * Execution semantics: This method routes through the same
+ * infrastructure as {@code callTools}, so it inherits the toolkit's
+ * {@link ExecutionConfig} (timeout, retry, shutdown guard). Previously
+ * this overload had no timeout or retry — callers that depend on
+ * exactly-once execution should note that non-idempotent tools may be
+ * re-invoked on timeout when a custom {@code ToolkitConfig.executionConfig()}
+ * sets {@code maxAttempts > 1}. The default {@code TOOL_DEFAULTS} uses
+ * {@code maxAttempts(1)}, which is a no-op for retry.
+ *
*
Example usage:
*
*
{@code
From 292a7dadde02e703e36aa6540c43517d45532241 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Fri, 25 Sep 2026 13:17:09 +0800
Subject: [PATCH 04/25] =?UTF-8?q?docs(Toolkit):=20fix=20callTool=20javadoc?=
=?UTF-8?q?=20=E2=80=94=20correct=20shutdown=20guard=20attribution=20and?=
=?UTF-8?q?=20add=20exception-as-result=20contract?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previous revision wrongly listed shutdown guard under ExecutionConfig;
it is a global GracefulShutdownManager concern. Also document that all
errors (tool exceptions, exhausted retry timeouts, shutdown fires) are
converted into ToolResultBlock.error(...) via onErrorResume, never
propagated.
---
.../java/io/agentscope/core/tool/Toolkit.java | 20 +++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
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 cb96a62f55..338cbe8d6c 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
@@ -537,12 +537,20 @@ public void setChunkCallback(BiConsumer callback)
*
* Execution semantics: This method routes through the same
* infrastructure as {@code callTools}, so it inherits the toolkit's
- * {@link ExecutionConfig} (timeout, retry, shutdown guard). Previously
- * this overload had no timeout or retry — callers that depend on
- * exactly-once execution should note that non-idempotent tools may be
- * re-invoked on timeout when a custom {@code ToolkitConfig.executionConfig()}
- * sets {@code maxAttempts > 1}. The default {@code TOOL_DEFAULTS} uses
- * {@code maxAttempts(1)}, which is a no-op for retry.
+ * {@link ExecutionConfig} (timeout and retry) and participates in the
+ * global {@code GracefulShutdownManager} shutdown guard. Previously
+ * this overload had no timeout, retry, or shutdown participation.
+ * Callers that depend on exactly-once execution should note that
+ * non-idempotent tools may be re-invoked on timeout when a custom
+ * {@code ToolkitConfig.executionConfig()} sets {@code maxAttempts > 1}.
+ * The default {@code TOOL_DEFAULTS} uses {@code maxAttempts(1)}, which
+ * is a no-op for retry.
+ *
+ *
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.
*
*
Example usage:
*
From 2bcea6012e4792533b706dcd185194f3beb9e3fe Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Fri, 25 Sep 2026 14:00:49 +0800
Subject: [PATCH 05/25] docs(Toolkit): add scheduling hop note to callTool
javadoc
---
.../src/main/java/io/agentscope/core/tool/Toolkit.java | 9 +++++++++
1 file changed, 9 insertions(+)
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 338cbe8d6c..0263b7ff2e 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
@@ -546,6 +546,15 @@ public void setChunkCallback(BiConsumer callback)
* The default {@code TOOL_DEFAULTS} uses {@code maxAttempts(1)}, which
* is a no-op for retry.
*
+ * Scheduling hop: Execution now 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
From 63b85bdaa76e7543ab01585b1d19b482a9ec81bc Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 28 Sep 2026 19:24:05 +0800
Subject: [PATCH 06/25] docs(ToolExecutor): fix stale javadoc and align
execute* family infrastructure-layer descriptions
---
.../io/agentscope/core/tool/ToolExecutor.java | 22 +++++++++++++------
1 file changed, 15 insertions(+), 7 deletions(-)
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 2466c8372e..77bfbce637 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, or shutdown guard). 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,10 +455,12 @@ 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 ({@code executeAll}) where only the tool use
- * block, agent, and runtime context are available.
+ *
This overload is used by the batch path ({@link #executeAll(List, boolean,
+ * ExecutionConfig, Agent, RuntimeContext)}) where only the tool use block, agent, and
+ * runtime context are available.
*/
Mono executeWithInfrastructure(
ToolUseBlock toolCall,
From 36a573822b53e704b249f403849c5c205ee26a0d Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Wed, 30 Sep 2026 14:23:18 +0800
Subject: [PATCH 07/25] docs(ToolExecutor): address PR #3130 review comments on
javadoc
---
.../src/main/java/io/agentscope/core/tool/ToolExecutor.java | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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 77bfbce637..9fee986c43 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
@@ -156,7 +156,7 @@ private void invokeChunkCallback(
/**
* Execute a single tool call (core execution only: Tracer + {@link #executeCore};
- * no scheduling, timeout, retry, or shutdown guard). Use
+ * 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
@@ -459,8 +459,8 @@ private boolean isConcurrencySafe(ToolUseBlock toolCall, ToolRequestConfig reque
* 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)}) where only the tool use block, agent, and
- * runtime context are available.
+ * ExecutionConfig, Agent, RuntimeContext)}), which routes each {@link ToolUseBlock} with
+ * the infrastructure config, per-call request config, and chunk callback.
*/
Mono executeWithInfrastructure(
ToolUseBlock toolCall,
From ffac69a34e5e661e73d8d33f5da58ce491f6a8b8 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 21:36:46 +0800
Subject: [PATCH 08/25] =?UTF-8?q?fix(tool):=20address=20review=20feedback?=
=?UTF-8?q?=20=E2=80=94=20timeout=20opt-out,=20retry=20doc=20accuracy,=20i?=
=?UTF-8?q?nfrastructure=20dedup?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add callTool(ToolCallParam, ExecutionConfig) overload so callers can override
(or null-out) the 5-minute TOOL_DEFAULTS timeout per call; callTool now
merges per-call > toolkit > system-default on ExecutionConfig fields
- Extract duplicated infrastructure pipeline (scheduling → timeout → retry →
shutdown guard + id/name stamp + error-to-result) into a single private
applyInfrastructure(Mono, ExecutionConfig, ToolUseBlock) helper so the two
executeWithInfrastructure entry points can't drift apart again
- Fix callTool javadoc to accurately describe retry semantics: retry only fires
on timeout or shutdown signals, never on tool failures (executeCore converts
tool exceptions to ToolResultBlock.error completions before retry runs)
- Document the 5-minute default timeout and the new opt-out path explicitly
- Call out the content/input contract split — validation reads ToolUseBlock.content,
execution merges param.input over toolCall.input
- Note that callTool never sees agent-level ExecutionConfig (unlike callTools)
---
.../io/agentscope/core/tool/ToolExecutor.java | 35 ++++----
.../java/io/agentscope/core/tool/Toolkit.java | 85 +++++++++++++------
2 files changed, 80 insertions(+), 40 deletions(-)
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 9fee986c43..4947303ddd 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
@@ -478,21 +478,7 @@ Mono executeWithInfrastructure(
Mono execution = execute(param, requestConfig, internalChunkCallback);
- execution = applyScheduling(execution);
- execution = applyTimeout(execution, executionConfig, toolCall);
- execution = applyRetry(execution, executionConfig, toolCall);
- execution = applyShutdownGuard(execution);
-
- return execution
- .map(result -> result.withIdAndName(toolCall.getId(), toolCall.getName()))
- .onErrorResume(
- e -> {
- logger.warn("Tool call failed: {}", toolCall.getName(), e);
- String errorMsg = ExceptionUtils.getErrorMessage(e);
- return Mono.just(
- ToolResultBlock.error("Tool execution failed: " + errorMsg)
- .withIdAndName(toolCall.getId(), toolCall.getName()));
- });
+ return applyInfrastructure(execution, executionConfig, toolCall);
}
/**
@@ -508,6 +494,25 @@ Mono executeWithInfrastructure(
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 exceptions emitted by the
+ * infrastructure layers themselves — {@link #applyTimeout} and {@link #applyShutdownGuard}.
+ * Tool failures are converted to normal {@link ToolResultBlock#error} completions inside
+ * {@link #executeCore} before this pipeline runs, so {@code retryWhen} never sees them.
+ * "Retry" here means "retry on timeout or shutdown signal", never "retry on tool failure".
+ */
+ private Mono applyInfrastructure(
+ Mono execution,
+ ExecutionConfig executionConfig,
+ ToolUseBlock toolCall) {
execution = applyScheduling(execution);
execution = applyTimeout(execution, executionConfig, toolCall);
execution = applyRetry(execution, executionConfig, toolCall);
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 0263b7ff2e..6f94c3a754 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,31 +535,44 @@ public void setChunkCallback(BiConsumer callback)
/**
* Execute a tool with the given parameters.
*
- * Execution semantics: This method routes through the same
- * infrastructure as {@code callTools}, so it inherits the toolkit's
- * {@link ExecutionConfig} (timeout and retry) and participates in the
- * global {@code GracefulShutdownManager} shutdown guard. Previously
- * this overload had no timeout, retry, or shutdown participation.
- * Callers that depend on exactly-once execution should note that
- * non-idempotent tools may be re-invoked on timeout when a custom
- * {@code ToolkitConfig.executionConfig()} sets {@code maxAttempts > 1}.
- * The default {@code TOOL_DEFAULTS} uses {@code maxAttempts(1)}, which
- * is a no-op for retry.
- *
- *
Scheduling hop: Execution now 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.
+ *
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 timeout or shutdown signals, never on tool
+ * failures. Tool exceptions are caught and converted into a normal
+ * {@link ToolResultBlock#error} completion before the retry layer runs, so
+ * {@code maxAttempts > 1} has no effect on a failing tool — only on infrastructure-level
+ * aborts. Callers that depend on exactly-once execution should still note that
+ * non-idempotent tools may be re-invoked when a 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:
*
@@ -590,6 +603,28 @@ public Mono callTool(ToolCallParam param) {
return executor.executeWithInfrastructure(param, effectiveConfig);
}
+ /**
+ * 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 a {@link ExecutionConfig} with {@code timeout(null)}).
+ *
+ * @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) {
+ ExecutionConfig effectiveConfig =
+ ExecutionConfig.mergeConfigs(
+ perCallConfig,
+ ExecutionConfig.mergeConfigs(
+ config.getExecutionConfig(), ExecutionConfig.TOOL_DEFAULTS));
+
+ return executor.executeWithInfrastructure(param, effectiveConfig);
+ }
+
/**
* Execute multiple tools asynchronously with agent-level context (internal use by
* ReActAgent).
From a826bfd531164a7b6e6e005e451ab77c6e062f5c Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 22:07:09 +0800
Subject: [PATCH 09/25] test(tool): add regression test for content-validation
split
Add testCallToolSingleOnlyParamInputNoContentFailsValidation which builds
a ToolCallParam with param.input populated but ToolUseBlock.content empty,
and asserts the call is rejected with 'Parameter validation failed'. This
pins the documented contract: schema validation reads ToolUseBlock.content,
so callers must mirror input into content or validation fails.
Without this test, a future change that makes validation read the merged
input instead of content would silently break the documented contract.
---
.../io/agentscope/core/tool/ToolkitTest.java | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
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 c829908784..ffe2a75583 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
@@ -1446,4 +1446,34 @@ void testCallToolSingleParamInputPrecedenceOverToolUseBlock() {
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() {
+ 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));
+ }
}
From 0cf254f75f9fafe8e1abc078bf635d5f7f2cc9ca Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 22:14:06 +0800
Subject: [PATCH 10/25] fix(tool): opt-out timeout sentinel, ToolRegistry
atomicity, merge helper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bug: callTool(ToolCallParam, ExecutionConfig) couldn't truly opt out of
the 5-minute timeout inherited from TOOL_DEFAULTS because mergeConfigs
treats null as 'inherit from fallback'. Added ExecutionConfig.NO_TIMEOUT
sentinel (negative Duration.NANOS) and Builder.noTimeout() API.
applyTimeout now skips timeout for negative durations, matching its
null-handling.
Warning: ToolRegistry stored AgentTool and RegisteredToolFunction in two
separate ConcurrentHashMaps with a two-step put/remove — concurrent
readers could see tool != null but registered == null and lose preset
parameters. Consolidated into a single ConcurrentHashMap
with a record holding both fields, making put/remove atomic.
Info: Extracted resolveToolExecutionConfig(perCall) helper in Toolkit so
the three-level merge (perCall > toolkit > TOOL_DEFAULTS) is written once
instead of duplicated across the two callTool overloads.
---
.../core/model/ExecutionConfig.java | 23 ++++++-
.../io/agentscope/core/tool/ToolExecutor.java | 2 +-
.../io/agentscope/core/tool/ToolRegistry.java | 65 +++++++++++--------
.../java/io/agentscope/core/tool/Toolkit.java | 35 ++++++----
4 files changed, 83 insertions(+), 42 deletions(-)
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 1b269cc6ad..6e2a7a834e 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
@@ -152,6 +152,17 @@ 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 applyTimeout} / {@code applyTimeout}
+ * 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".
+ */
+ public static final Duration NO_TIMEOUT = Duration.ofNanos(-1);
+
/**
* Standard defaults for tool executions.
*
@@ -302,7 +313,7 @@ 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, or null to inherit from fallback
* @return this builder instance
*/
public Builder timeout(Duration timeout) {
@@ -310,6 +321,16 @@ public Builder timeout(Duration 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/tool/ToolExecutor.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java
index 4947303ddd..7f5bfc0897 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
@@ -541,7 +541,7 @@ private Mono applyScheduling(Mono execution) {
private Mono applyTimeout(
Mono execution, ExecutionConfig config, ToolUseBlock toolCall) {
- if (config == null || config.getTimeout() == null) {
+ if (config == null || config.getTimeout() == null || config.getTimeout().isNegative()) {
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..b6d4509dfb 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,8 +120,7 @@ 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);
}
/**
@@ -123,11 +132,11 @@ void removeTool(String toolName) {
* @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 +158,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 6f94c3a754..46b01d4dd3 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
@@ -596,18 +596,15 @@ public void setChunkCallback(BiConsumer callback)
* @return Mono containing execution result
*/
public Mono callTool(ToolCallParam param) {
- ExecutionConfig effectiveConfig =
- ExecutionConfig.mergeConfigs(
- config.getExecutionConfig(), ExecutionConfig.TOOL_DEFAULTS);
-
- return executor.executeWithInfrastructure(param, effectiveConfig);
+ 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 a {@link ExecutionConfig} with {@code timeout(null)}).
+ * 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
@@ -616,13 +613,27 @@ public Mono callTool(ToolCallParam param) {
* @return Mono containing execution result
*/
public Mono callTool(ToolCallParam param, ExecutionConfig perCallConfig) {
- ExecutionConfig effectiveConfig =
- ExecutionConfig.mergeConfigs(
- perCallConfig,
- ExecutionConfig.mergeConfigs(
- config.getExecutionConfig(), ExecutionConfig.TOOL_DEFAULTS));
+ return executor.executeWithInfrastructure(param, resolveToolExecutionConfig(perCallConfig));
+ }
- return executor.executeWithInfrastructure(param, effectiveConfig);
+ /**
+ * 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);
}
/**
From d4a081d751bbaed7bf28923b5a42178901c0e737 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 22:44:57 +0800
Subject: [PATCH 11/25] fix(tool): fix retry/shutdown javadoc, add per-call
config tests
Retry javadoc: shutdown guard runs *after* retry, so shutdown signals are never retried. Narrow retry claim to only timeout, not imeout or shutdown. Test: estCallToolPerCallConfigNullIsSameAsSingleArg, estCallToolPerCallConfigTakesEffect, estCallToolPerCallConfigNoTimeout for the per-call callTool(Param, ExecutionConfig) overload.
From 549af6cbe0941df5df3a2f7d6837bc8de2d7527c Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 22:47:30 +0800
Subject: [PATCH 12/25] fix(tool): final javadoc and test: retry claim,
callTool per-call coverage
---
.../io/agentscope/core/tool/ToolExecutor.java | 11 +--
.../java/io/agentscope/core/tool/Toolkit.java | 13 +--
.../io/agentscope/core/tool/ToolkitTest.java | 87 +++++++++++++++++++
3 files changed, 100 insertions(+), 11 deletions(-)
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 7f5bfc0897..32c1a2ba44 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
@@ -503,11 +503,12 @@ Mono executeWithInfrastructure(
* 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 exceptions emitted by the
- * infrastructure layers themselves — {@link #applyTimeout} and {@link #applyShutdownGuard}.
- * Tool failures are converted to normal {@link ToolResultBlock#error} completions inside
- * {@link #executeCore} before this pipeline runs, so {@code retryWhen} never sees them.
- * "Retry" here means "retry on timeout or shutdown signal", never "retry on tool failure".
+ *
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,
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 46b01d4dd3..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
@@ -546,12 +546,13 @@ public void setChunkCallback(BiConsumer callback)
* 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 timeout or shutdown signals, never on tool
- * failures. Tool exceptions are caught and converted into a normal
- * {@link ToolResultBlock#error} completion before the retry layer runs, so
- * {@code maxAttempts > 1} has no effect on a failing tool — only on infrastructure-level
- * aborts. Callers that depend on exactly-once execution should still note that
- * non-idempotent tools may be re-invoked when a timeout fires.
+ *
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
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 ffe2a75583..07f11db347 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;
@@ -1476,4 +1478,89 @@ void testCallToolSingleOnlyParamInputNoContentFailsValidation() {
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(
+ "callTool(ToolCallParam, ExecutionConfig) with noTimeout() should execute without"
+ + " timeout")
+ void testCallToolPerCallConfigNoTimeout() {
+ toolkit.registerTool(sampleTools);
+
+ Map input = Map.of("a", 1, "b", 2);
+ ToolUseBlock toolCall =
+ ToolUseBlock.builder()
+ .id("call-notimeout")
+ .name("add")
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build();
+
+ ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolCall).input(input).build();
+
+ ExecutionConfig perCallConfig =
+ ExecutionConfig.builder().noTimeout().maxAttempts(1).build();
+
+ ToolResultBlock result = toolkit.callTool(param, perCallConfig).block();
+
+ assertNotNull(result);
+ assertEquals("call-notimeout", result.getId());
+ assertEquals("add", result.getName());
+ assertEquals("3", ToolTestUtils.extractContent(result));
+
+ assertEquals(
+ ExecutionConfig.NO_TIMEOUT,
+ perCallConfig.getTimeout(),
+ "noTimeout() should set the sentinel value");
+ }
}
From 6ce5af4fa5f9dde8b4eca0adb134e4c281775cf5 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 22:54:21 +0800
Subject: [PATCH 13/25] docs(tool): pin identity semantics in removeToolIfSame
javadoc
== reference comparison is by design, not a copy-paste omission from old two-map code which used ConcurrentHashMap.remove(key, value) i.e. equals(). Also document Entry record equals dependency on RegisteredToolFunction stability.
---
.../java/io/agentscope/core/tool/ToolRegistry.java | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
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 b6d4509dfb..ca7603aabf 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
@@ -127,8 +127,20 @@ void removeTool(String 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 expected tool is compared 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 is
+ * intentional — this method 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
+ * {@link 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) {
From d47a113dfdeb9089beeaeece03bec745598e889c Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Thu, 1 Oct 2026 22:57:01 +0800
Subject: [PATCH 14/25] docs(test): flag that negative validation test pins
current gap, not end state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
executeCore validates ToolUseBlock.content while execution merges ToolCallParam.input. This test asserts the current split — when someone fixes executeCore to validate against the merged input, update this test to assert success instead of failure.
---
.../src/test/java/io/agentscope/core/tool/ToolkitTest.java | 5 +++++
1 file changed, 5 insertions(+)
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 07f11db347..dd0bbbdf15 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
@@ -1454,6 +1454,11 @@ void testCallToolSingleParamInputPrecedenceOverToolUseBlock() {
"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);
From 28f74847ceb2094b0b9a13d890aee17c98a1f88b Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Sat, 3 Oct 2026 21:19:32 +0800
Subject: [PATCH 15/25] fix(tool): add isTimeoutDisabled() helper and unify
NO_TIMEOUT checks across ToolExecutor and ModelUtils
- Add ExecutionConfig.isTimeoutDisabled() convenience method - Use isTimeoutDisabled() in ToolExecutor.applyTimeout instead of raw isNegative() - Use isTimeoutDisabled() in ModelUtils.applyTimeoutAndRetry to correctly skip timeout when NO_TIMEOUT is set - Fix stale javadoc references in NO_TIMEOUT sentinel description - Refine removeToolIfSame javadoc for identity/CAS semantics clarity
---
.../io/agentscope/core/model/ExecutionConfig.java | 13 ++++++++++++-
.../java/io/agentscope/core/model/ModelUtils.java | 2 +-
.../java/io/agentscope/core/tool/ToolExecutor.java | 2 +-
.../java/io/agentscope/core/tool/ToolRegistry.java | 10 +++++-----
4 files changed, 19 insertions(+), 8 deletions(-)
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 6e2a7a834e..9ecc9c21dc 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
@@ -154,7 +154,8 @@ private static boolean isRetryableError(Throwable error) {
/**
* Sentinel value for {@link #timeout} meaning "no timeout". A negative duration is never
- * produced by normal usage and is recognised by {@code applyTimeout} / {@code applyTimeout}
+ * 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
@@ -192,6 +193,16 @@ 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.
+ *
+ * @return true if timeout is disabled via {@link #NO_TIMEOUT}
+ */
+ public boolean isTimeoutDisabled() {
+ return timeout != null && timeout.isNegative();
+ }
+
/**
* Gets the maximum number of attempts.
*
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 27ba87b82d..c028d2dfa7 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
@@ -82,7 +82,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 32c1a2ba44..ecaf3fab78 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
@@ -542,7 +542,7 @@ private Mono applyScheduling(Mono execution) {
private Mono applyTimeout(
Mono execution, ExecutionConfig config, ToolUseBlock toolCall) {
- if (config == null || config.getTimeout() == null || config.getTimeout().isNegative()) {
+ 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 ca7603aabf..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
@@ -127,13 +127,13 @@ void removeTool(String 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 expected tool is compared 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 is
- * intentional — this method guards against accidental removal of a tool that was
+ *
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
- * {@link Entry} record's {@link Object#equals}, which compares both 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.
From 772f1b60ca0c7f1933d0920594ca105254438f64 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Sat, 3 Oct 2026 22:05:26 +0800
Subject: [PATCH 16/25] fix(tool): tighten isTimeoutDisabled to sentinel, add
builder validation, guard EmbeddingUtils, add slow-tool regression test
---
.../core/model/ExecutionConfig.java | 23 ++++++-
.../io/agentscope/core/tool/ToolExecutor.java | 1 +
.../core/model/ModelTimeoutRetryTest.java | 2 +-
.../io/agentscope/core/tool/ToolkitTest.java | 68 +++++++++++++++----
.../core/embedding/EmbeddingUtils.java | 4 +-
5 files changed, 79 insertions(+), 19 deletions(-)
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 306d0dc4db..4eada4204d 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
@@ -164,6 +164,14 @@ private static boolean isRetryableError(Throwable error) {
*
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}). Extension consumers that read
+ * {@link #getTimeout()} directly and pass the value to a framework timeout operator
+ * (e.g. {@code EmbeddingUtils.applyTimeoutAndRetry} in {@code rag-simple}, or the
+ * OpenAI SDK client timeout in {@code openai-official}) must apply the same guard
+ * via {@link #isTimeoutDisabled()}.
*/
public static final Duration NO_TIMEOUT = Duration.ofNanos(-1);
@@ -200,10 +208,14 @@ public Duration getTimeout() {
* 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 timeout != null && timeout.isNegative();
+ return NO_TIMEOUT.equals(timeout);
}
/**
@@ -327,10 +339,17 @@ public static class Builder {
/**
* Sets the timeout duration for a single execution.
*
- * @param timeout the timeout duration, or null to inherit from fallback
+ * @param timeout the timeout duration (must be >= 0, or {@link #NO_TIMEOUT}),
+ * or null to inherit from fallback
* @return this builder instance
+ * @throws IllegalArgumentException if timeout is a negative duration other than
+ * {@link #NO_TIMEOUT}
*/
public Builder timeout(Duration timeout) {
+ if (timeout != null && timeout.isNegative() && !NO_TIMEOUT.equals(timeout)) {
+ throw new IllegalArgumentException(
+ "timeout must be >= 0; use NO_TIMEOUT (or noTimeout()) to disable it");
+ }
this.timeout = timeout;
return this;
}
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 ecaf3fab78..9e7a75211e 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
@@ -542,6 +542,7 @@ private Mono applyScheduling(Mono execution) {
private Mono applyTimeout(
Mono execution, ExecutionConfig config, ToolUseBlock toolCall) {
+ // null = inherit from fallback, negative = explicitly disabled via NO_TIMEOUT
if (config == null || config.getTimeout() == null || config.isTimeoutDisabled()) {
return execution;
}
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..0cd19663c9 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
@@ -462,7 +462,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 dd0bbbdf15..626fe3b347 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
@@ -1537,16 +1537,17 @@ void testCallToolPerCallConfigTakesEffect() {
@Test
@DisplayName(
- "callTool(ToolCallParam, ExecutionConfig) with noTimeout() should execute without"
- + " timeout")
- void testCallToolPerCallConfigNoTimeout() {
- toolkit.registerTool(sampleTools);
+ "callTool(ToolCallParam, ExecutionConfig) with noTimeout() should not abort a slow"
+ + " tool")
+ void testCallToolPerCallConfigNoTimeoutDoesNotAbortSlowTool() {
+ SlowTool slowTool = new SlowTool();
+ toolkit.registerTool(slowTool);
- Map input = Map.of("a", 1, "b", 2);
+ Map input = Map.of("delayMs", 300);
ToolUseBlock toolCall =
ToolUseBlock.builder()
- .id("call-notimeout")
- .name("add")
+ .id("call-notimeout-slow")
+ .name("slow")
.input(input)
.content(JsonUtils.getJsonCodec().toJson(input))
.build();
@@ -1559,13 +1560,52 @@ void testCallToolPerCallConfigNoTimeout() {
ToolResultBlock result = toolkit.callTool(param, perCallConfig).block();
assertNotNull(result);
- assertEquals("call-notimeout", result.getId());
- assertEquals("add", result.getName());
- assertEquals("3", ToolTestUtils.extractContent(result));
+ assertEquals("call-notimeout-slow", result.getId());
+ assertEquals("slow", result.getName());
+ assertEquals("\"done\"", ToolTestUtils.extractContent(result));
+ assertFalse(isErrorResult(result), "NO_TIMEOUT must not trigger the timeout path");
+ }
- assertEquals(
- ExecutionConfig.NO_TIMEOUT,
- perCallConfig.getTimeout(),
- "noTimeout() should set the sentinel value");
+ @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-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..7f9122c355 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
@@ -83,9 +83,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,
From b7ad1cc258634fb1303d50251c9a52676290cae4 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Sun, 4 Oct 2026 16:54:04 +0800
Subject: [PATCH 17/25] fix: guard NO_TIMEOUT sentinel in openai-official SDK
path and javadoc, add strict timeout regression tests
- Guard ExecutionConfig.getTimeout() with isTimeoutDisabled() before passing to OpenAISdkClientFactory, aligning openai-official with ToolExecutor/ModelUtils/EmbeddingUtils
- Replace weak no-timeout tool test with strict control-group pair: noTimeout() overrides 100ms toolkit timeout (300ms tool succeeds) vs same tool without noTimeout() correctly times out
- Add NO_TIMEOUT test to EmbeddingUtilsTest (mergeConfigs + 300ms delay completes untouched) mirroring tool-path test
- EmbeddingUtils javadoc: document NO_TIMEOUT sentinel and isTimeoutDisabled() guard to match ExecutionConfig contract
---
.../io/agentscope/core/tool/ToolkitTest.java | 53 ++++++++++++++-----
.../OpenAIResponsesChatModel.java | 10 ++--
.../core/embedding/EmbeddingUtils.java | 9 ++--
.../core/embedding/EmbeddingUtilsTest.java | 21 ++++++++
4 files changed, 75 insertions(+), 18 deletions(-)
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 626fe3b347..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
@@ -1537,33 +1537,62 @@ void testCallToolPerCallConfigTakesEffect() {
@Test
@DisplayName(
- "callTool(ToolCallParam, ExecutionConfig) with noTimeout() should not abort a slow"
- + " tool")
- void testCallToolPerCallConfigNoTimeoutDoesNotAbortSlowTool() {
- SlowTool slowTool = new SlowTool();
- toolkit.registerTool(slowTool);
+ "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-notimeout-slow")
+ .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 perCallConfig =
+ ExecutionConfig noTimeoutConfig =
ExecutionConfig.builder().noTimeout().maxAttempts(1).build();
- ToolResultBlock result = toolkit.callTool(param, perCallConfig).block();
+ ToolResultBlock result = shortTimeoutToolkit.callTool(param, noTimeoutConfig).block();
assertNotNull(result);
- assertEquals("call-notimeout-slow", result.getId());
- assertEquals("slow", result.getName());
assertEquals("\"done\"", ToolTestUtils.extractContent(result));
- assertFalse(isErrorResult(result), "NO_TIMEOUT must not trigger the timeout path");
+ 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
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..636f3317df 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,17 @@ public OpenAIResponsesChatModel build() {
String resolvedBaseUrl =
ModelProviderSupport.firstNonBlank(
effectiveOptions.getBaseUrl(), System.getenv("OPENAI_BASE_URL"));
+ Duration clientTimeout = null;
+ if (effectiveOptions.getExecutionConfig() != null
+ && !effectiveOptions.getExecutionConfig().isTimeoutDisabled()) {
+ clientTimeout = effectiveOptions.getExecutionConfig().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-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 7f9122c355..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:
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..13ea11e888 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,25 @@ 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();
+ }
}
From e21cbd6f5f3983ff6d940377a39bc0b6fce76464 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Sun, 4 Oct 2026 19:08:08 +0800
Subject: [PATCH 18/25] fix: address all four info-level review notes from
round 7
- ToolExecutor: fix stale comment (negative -> NO_TIMEOUT sentinel)
- ExecutionConfig.Builder.timeout(): reject Duration.ZERO alongside negatives; update javadoc
- OpenAIResponsesChatModel: add javadoc explaining null maps to SDK default
- EmbeddingUtilsTest: add testShortTimeoutExpiresSlowMono control test
---
.../agentscope/core/model/ExecutionConfig.java | 13 ++++++++-----
.../io/agentscope/core/tool/ToolExecutor.java | 2 +-
.../OpenAIResponsesChatModel.java | 2 ++
.../core/embedding/EmbeddingUtilsTest.java | 17 +++++++++++++++++
4 files changed, 28 insertions(+), 6 deletions(-)
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 4eada4204d..de447ad375 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
@@ -339,16 +339,19 @@ public static class Builder {
/**
* Sets the timeout duration for a single execution.
*
- * @param timeout the timeout duration (must be >= 0, or {@link #NO_TIMEOUT}),
- * or null to inherit from fallback
+ * @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 will cause immediate expiration
* @return this builder instance
* @throws IllegalArgumentException if timeout is a negative duration other than
- * {@link #NO_TIMEOUT}
+ * {@link #NO_TIMEOUT}, or if timeout is {@code Duration.ZERO}
*/
public Builder timeout(Duration timeout) {
- if (timeout != null && timeout.isNegative() && !NO_TIMEOUT.equals(timeout)) {
+ if (timeout != null
+ && (timeout.isNegative() || timeout.isZero())
+ && !NO_TIMEOUT.equals(timeout)) {
throw new IllegalArgumentException(
- "timeout must be >= 0; use NO_TIMEOUT (or noTimeout()) to disable it");
+ "timeout must be > 0; use NO_TIMEOUT (or noTimeout()) to disable it");
}
this.timeout = timeout;
return this;
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 9e7a75211e..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
@@ -542,7 +542,7 @@ private Mono applyScheduling(Mono execution) {
private Mono applyTimeout(
Mono execution, ExecutionConfig config, ToolUseBlock toolCall) {
- // null = inherit from fallback, negative = explicitly disabled via NO_TIMEOUT
+ // null = inherit from fallback, NO_TIMEOUT sentinel = explicitly disabled
if (config == null || config.getTimeout() == null || config.isTimeoutDisabled()) {
return execution;
}
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 636f3317df..d55c3036ce 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
@@ -356,6 +356,8 @@ 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
+ // (not truly unbounded, but avoids the NO_TIMEOUT sentinel reaching the SDK)
Duration clientTimeout = null;
if (effectiveOptions.getExecutionConfig() != null
&& !effectiveOptions.getExecutionConfig().isTimeoutDisabled()) {
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 13ea11e888..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
@@ -225,4 +225,21 @@ void testNoTimeoutSentinelSkipsTimeout() {
// 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();
+ }
}
From dcaf9112599696bf8dc1b594aec43960bae70d8b Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 00:45:13 +0800
Subject: [PATCH 19/25] =?UTF-8?q?fix:=20address=20round-9=20review=20?=
=?UTF-8?q?=E2=80=94=20error=20message,=20log,=20mergeConfigs=20tests,=20n?=
=?UTF-8?q?oTimeout=20builder=20tests?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../core/model/ExecutionConfig.java | 2 +-
.../core/model/ExecutionConfigTest.java | 46 ++++++++++++++
.../OpenAIResponsesChatModel.java | 12 +++-
.../OpenAIResponsesChatModelTest.java | 61 +++++++++++++++++++
4 files changed, 118 insertions(+), 3 deletions(-)
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 de447ad375..62e9d2b86f 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
@@ -351,7 +351,7 @@ public Builder timeout(Duration timeout) {
&& (timeout.isNegative() || timeout.isZero())
&& !NO_TIMEOUT.equals(timeout)) {
throw new IllegalArgumentException(
- "timeout must be > 0; use NO_TIMEOUT (or noTimeout()) to disable it");
+ "timeout must be positive; use NO_TIMEOUT or noTimeout() to disable it");
}
this.timeout = timeout;
return this;
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..c216a69002 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,49 @@ 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(
+ "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");
+ }
+
private static final class TestModelHttpException extends RuntimeException
implements ModelHttpException {
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 d55c3036ce..4f4e7bc7fc 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
@@ -356,12 +356,20 @@ 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
- // (not truly unbounded, but avoids the NO_TIMEOUT sentinel reaching the SDK)
+ // 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.
Duration clientTimeout = null;
if (effectiveOptions.getExecutionConfig() != null
&& !effectiveOptions.getExecutionConfig().isTimeoutDisabled()) {
clientTimeout = effectiveOptions.getExecutionConfig().getTimeout();
+ } else if (effectiveOptions.getExecutionConfig() != null
+ && effectiveOptions.getExecutionConfig().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);
}
OpenAIClient client =
OpenAISdkClientFactory.createClient(
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");
+ }
+ }
}
From bd1e1c6c5b09a1d59c0d6b0021011209a3a7539b Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 01:09:24 +0800
Subject: [PATCH 20/25] test: add Duration.ZERO rejection test for
Builder.timeout()
---
.../agentscope/core/model/ExecutionConfigTest.java | 12 ++++++++++++
1 file changed, 12 insertions(+)
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 c216a69002..da8e42f7de 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
@@ -98,6 +98,18 @@ void shouldRejectNonSentinelNegativeDurations() {
"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"
From 18968336ad0a2933acbd79c29fbf9b788dfbaa66 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 01:32:01 +0800
Subject: [PATCH 21/25] =?UTF-8?q?fix:=20align=20Builder.timeout()=20javado?=
=?UTF-8?q?c=20=E2=80=94=20ZERO=20is=20rejected,=20not=20immediate=20expir?=
=?UTF-8?q?ation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/main/java/io/agentscope/core/model/ExecutionConfig.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 62e9d2b86f..86ba363910 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
@@ -341,7 +341,7 @@ public static class Builder {
*
* @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 will cause immediate expiration
+ * 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}
From 016626affd1911770de711858007a19c27303613 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 07:55:19 +0800
Subject: [PATCH 22/25] test: verify mergeConfigs inherits NO_TIMEOUT when
primary timeout is null
---
.../agentscope/core/model/ExecutionConfigTest.java | 13 +++++++++++++
1 file changed, 13 insertions(+)
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 da8e42f7de..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
@@ -141,6 +141,19 @@ void mergeConfigsPositiveTimeoutPrimaryOverridesNoTimeout() {
"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 {
From 8b1d6aaf99f33daedd93b465c43252103ed02e60 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 16:08:38 +0800
Subject: [PATCH 23/25] =?UTF-8?q?fix:=20address=20round-10=20review=20?=
=?UTF-8?q?=E2=80=94=20error=20message=20includes=20value,=20production-co?=
=?UTF-8?q?de=20noTimeout=20test,=20NO=5FTIMEOUT=20javadoc=20openai-offici?=
=?UTF-8?q?al=20divergence,=20hoist=20config=20reference=20in=20OpenAIResp?=
=?UTF-8?q?onsesChatModel?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../core/model/ExecutionConfig.java | 19 ++++++++++++-----
.../core/model/ModelTimeoutRetryTest.java | 19 +++++++++++++++++
.../OpenAIResponsesChatModel.java | 21 ++++++++++---------
3 files changed, 44 insertions(+), 15 deletions(-)
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 86ba363910..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
@@ -167,11 +167,18 @@ private static boolean isRetryableError(Throwable error) {
*
* Supported paths: The sentinel is honoured on the tool path
* ({@code ToolExecutor.applyTimeout}) and the model-flux path
- * ({@code ModelUtils.applyTimeoutAndRetry}). Extension consumers that read
+ * ({@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
- * (e.g. {@code EmbeddingUtils.applyTimeoutAndRetry} in {@code rag-simple}, or the
- * OpenAI SDK client timeout in {@code openai-official}) must apply the same guard
- * via {@link #isTimeoutDisabled()}.
+ * 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);
@@ -351,7 +358,9 @@ public Builder timeout(Duration timeout) {
&& (timeout.isNegative() || timeout.isZero())
&& !NO_TIMEOUT.equals(timeout)) {
throw new IllegalArgumentException(
- "timeout must be positive; use NO_TIMEOUT or noTimeout() to disable it");
+ "timeout must be positive, got "
+ + timeout
+ + "; use NO_TIMEOUT or noTimeout() to disable it");
}
this.timeout = timeout;
return this;
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 0cd19663c9..289f6e7fd5 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,25 @@ 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
/**
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 4f4e7bc7fc..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
@@ -359,17 +359,18 @@ public OpenAIResponsesChatModel build() {
// 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 (effectiveOptions.getExecutionConfig() != null
- && !effectiveOptions.getExecutionConfig().isTimeoutDisabled()) {
- clientTimeout = effectiveOptions.getExecutionConfig().getTimeout();
- } else if (effectiveOptions.getExecutionConfig() != null
- && effectiveOptions.getExecutionConfig().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);
+ 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(
From 6bed4723e90e468ee696d6e314da7ccd6d0a8416 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 16:11:33 +0800
Subject: [PATCH 24/25] style: fix spotless formatting violation in
ModelTimeoutRetryTest
---
.../java/io/agentscope/core/model/ModelTimeoutRetryTest.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
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 289f6e7fd5..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
@@ -372,8 +372,7 @@ void shouldSkipTimeoutWithNoTimeoutSentinelInProductionCode() {
Flux.just(createMockResponse()).delayElements(Duration.ofMillis(500));
ExecutionConfig config = ExecutionConfig.builder().noTimeout().build();
- GenerateOptions options =
- GenerateOptions.builder().executionConfig(config).build();
+ GenerateOptions options = GenerateOptions.builder().executionConfig(config).build();
StepVerifier.create(
ModelUtils.applyTimeoutAndRetry(
From e63fede029018f5177c239b296ccf43ba1021350 Mon Sep 17 00:00:00 2001
From: KIM406-CMD <2336467480@qq.com>
Date: Mon, 5 Oct 2026 17:06:38 +0800
Subject: [PATCH 25/25] docs: add changelog entry for
ExecutionConfig.Builder.timeout() validation tightening
---
docs/v2/en/docs/change-log.md | 8 ++++++++
docs/v2/zh/docs/change-log.md | 8 ++++++++
2 files changed, 16 insertions(+)
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)`,仍可调用)