Repository navigation
fix(Toolkit): route callTool through executeWithInfrastructure #3130
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
aaa99c6
c362855
cbdfcfe
292a7da
2bcea60
63b85bd
36a5738
ffac69a
84440a5
a826bfd
0cf254f
d4a081d
549af6c
6ce5af4
d47a113
28f7484
621faef
772f1b6
b7ad1cc
a9f7be8
e21cbd6
dcaf911
bd1e1c6
1896833
016626a
8b1d6aa
6bed472
e63fede
dc85c84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -155,6 +155,33 @@ private static boolean isRetryableError(Throwable error) { | |
| .retryOn(RETRYABLE_ERRORS) | ||
| .build(); | ||
|
|
||
| /** | ||
| * Sentinel value for {@link #timeout} meaning "no timeout". A negative duration is never | ||
| * produced by normal usage and is recognised by {@code ToolExecutor.applyTimeout} and | ||
| * {@link ModelUtils#applyTimeoutAndRetry ModelUtils.applyTimeoutAndRetry} | ||
| * as "skip the timeout operator entirely". | ||
| * | ||
| * <p>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". | ||
| * | ||
| * <p><b>Supported paths</b>: The sentinel is honoured on the <em>tool</em> path | ||
| * ({@code ToolExecutor.applyTimeout}) and the <em>model-flux</em> path | ||
| * ({@code ModelUtils.applyTimeoutAndRetry}), where it means genuinely unbounded | ||
| * (no timeout operator is applied). Extension consumers that read | ||
| * {@link #getTimeout()} directly and pass the value to a framework timeout operator | ||
| * must apply the same guard via {@link #isTimeoutDisabled()}: | ||
| * <ul> | ||
| * <li>{@code EmbeddingUtils.applyTimeoutAndRetry} in {@code rag-simple} — honours the | ||
| * sentinel by skipping the timeout operator, consistent with tool/model-flux.</li> | ||
| * <li>{@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.</li> | ||
| * </ul> | ||
| */ | ||
| public static final Duration NO_TIMEOUT = Duration.ofNanos(-1); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Carry-over from the previous round, still open at Two ways to close it: mirror the /** @return true when the configured timeout is the {@link #NO_TIMEOUT} sentinel. */
public boolean isTimeoutDisabled() {
return timeout != null && timeout.isNegative();
}and use it in both
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oss-maintainer Good call — added
This way both call sites share the same guard and won't drift apart. |
||
|
|
||
| /** | ||
| * Standard defaults for tool executions. | ||
| * | ||
|
|
@@ -184,6 +211,20 @@ public Duration getTimeout() { | |
| return timeout; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the configured timeout is the {@link #NO_TIMEOUT} sentinel, | ||
| * meaning consumers should skip applying any timeout operator. | ||
| * | ||
| * <p>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() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] The javadoc says this returns true when the timeout is the So public boolean isTimeoutDisabled() {
return NO_TIMEOUT.equals(timeout);
}…or keep the broad check but reject accidental negatives at construction time: 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;
}The second option also matches how |
||
| return NO_TIMEOUT.equals(timeout); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Exact-sentinel matching is the correct narrowing — "any negative duration means disabled" is exactly what let the What remains is that
Fine as a follow-up, not a blocker for this PR.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — effectiveTimeoutOrNull() (or a shared helper) is the right long-term solution. I'll track it as a follow-up; not in this PR. |
||
| } | ||
|
|
||
| /** | ||
| * Gets the maximum number of attempts. | ||
| * | ||
|
|
@@ -305,14 +346,36 @@ public static class Builder { | |
| /** | ||
| * Sets the timeout duration for a single execution. | ||
| * | ||
| * @param timeout the timeout duration, or null for no timeout | ||
| * @param timeout the timeout duration (must be > 0, or {@link #NO_TIMEOUT}), | ||
| * or null to inherit from fallback; {@code Duration.ZERO} is not a synonym | ||
| * for {@link #noTimeout()} and is therefore rejected | ||
| * @return this builder instance | ||
| * @throws IllegalArgumentException if timeout is a negative duration other than | ||
| * {@link #NO_TIMEOUT}, or if timeout is {@code Duration.ZERO} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] One item from the previous round is still open, and it is documentation rather than logic: |
||
| */ | ||
| public Builder timeout(Duration timeout) { | ||
| if (timeout != null | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Right place for the validation, but note that builder.timeout(primary.timeout != null ? primary.timeout : fallback.timeout);so a config produced before this change — or assembled by a path that bypasses the builder, e.g. deserialized session/agent state or an extension option map — carrying Two test asks while you are here:
And a changelog line: Nit: the message says
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All three items addressed in dcaf9112:
Agreed on the changelog line for Duration.ZERO — I'll make sure it's called out. |
||
| && (timeout.isNegative() || timeout.isZero()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Rejecting
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oss-maintainer Acknowledged. The javadoc already documents the Duration.ZERO rejection. Maintainers can include this in the release notes when applicable. Thanks. |
||
| && !NO_TIMEOUT.equals(timeout)) { | ||
| throw new IllegalArgumentException( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Throwing from Two things worth doing before merge:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. The error message now includes the rejected value: "timeout must be positive, got " + timeout + "; 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; | ||
| } | ||
|
|
||
| /** | ||
| * 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). | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,7 +87,7 @@ public static Flux<ChatResponse> applyTimeoutAndRetry( | |
| if (execConfig != null) { | ||
| // Apply timeout if configured | ||
| Duration timeout = execConfig.getTimeout(); | ||
| if (timeout != null) { | ||
| if (timeout != null && !execConfig.isTimeoutDisabled()) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] This is a genuine behavior fix and worth calling out: Reactor clamps a negative delay to immediate expiry, so before this change The unification is still partial, though. Other consumers that null-check
Since |
||
| responseFlux = | ||
| responseFlux.timeout( | ||
| timeout, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -155,7 +155,9 @@ private void invokeChunkCallback( | |
| // ==================== Single Tool Execution ==================== | ||
|
|
||
| /** | ||
| * Execute a single tool call with full infrastructure support. | ||
| * Execute a single tool call (core execution only: Tracer + {@link #executeCore}; | ||
| * no scheduling, timeout, retry, shutdown guard, or id/name stamping). Use | ||
| * {@link #executeWithInfrastructure(ToolCallParam, ExecutionConfig)} for the full-infrastructure path. | ||
| * | ||
| * @param param Tool call parameters | ||
| * @return Mono containing execution result | ||
|
|
@@ -171,8 +173,9 @@ Mono<ToolResultBlock> 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<ToolResultBlock> execute( | ||
| ToolCallParam param, | ||
|
|
@@ -342,7 +345,10 @@ private Collection<String> 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,33 +455,70 @@ 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. | ||
| * | ||
| * <p>This overload is used by the batch path ({@link #executeAll(List, boolean, | ||
| * ExecutionConfig, Agent, RuntimeContext)}), which routes each {@link ToolUseBlock} with | ||
| * the infrastructure config, per-call request config, and chunk callback. | ||
| */ | ||
| private Mono<ToolResultBlock> executeWithInfrastructure( | ||
| Mono<ToolResultBlock> executeWithInfrastructure( | ||
| ToolUseBlock toolCall, | ||
| ExecutionConfig executionConfig, | ||
| Agent agent, | ||
| RuntimeContext agentRuntimeContext, | ||
| ToolRequestConfig requestConfig, | ||
| BiConsumer<ToolUseBlock, ToolResultBlock> internalChunkCallback) { | ||
| // Build tool call parameter | ||
| ToolCallParam param = | ||
| ToolCallParam.builder() | ||
| .toolUseBlock(toolCall) | ||
| .agent(agent) | ||
| .runtimeContext(agentRuntimeContext) | ||
| .build(); | ||
|
|
||
| // Get core execution | ||
| Mono<ToolResultBlock> execution = execute(param, requestConfig, internalChunkCallback); | ||
|
|
||
| // Apply infrastructure layers | ||
| return applyInfrastructure(execution, executionConfig, toolCall); | ||
| } | ||
|
|
||
| /** | ||
| * Execute a single tool call with full infrastructure, preserving all fields from the | ||
| * original {@link ToolCallParam} (including input). | ||
| * | ||
| * <p>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<ToolResultBlock> executeWithInfrastructure( | ||
| ToolCallParam param, ExecutionConfig executionConfig) { | ||
| ToolUseBlock toolCall = param.getToolUseBlock(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] The two overloads are now asymmetric in field preservation. The 4-arg overload still rebuilds a fresh
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Everything from here to the Duplicating exactly the block this PR identifies as "the thing the single path was missing" is how the two drift apart again, and the drift is invisible because each copy looks correct on its own. Consider extracting
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oss-maintainer Done. I extracted the duplicated infrastructure pipeline — the four layers (applyScheduling → applyTimeout → applyRetry → applyShutdownGuard), the id/name stamping, and the error-to-result onErrorResume — into a single private applyInfrastructure(Mono, ExecutionConfig, ToolUseBlock) method. Both executeWithInfrastructure overloads now delegate to it in one line: This way, if a future layer (metrics tracing, rate limiting, etc.) needs to be added, it goes in one place and both entry points get it. The helper's javadoc also documents the retry-only-on-infrastructure semantics — which addresses the previous finding's doc gap as a bonus. |
||
|
|
||
| Mono<ToolResultBlock> 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. | ||
| * | ||
| * <p><b>Retry semantics</b>: {@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 <em>after</em> retry so | ||
| * shutdown signals are never seen by {@code retryWhen} either. "Retry" here means | ||
| * "retry on timeout", nothing else. | ||
| */ | ||
| private Mono<ToolResultBlock> applyInfrastructure( | ||
| Mono<ToolResultBlock> execution, | ||
| ExecutionConfig executionConfig, | ||
| ToolUseBlock toolCall) { | ||
| execution = applyScheduling(execution); | ||
| execution = applyTimeout(execution, executionConfig, toolCall); | ||
| execution = applyRetry(execution, executionConfig, toolCall); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] The What still works is retry over timeout and shutdown-guard signals, because those are produced by the infrastructure layers themselves ( Nothing regresses here — the batch path has always behaved this way — but this PR is the first place that promises retry semantics to
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oss-maintainer This is a sharp catch — I didn't even realize half of my own test was a workaround 😅
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oss-maintainer Fully agree — the previous javadoc sentence ("non-idempotent tools may be re-invoked on timeout when maxAttempts > 1") promised more than the code delivers. |
||
| execution = applyShutdownGuard(execution); | ||
|
|
||
| // Add tool metadata and error handling | ||
| return execution | ||
| .map(result -> result.withIdAndName(toolCall.getId(), toolCall.getName())) | ||
| .onErrorResume( | ||
|
|
@@ -499,7 +542,8 @@ private Mono<ToolResultBlock> applyScheduling(Mono<ToolResultBlock> execution) { | |
|
|
||
| private Mono<ToolResultBlock> applyTimeout( | ||
| Mono<ToolResultBlock> execution, ExecutionConfig config, ToolUseBlock toolCall) { | ||
| if (config == null || config.getTimeout() == null) { | ||
| // null = inherit from fallback, NO_TIMEOUT sentinel = explicitly disabled | ||
| if (config == null || config.getTimeout() == null || config.isTimeoutDisabled()) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Nit on the extracted Verified against reactor-core 3.8.4 that |
||
| return execution; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,9 @@ | |
| * and retrieve tools. | ||
| * | ||
| * <p><b>Thread Safety:</b> 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. | ||
| * | ||
| * <p><b>Key Responsibilities:</b> | ||
| * <ul> | ||
|
|
@@ -39,8 +41,9 @@ | |
| */ | ||
| class ToolRegistry { | ||
|
|
||
| private final Map<String, AgentTool> tools = new ConcurrentHashMap<>(); | ||
| private final Map<String, RegisteredToolFunction> registeredTools = new ConcurrentHashMap<>(); | ||
| private record Entry(AgentTool tool, RegisteredToolFunction registered) {} | ||
|
|
||
| private final Map<String, Entry> 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<String> getToolNames() { | ||
| return new HashSet<>(tools.keySet()); | ||
| return new HashSet<>(entries.keySet()); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -98,7 +102,13 @@ Set<String> getToolNames() { | |
| * @return Map of tool name to RegisteredToolFunction | ||
| */ | ||
| Map<String, RegisteredToolFunction> getAllRegisteredTools() { | ||
| return new ConcurrentHashMap<>(registeredTools); | ||
| Map<String, RegisteredToolFunction> result = new ConcurrentHashMap<>(); | ||
| for (Map.Entry<String, Entry> e : entries.entrySet()) { | ||
| if (e.getValue().registered() != null) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Nice win on atomicity — one compound map entry removes the window where |
||
| result.put(e.getKey(), e.getValue().registered()); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -110,24 +120,35 @@ void removeTool(String toolName) { | |
| if (toolName == null || toolName.isBlank()) { | ||
| throw new IllegalArgumentException("Tool name cannot be null or blank"); | ||
| } | ||
| tools.remove(toolName); | ||
| registeredTools.remove(toolName); | ||
| entries.remove(toolName); | ||
| } | ||
|
|
||
| /** | ||
| * Atomically remove a tool only if the current instance matches the expected one. | ||
| * Uses {@link ConcurrentHashMap#remove(Object, Object)} to avoid TOCTOU races. | ||
| * | ||
| * <p><b>Identity semantics</b>: The guard check ({@code existing.tool() == expected}) | ||
| * compares the expected tool by reference ({@code ==}), not via {@link Object#equals}. | ||
| * Two {@code AgentTool} instances that are {@link Object#equals equal} but not the same | ||
| * reference will not match — this guards against accidental removal of a tool that was | ||
| * re-registered under the same name by another caller. The CAS at | ||
| * {@link ConcurrentHashMap#remove(Object, Object)} additionally depends on the | ||
| * {@code Entry} record's {@link Object#equals}, which compares both the | ||
| * {@code AgentTool} and {@code RegisteredToolFunction} fields; callers that | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new identity-semantics paragraph matches the implementation (
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reworded so the first sentence explicitly ties to the guard check See: 28f7484 |
||
| * rebuild {@code Entry} objects (e.g. via {@code copyTo}) must ensure | ||
| * {@code RegisteredToolFunction} equality remains stable across rebuilds. | ||
| * | ||
| * @param toolName Tool name to remove | ||
| * @param expected The expected AgentTool instance (identity comparison) | ||
| * @param expected The expected {@link AgentTool} instance, compared by reference | ||
| * ({@code ==}), not by {@link Object#equals} | ||
| * @return true if the tool was removed, false if it was already replaced or absent | ||
| */ | ||
| boolean removeToolIfSame(String toolName, AgentTool expected) { | ||
| boolean removed = tools.remove(toolName, expected); | ||
| if (removed) { | ||
| registeredTools.remove(toolName); | ||
| Entry existing = entries.get(toolName); | ||
| if (existing != null && existing.tool() == expected) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Identity semantics changed here. The old code was If reference-equality is the intent here (it probably is, for a "same instance" check), please say so explicitly in the javadoc so a future
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @oss-maintainer Good catch. Added an explicit Identity semantics paragraph to removeToolIfSame javadoc documenting that the == comparison is by design — it guards against removal of a tool that was re-registered under the same name by another caller between the get() and the remove(). An equals()-based comparison would silently delete a replacement tool that happened to be equal. |
||
| return entries.remove(toolName, existing); | ||
| } | ||
| return removed; | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -149,20 +170,20 @@ void removeTools(Set<String> toolNames) { | |
| * @param target The target registry to copy tools to | ||
| */ | ||
| void copyTo(ToolRegistry target) { | ||
| for (Map.Entry<String, AgentTool> entry : tools.entrySet()) { | ||
| String toolName = entry.getKey(); | ||
| AgentTool tool = entry.getValue(); | ||
| RegisteredToolFunction registered = registeredTools.get(toolName); | ||
| target.registerTool( | ||
| for (Map.Entry<String, 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()))); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical]
NO_TIMEOUTis a public constant onExecutionConfig, but only the tool path recognises it.ToolExecutor.applyTimeoutnow guards withisNegative(), whileModelUtils.applyTimeoutAndRetry(agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java:85) still only checkstimeout != nulland hands the value straight toresponseFlux.timeout(...). SoExecutionConfig.builder().noTimeout().build()on a model/embedding call does not mean "no timeout" — a negative duration reaches Reactor and either fails validation or fires immediately.The javadoc right above makes this worse: it explicitly advertises
NO_TIMEOUTas the way to opt out of "the timeout thatTOOL_DEFAULTSandMODEL_DEFAULTSalways carry", which invites exactly the model-path usage that is not handled. Since this repo's ownMODEL_DEFAULTSis merged into every model call, callers will hit this.Suggest centralising the predicate so no consumer can forget it, e.g.
and using it in both
ToolExecutor.applyTimeoutandModelUtils.applyTimeoutAndRetry(plusEmbeddingUtils, which routes through the same helper). A model-path test mirroringModelTimeoutRetryTestwithnoTimeout()would pin it down.