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