From cf70c11e6ac51b0db76340e451bbd8840881fead Mon Sep 17 00:00:00 2001 From: cx <415784429@qq.com> Date: Fri, 4 Sep 2026 16:25:17 +0800 Subject: [PATCH 1/2] feat(tool): add tool-level circuit breaker to stop offering repeatedly failing tools A ReAct loop reasons, calls a tool, reads the result and reasons again. When a tool returns an error the model usually calls it again, since one failure looks incidental from where it stands, and nothing remembered that the tool is broken. Against a dependency that is genuinely down each retry costs a model call, an outbound request and seconds of latency, and still fails. The existing controls address a different problem: applyTimeout bounds a single call, applyRetry recovers a transient blip within a single call (and defaults to maxAttempts=1 for tools), and maxIters counts iterations rather than failures. None of them carry state across calls, so none can express "this tool has failed N times in a row, leave it alone for a while". Add an opt-in per-tool breaker with CLOSED/OPEN/HALF_OPEN transitions and exponential backoff, capped so backoff cannot isolate a tool indefinitely: - ToolCircuitBreaker holds the state machine and backoff policy as a plain object with no reactive or agent dependencies, driven by an injected Clock. - ToolCircuitBreakerStore is the persistence SPI, with an in-process default and a Redis implementation so replicas share one view of a broken tool. - ToolCircuitBreakerMiddleware adapts it onto MiddlewareBase. Rather than rejecting the call once open, the breaker drops the tool from the schema list the model receives, so the retry loop disappears at the source. This filters ReasoningInput.tools() per turn and never mutates the Toolkit: ToolGroup membership is shared mutable state, so tripping a circuit there would remove the tool from concurrent sessions and overwrite the application's registrations. Per-turn filtering also needs no repair step on recovery. Failures are classified by the typed ToolResultState already on ToolResultEndEvent. Only ERROR counts; DENIED is a permission refusal and INTERRUPTED a cancellation, neither being evidence about the dependency. Supervision is opt-in and the default configuration is inert, so a breaker can never withhold an infrastructure tool nobody considered. State is derived from the stored snapshot plus the current time, so an elapsed cooldown is recognised on the next read with no scheduler or background thread. --- .../InMemoryToolCircuitBreakerStore.java | 75 ++++ .../circuitbreaker/ToolCircuitBreaker.java | 296 ++++++++++++++++ .../ToolCircuitBreakerConfig.java | 327 +++++++++++++++++ .../ToolCircuitBreakerMiddleware.java | 174 +++++++++ .../ToolCircuitBreakerStore.java | 94 +++++ .../circuitbreaker/ToolCircuitSnapshot.java | 48 +++ .../tool/circuitbreaker/ToolCircuitState.java | 53 +++ .../InMemoryToolCircuitBreakerStoreTest.java | 152 ++++++++ .../tool/circuitbreaker/MutableClock.java | 61 ++++ .../ToolCircuitBreakerMiddlewareTest.java | 297 ++++++++++++++++ .../ToolCircuitBreakerTest.java | 331 ++++++++++++++++++ .../RedisToolCircuitBreakerStore.java | 211 +++++++++++ .../RedisToolCircuitBreakerStoreTest.java | 261 ++++++++++++++ 13 files changed, 2380 insertions(+) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitState.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/MutableClock.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java create mode 100644 agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java create mode 100644 agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java new file mode 100644 index 0000000000..bbdd259cf6 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Default in-process {@link ToolCircuitBreakerStore}, backed by a {@link ConcurrentHashMap}. + * + *

Suitable for single-replica deployments and for tests. State is per JVM: it is lost on restart + * and not shared between replicas, so in a multi-replica deployment every replica trips its own + * circuit independently. Use a distributed store when that matters. + * + *

Entries are created lazily on first write and removed once a tool is fully healthy again, so + * tools that never fail cost nothing. + */ +public class InMemoryToolCircuitBreakerStore implements ToolCircuitBreakerStore { + + private final Map failureCounts = new ConcurrentHashMap<>(); + private final Map circuits = new ConcurrentHashMap<>(); + + @Override + public long recordFailure(String toolName) { + return failureCounts.computeIfAbsent(toolName, name -> new AtomicLong()).incrementAndGet(); + } + + @Override + public void resetFailures(String toolName) { + failureCounts.remove(toolName); + } + + @Override + public long failureCount(String toolName) { + AtomicLong counter = failureCounts.get(toolName); + return counter == null ? 0L : counter.get(); + } + + @Override + public long open(String toolName, long openedAtEpochMilli) { + // compute() holds the bin lock, so the generation increment and the timestamp stamp are + // applied as one atomic step even when several failing calls trip the same tool at once. + return circuits.compute( + toolName, + (name, current) -> + new ToolCircuitSnapshot( + (current == null ? 0L : current.generation()) + 1L, + openedAtEpochMilli)) + .generation(); + } + + @Override + public void close(String toolName) { + circuits.remove(toolName); + } + + @Override + public ToolCircuitSnapshot snapshot(String toolName) { + return circuits.getOrDefault(toolName, ToolCircuitSnapshot.CLOSED); + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java new file mode 100644 index 0000000000..ea7e63b429 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java @@ -0,0 +1,296 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Per-tool circuit breaker: decides when a repeatedly failing tool should stop being offered to the + * model, and when it is worth offering again. + * + *

This class holds all policy and no transport. It is a plain object with no reactive or agent + * dependencies, so the state machine can be tested directly against a fixed {@link Clock}. {@link + * ToolCircuitBreakerMiddleware} is the adapter that wires it into an agent. + * + *

Why withhold the tool instead of rejecting the call

+ * + *

A ReAct loop reasons, calls a tool, reads the result, and reasons again. When a tool returns an + * error the model commonly retries it, because from the model's point of view a single failure looks + * incidental. Each retry costs a model call, an outbound request and seconds of latency, and against + * a dependency that is genuinely down it fails again. + * + *

A classic breaker sits between caller and dependency and fails fast once open. Here there is a + * better option: stop advertising the tool. A tool absent from the schema list is a tool the model + * cannot ask for, which removes the failure loop at the source rather than absorbing it. The model + * needs no prompt telling it to avoid the tool and cannot argue with the decision. + * + *

State machine

+ * + *
+ *   CLOSED --failureThreshold consecutive failures--> OPEN
+ *   OPEN --cooldown elapsed--> HALF_OPEN            (tool advertised again, as a probe)
+ *   HALF_OPEN --probe succeeds--> CLOSED            (failure counter cleared)
+ *   HALF_OPEN --probe fails--> OPEN                 (next generation, longer cooldown)
+ * 
+ * + *

Nothing runs in the background: {@link #state(String)} derives the state from the stored + * snapshot and the current time, so a cooldown that elapsed while the agent was idle is recognised + * on the next read. There is no timer to leak and no scheduler to configure. + * + *

Threading

+ * + *

Safe for concurrent use as long as the {@link ToolCircuitBreakerStore} is. Read-modify-write + * sequences are not globally serialised: two tool calls failing at the same instant may both observe + * the threshold and call {@link ToolCircuitBreakerStore#open(String, long)}. That is harmless — + * opening is idempotent apart from advancing the generation, so the worst case is one extra backoff + * step. + */ +public class ToolCircuitBreaker { + + private static final Logger logger = LoggerFactory.getLogger(ToolCircuitBreaker.class); + + private final ToolCircuitBreakerConfig config; + private final ToolCircuitBreakerStore store; + private final Clock clock; + + /** + * Create a breaker with the default in-process store and the system clock. + * + * @param config supervision and backoff policy + */ + public ToolCircuitBreaker(ToolCircuitBreakerConfig config) { + this(config, new InMemoryToolCircuitBreakerStore(), Clock.systemUTC()); + } + + /** + * Create a breaker with a caller-supplied store and the system clock. + * + * @param config supervision and backoff policy + * @param store state persistence + */ + public ToolCircuitBreaker(ToolCircuitBreakerConfig config, ToolCircuitBreakerStore store) { + this(config, store, Clock.systemUTC()); + } + + /** + * Create a breaker with a caller-supplied store and clock. + * + * @param config supervision and backoff policy + * @param store state persistence + * @param clock time source; inject a fixed or adjustable clock in tests to step through + * cooldowns without sleeping. Cooldowns compare a stored wall-clock stamp against this + * clock, so a backwards jump (a manual correction, not NTP slew) can hold a tool back for + * up to the size of that jump. Use {@link #reset(String)} to clear it immediately. + */ + public ToolCircuitBreaker( + ToolCircuitBreakerConfig config, ToolCircuitBreakerStore store, Clock clock) { + this.config = Objects.requireNonNull(config, "config must not be null"); + this.store = Objects.requireNonNull(store, "store must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + } + + /** + * Whether this tool is under supervision. + * + *

Exclusions win over both the monitored set and {@code monitorAllTools}, so a tool can be + * exempted without editing the supervised list. + * + * @param toolName tool to test; null is never supervised + * @return true when failures of this tool count towards a trip + */ + public boolean supervises(String toolName) { + if (toolName == null || !config.isEnabled()) { + return false; + } + if (config.getExcludedTools().contains(toolName)) { + return false; + } + return config.isMonitorAllTools() || config.getMonitoredTools().contains(toolName); + } + + /** + * Current state of a tool's circuit, derived from stored state and the current time. + * + *

Unsupervised tools always report {@link ToolCircuitState#CLOSED}. + * + * @param toolName tool to inspect + * @return the current state, never null + */ + public ToolCircuitState state(String toolName) { + if (!supervises(toolName)) { + return ToolCircuitState.CLOSED; + } + ToolCircuitSnapshot snapshot = store.snapshot(toolName); + if (!snapshot.isOpen()) { + return ToolCircuitState.CLOSED; + } + return hasCooldownElapsed(snapshot) ? ToolCircuitState.HALF_OPEN : ToolCircuitState.OPEN; + } + + /** + * Whether the tool should be kept out of the schema list offered to the model. + * + *

True only in {@link ToolCircuitState#OPEN}: a half-open circuit deliberately advertises the + * tool again so the model's next call doubles as the recovery probe. + * + * @param toolName tool to test + * @return true when the tool must not be advertised + */ + public boolean isWithheld(String toolName) { + return state(toolName) == ToolCircuitState.OPEN; + } + + /** + * Cooldown for a given trip generation: {@code initial * multiplier^(generation-1)}, capped at + * the configured maximum. + * + * @param generation trip count, 1 for the first trip + * @return the cooldown, or {@link Duration#ZERO} for a circuit that has never tripped + */ + public Duration cooldownFor(long generation) { + if (generation <= 0L) { + return Duration.ZERO; + } + double scaled = + config.getInitialCooldown().toMillis() + * Math.pow(config.getBackoffMultiplier(), (double) generation - 1.0); + // A large generation overflows to Infinity; both that and any value past the ceiling clamp + // to maxCooldown, so backoff can never isolate a tool indefinitely. + if (!Double.isFinite(scaled) || scaled >= config.getMaxCooldown().toMillis()) { + return config.getMaxCooldown(); + } + return Duration.ofMillis((long) Math.ceil(scaled)); + } + + /** + * Record a successful tool execution. + * + *

In {@link ToolCircuitState#HALF_OPEN} this closes the circuit. In {@link + * ToolCircuitState#CLOSED} it clears any partial failure streak, which is what makes the + * threshold count consecutive failures and stops an occasional blip from ever tripping a + * healthy tool. + * + * @param toolName tool that succeeded + */ + public void recordSuccess(String toolName) { + if (!supervises(toolName)) { + return; + } + ToolCircuitState current = state(toolName); + if (current == ToolCircuitState.OPEN) { + // Withheld yet still executed: tolerated, see recordFailure. + return; + } + if (current == ToolCircuitState.HALF_OPEN) { + store.close(toolName); + store.resetFailures(toolName); + logger.info("Tool circuit closed after successful probe: tool={}", toolName); + return; + } + if (store.failureCount(toolName) > 0L) { + store.resetFailures(toolName); + logger.debug("Tool circuit failure streak cleared by success: tool={}", toolName); + } + } + + /** + * Record a failed tool execution, tripping the circuit once the threshold is reached. + * + *

Only genuine execution failures belong here. A call refused by permission rules or + * cancelled by the user says nothing about the health of the dependency and must not count + * towards a trip. + * + * @param toolName tool that failed + */ + public void recordFailure(String toolName) { + if (!supervises(toolName)) { + return; + } + ToolCircuitState current = state(toolName); + if (current == ToolCircuitState.OPEN) { + // The tool was withheld, so the model should not have been able to call it. This is + // still reachable: the model may have chosen the call in the same turn the circuit + // tripped. Ignore it rather than counting a failure the policy never authorised. + logger.debug( + "Ignoring failure of withheld tool, likely decided before the circuit opened:" + + " tool={}", + toolName); + return; + } + if (current == ToolCircuitState.HALF_OPEN) { + long generation = store.open(toolName, clock.millis()); + logger.warn( + "Tool circuit re-opened after failed probe: tool={}, generation={}," + + " cooldown={}", + toolName, + generation, + cooldownFor(generation)); + return; + } + long failures = store.recordFailure(toolName); + if (failures < config.getFailureThreshold()) { + logger.debug( + "Tool failure recorded: tool={}, consecutiveFailures={}/{}", + toolName, + failures, + config.getFailureThreshold()); + return; + } + long generation = store.open(toolName, clock.millis()); + // Clear the streak on trip so the counter always means "failures seen while closed". + store.resetFailures(toolName); + logger.warn( + "Tool circuit opened: tool={}, consecutiveFailures={}, generation={}, cooldown={}." + + " The tool will not be offered to the model until the cooldown elapses.", + toolName, + failures, + generation, + cooldownFor(generation)); + } + + /** + * Force a tool back to {@link ToolCircuitState#CLOSED}, discarding its failure streak and + * accumulated backoff. + * + *

Intended for operators who know a dependency is healthy again and do not want to wait out + * the cooldown. + * + * @param toolName tool to reset + */ + public void reset(String toolName) { + store.close(toolName); + store.resetFailures(toolName); + logger.info("Tool circuit manually reset: tool={}", toolName); + } + + /** + * The policy in force. + * + * @return the configuration this breaker was built with + */ + public ToolCircuitBreakerConfig getConfig() { + return config; + } + + private boolean hasCooldownElapsed(ToolCircuitSnapshot snapshot) { + Duration cooldown = cooldownFor(snapshot.generation()); + return clock.millis() >= snapshot.openedAtEpochMilli() + cooldown.toMillis(); + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java new file mode 100644 index 0000000000..f847a8e477 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java @@ -0,0 +1,327 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import java.time.Duration; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Policy for {@link ToolCircuitBreaker}: which tools are supervised, when a circuit trips, and how + * long it stays open. + * + *

Tools are supervised by opt-in, not by default

+ * + *

A breaker is only ever applied to tools named by {@link Builder#monitorTools(Collection)} (or + * to every tool once {@link Builder#monitorAllTools(boolean)} is set). With neither configured the + * breaker is inert. + * + *

That default is deliberate. Withholding a tool is the right response for a flaky external + * dependency, but the wrong response for infrastructure the agent cannot work without: breaking a + * database or filesystem tool does not degrade the agent gracefully, it cripples it. Requiring the + * supervised set to be named means a breaker can never take down a tool its author never + * considered. Prefer tools whose loss leaves the main flow viable. + * + *

Cooldown grows with each trip

+ * + *

Cooldown is {@code min(initialCooldown * backoffMultiplier^(generation-1), maxCooldown)}. With + * the defaults (60s, x2, capped at 600s) successive trips wait 60s, 120s, 240s, 480s, 600s, 600s... + * A tool that keeps failing is isolated for longer, which cuts both the probe traffic aimed at a + * struggling dependency and the tokens spent re-discovering that it is still down. The cap stops + * backoff from isolating a tool for hours after a long outage. + */ +public final class ToolCircuitBreakerConfig { + + private final boolean enabled; + private final Set monitoredTools; + private final Set excludedTools; + private final boolean monitorAllTools; + private final int failureThreshold; + private final Duration initialCooldown; + private final double backoffMultiplier; + private final Duration maxCooldown; + + private ToolCircuitBreakerConfig(Builder builder) { + this.enabled = builder.enabled; + this.monitoredTools = Set.copyOf(builder.monitoredTools); + this.excludedTools = Set.copyOf(builder.excludedTools); + this.monitorAllTools = builder.monitorAllTools; + this.failureThreshold = builder.failureThreshold; + this.initialCooldown = builder.initialCooldown; + this.backoffMultiplier = builder.backoffMultiplier; + this.maxCooldown = builder.maxCooldown; + } + + /** + * Create a builder carrying the documented defaults. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Whether the breaker does anything at all. + * + * @return true when supervision is active + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Tools placed under supervision by name. + * + * @return unmodifiable set of tool names + */ + public Set getMonitoredTools() { + return monitoredTools; + } + + /** + * Tools never supervised, which overrides both {@link #getMonitoredTools()} and {@link + * #isMonitorAllTools()}. + * + * @return unmodifiable set of tool names + */ + public Set getExcludedTools() { + return excludedTools; + } + + /** + * Whether every tool is supervised unless excluded. + * + * @return true when supervision is opt-out rather than opt-in + */ + public boolean isMonitorAllTools() { + return monitorAllTools; + } + + /** + * Consecutive failures that trip a closed circuit. + * + * @return failure threshold, at least 1 + */ + public int getFailureThreshold() { + return failureThreshold; + } + + /** + * Cooldown applied on the first trip. + * + * @return initial cooldown + */ + public Duration getInitialCooldown() { + return initialCooldown; + } + + /** + * Factor the cooldown is multiplied by on each successive trip. + * + * @return backoff multiplier, at least 1.0 + */ + public double getBackoffMultiplier() { + return backoffMultiplier; + } + + /** + * Upper bound on the cooldown, whatever the generation. + * + * @return maximum cooldown + */ + public Duration getMaxCooldown() { + return maxCooldown; + } + + /** Builder for {@link ToolCircuitBreakerConfig}. */ + public static final class Builder { + + private boolean enabled = true; + private final Set monitoredTools = new LinkedHashSet<>(); + private final Set excludedTools = new LinkedHashSet<>(); + private boolean monitorAllTools = false; + private int failureThreshold = 3; + private Duration initialCooldown = Duration.ofSeconds(60); + private double backoffMultiplier = 2.0; + private Duration maxCooldown = Duration.ofSeconds(600); + + private Builder() {} + + /** + * Turn supervision on or off, leaving the rest of the configuration in place. + * + * @param enabled false to make the breaker inert + * @return this builder + */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Place the named tools under supervision. + * + * @param toolNames tool names to supervise + * @return this builder + */ + public Builder monitorTools(Collection toolNames) { + if (toolNames != null) { + this.monitoredTools.addAll(toolNames); + } + return this; + } + + /** + * Place the named tools under supervision. + * + * @param toolNames tool names to supervise + * @return this builder + */ + public Builder monitorTools(String... toolNames) { + if (toolNames != null) { + this.monitoredTools.addAll(Set.of(toolNames)); + } + return this; + } + + /** + * Supervise every tool, subject to {@link #excludeTools(Collection)}. + * + *

Read the class-level note on infrastructure tools before enabling this. Opt-out + * supervision also covers tools the framework adds per call, such as the structured-output + * tool used to produce a final answer; withholding one of those does not degrade the agent, + * it stops it completing. Name the unstable dependencies instead, or exclude the tools that + * must always stay reachable. + * + * @param monitorAllTools true to switch to opt-out supervision + * @return this builder + */ + public Builder monitorAllTools(boolean monitorAllTools) { + this.monitorAllTools = monitorAllTools; + return this; + } + + /** + * Exempt the named tools from supervision, overriding every other setting. + * + * @param toolNames tool names to exempt + * @return this builder + */ + public Builder excludeTools(Collection toolNames) { + if (toolNames != null) { + this.excludedTools.addAll(toolNames); + } + return this; + } + + /** + * Exempt the named tools from supervision, overriding every other setting. + * + * @param toolNames tool names to exempt + * @return this builder + */ + public Builder excludeTools(String... toolNames) { + if (toolNames != null) { + this.excludedTools.addAll(Set.of(toolNames)); + } + return this; + } + + /** + * Set how many consecutive failures trip a closed circuit. + * + * @param failureThreshold threshold, at least 1 + * @return this builder + */ + public Builder failureThreshold(int failureThreshold) { + this.failureThreshold = failureThreshold; + return this; + } + + /** + * Set the cooldown applied on the first trip. + * + * @param initialCooldown positive duration + * @return this builder + */ + public Builder initialCooldown(Duration initialCooldown) { + this.initialCooldown = initialCooldown; + return this; + } + + /** + * Set the growth factor applied to the cooldown on each successive trip. + * + * @param backoffMultiplier factor, at least 1.0 (1.0 gives a fixed cooldown) + * @return this builder + */ + public Builder backoffMultiplier(double backoffMultiplier) { + this.backoffMultiplier = backoffMultiplier; + return this; + } + + /** + * Set the cooldown ceiling. + * + * @param maxCooldown duration, not shorter than the initial cooldown + * @return this builder + */ + public Builder maxCooldown(Duration maxCooldown) { + this.maxCooldown = maxCooldown; + return this; + } + + /** + * Validate and build the configuration. + * + * @return an immutable configuration + * @throws IllegalArgumentException if any value is out of range or the cooldown bounds are + * inverted + */ + public ToolCircuitBreakerConfig build() { + if (failureThreshold < 1) { + throw new IllegalArgumentException( + "failureThreshold must be at least 1, got " + failureThreshold); + } + if (initialCooldown == null + || initialCooldown.isNegative() + || initialCooldown.isZero()) { + throw new IllegalArgumentException( + "initialCooldown must be positive, got " + initialCooldown); + } + if (maxCooldown == null || maxCooldown.isNegative() || maxCooldown.isZero()) { + throw new IllegalArgumentException( + "maxCooldown must be positive, got " + maxCooldown); + } + if (backoffMultiplier < 1.0 || !Double.isFinite(backoffMultiplier)) { + throw new IllegalArgumentException( + "backoffMultiplier must be a finite value of at least 1.0, got " + + backoffMultiplier); + } + if (maxCooldown.compareTo(initialCooldown) < 0) { + throw new IllegalArgumentException( + "maxCooldown (" + + maxCooldown + + ") must not be shorter than initialCooldown (" + + initialCooldown + + ")"); + } + return new ToolCircuitBreakerConfig(this); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java new file mode 100644 index 0000000000..cf711a7528 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java @@ -0,0 +1,174 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.message.ToolResultState; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ReasoningInput; +import io.agentscope.core.model.ToolSchema; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +/** + * Middleware that applies a {@link ToolCircuitBreaker} to an agent, so a tool that keeps failing + * stops being offered to the model until it is worth trying again. + * + *

It occupies two interception points, which together close the state machine: + * + *

+ * + *

Filtering happens per turn on a copy of the schema list. Nothing registered on the {@link + * io.agentscope.core.tool.Toolkit} is mutated, so a circuit tripped while serving one session cannot + * remove a tool from a concurrent session, and the tool registrations the application declared stay + * authoritative. Recovery needs no repair step for the same reason: once the breaker stops + * withholding a tool, the unfiltered list is already correct. + * + *

Usage + * + *

{@code
+ * ToolCircuitBreakerConfig config = ToolCircuitBreakerConfig.builder()
+ *         .monitorTools("query_weather", "query_destination_news")
+ *         .failureThreshold(3)
+ *         .initialCooldown(Duration.ofSeconds(60))
+ *         .maxCooldown(Duration.ofSeconds(600))
+ *         .build();
+ *
+ * ReActAgent agent = ReActAgent.builder()
+ *         .model(model)
+ *         .toolkit(toolkit)
+ *         .middleware(new ToolCircuitBreakerMiddleware(new ToolCircuitBreaker(config)))
+ *         .build();
+ * }
+ * + *

Share one breaker (and therefore one store) across the agents that call the same dependency, so + * they learn from each other's failures instead of each discovering the outage separately. + */ +public class ToolCircuitBreakerMiddleware implements MiddlewareBase { + + private static final Logger logger = + LoggerFactory.getLogger(ToolCircuitBreakerMiddleware.class); + + private final ToolCircuitBreaker breaker; + + /** + * Wrap a breaker as middleware. + * + * @param breaker the breaker holding policy and state + */ + public ToolCircuitBreakerMiddleware(ToolCircuitBreaker breaker) { + this.breaker = Objects.requireNonNull(breaker, "breaker must not be null"); + } + + /** + * Convenience constructor building a breaker with the default in-process store. + * + * @param config supervision and backoff policy + */ + public ToolCircuitBreakerMiddleware(ToolCircuitBreakerConfig config) { + this(new ToolCircuitBreaker(config)); + } + + /** + * The breaker being applied, exposed so callers can inspect circuit state or reset a tool. + * + * @return the underlying breaker + */ + public ToolCircuitBreaker getBreaker() { + return breaker; + } + + @Override + public Flux onReasoning( + Agent agent, + RuntimeContext ctx, + ReasoningInput input, + Function> next) { + List tools = input.tools(); + if (tools == null || tools.isEmpty()) { + return next.apply(input); + } + List visible = new ArrayList<>(tools.size()); + List withheld = null; + for (ToolSchema tool : tools) { + if (tool != null && breaker.isWithheld(tool.getName())) { + if (withheld == null) { + withheld = new ArrayList<>(2); + } + withheld.add(tool.getName()); + continue; + } + visible.add(tool); + } + if (withheld == null) { + return next.apply(input); + } + logger.debug( + "Withholding tripped tools from this reasoning turn: {} of {} tools hidden," + + " hidden={}", + withheld.size(), + tools.size(), + withheld); + return next.apply(new ReasoningInput(input.messages(), visible, input.options())); + } + + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + return next.apply(input).doOnNext(this::recordOutcome); + } + + /** + * Feed one tool result into the breaker. + * + *

Only {@link ToolResultState#ERROR} counts as a failure. {@code DENIED} is a policy refusal + * and {@code INTERRUPTED} a cancellation — neither is evidence about the dependency, and + * counting them would let a user who declines a confirmation prompt trip the circuit. {@code + * RUNNING} marks a suspended call whose outcome is not known yet. + */ + private void recordOutcome(AgentEvent event) { + if (!(event instanceof ToolResultEndEvent result)) { + return; + } + String toolName = result.getToolCallName(); + ToolResultState state = result.getState(); + if (toolName == null || state == null) { + return; + } + if (state == ToolResultState.ERROR) { + breaker.recordFailure(toolName); + } else if (state == ToolResultState.SUCCESS) { + breaker.recordSuccess(toolName); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java new file mode 100644 index 0000000000..58d6aacbc0 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +/** + * Persistence contract for tool circuit-breaker state. + * + *

Implementations are pure state holders: they must not apply the backoff policy, decide when a + * circuit trips, or consult a clock. All policy lives in {@link ToolCircuitBreaker}, which keeps + * this SPI stable when the policy evolves and makes the policy unit-testable without a store. + * + *

{@link InMemoryToolCircuitBreakerStore} is the default and is sufficient for a single + * process. A distributed implementation (for example the Redis-backed store in + * {@code agentscope-extensions-redis}) lets every replica share one view of a broken tool, so a + * dependency that node A found down is not re-probed by nodes B and C in parallel. + * + *

Threading

+ * + *

Implementations must be safe for concurrent use from multiple threads. {@link + * #recordFailure(String)} and {@link #open(String, long)} must be atomic, since concurrent tool + * calls in one ReAct turn race on both. + */ +public interface ToolCircuitBreakerStore { + + /** + * Atomically increment the consecutive-failure counter and return the new value. + * + * @param toolName tool being counted + * @return the counter value after incrementing, starting at 1 + */ + long recordFailure(String toolName); + + /** + * Clear the consecutive-failure counter. + * + *

Called whenever a tool succeeds, which is what makes the threshold count + * consecutive failures rather than lifetime failures. + * + * @param toolName tool to reset + */ + void resetFailures(String toolName); + + /** + * Read the consecutive-failure counter without modifying it. + * + * @param toolName tool to read + * @return current count, or {@code 0} when nothing is recorded + */ + long failureCount(String toolName); + + /** + * Atomically move the circuit to OPEN: increment the backoff generation and stamp the open + * instant, returning the new generation. + * + *

The caller supplies the timestamp so that it shares a clock with the cooldown comparison + * in {@link ToolCircuitBreaker}; a store must never substitute its own clock. + * + * @param toolName tool to trip + * @param openedAtEpochMilli instant the circuit opened, in epoch milliseconds + * @return the backoff generation after incrementing, starting at 1 + */ + long open(String toolName, long openedAtEpochMilli); + + /** + * Reset the circuit to CLOSED, discarding both the open timestamp and the backoff generation. + * + *

Dropping the generation means a tool that recovers starts its next incident from the + * initial cooldown instead of inheriting an old, long backoff. + * + * @param toolName tool to close + */ + void close(String toolName); + + /** + * Read the trip state in a single round trip. + * + * @param toolName tool to read + * @return current snapshot, never null; {@link ToolCircuitSnapshot#CLOSED} when no state exists + */ + ToolCircuitSnapshot snapshot(String toolName); +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java new file mode 100644 index 0000000000..ddd27e8e9e --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +/** + * Immutable point-in-time view of a tool's trip state, read in a single store round trip. + * + *

The cooldown duration is deliberately not part of the snapshot: it is derived from + * {@code generation} by {@link ToolCircuitBreaker#cooldownFor(long)}, so changing the backoff + * policy takes effect immediately and never has to be migrated in the store. + * + * @param generation number of times the circuit has tripped, driving exponential backoff; + * {@code 0} means it has never tripped + * @param openedAtEpochMilli wall-clock instant the circuit was last opened, or {@code 0} when + * the circuit is not open — a positive value is the sole marker of the OPEN state, so no + * separate boolean has to be kept consistent with it + */ +public record ToolCircuitSnapshot(long generation, long openedAtEpochMilli) { + + /** Snapshot of a tool that has never tripped. */ + public static final ToolCircuitSnapshot CLOSED = new ToolCircuitSnapshot(0L, 0L); + + /** + * Whether the circuit is currently open, ignoring whether its cooldown has elapsed. + * + *

An open circuit whose cooldown has elapsed is reported as + * {@link ToolCircuitState#HALF_OPEN} by {@link ToolCircuitBreaker#state(String)}; this method + * only reports the persisted flag. + * + * @return true when an open timestamp is recorded + */ + public boolean isOpen() { + return openedAtEpochMilli > 0L; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitState.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitState.java new file mode 100644 index 0000000000..de833f92bb --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitState.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +/** + * Lifecycle state of a single tool's circuit. + * + *

The state is always derived from the persisted snapshot plus the current time + * rather than stored directly, so a cooldown that elapsed while no traffic flowed is observed + * correctly on the next read without any background scheduler. + * + *

+ *   CLOSED --failure x threshold--> OPEN --cooldown elapsed--> HALF_OPEN --success--> CLOSED
+ *                                    ^                              |
+ *                                    +---------- failure -----------+
+ * 
+ */ +public enum ToolCircuitState { + + /** + * Normal operation: the tool is exposed to the model and consecutive failures are counted. + * + *

Named after a closed electrical circuit — current flows, so {@code CLOSED} means + * "healthy", not "unavailable". + */ + CLOSED, + + /** + * Tripped and still cooling down: the tool is withheld from the model. + */ + OPEN, + + /** + * Cooldown elapsed: the tool is exposed again as a single probe. + * + *

A successful probe closes the circuit; a failing probe re-opens it with the next + * (longer) backoff generation. + */ + HALF_OPEN +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java new file mode 100644 index 0000000000..9a5a286660 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +/** Contract of {@link InMemoryToolCircuitBreakerStore}, including its atomicity guarantees. */ +class InMemoryToolCircuitBreakerStoreTest { + + private static final String TOOL = "query_weather"; + private static final String OTHER_TOOL = "query_news"; + + private final InMemoryToolCircuitBreakerStore store = new InMemoryToolCircuitBreakerStore(); + + @Test + void unknownToolReadsAsClosedWithNoFailures() { + assertEquals(0L, store.failureCount(TOOL)); + assertEquals(ToolCircuitSnapshot.CLOSED, store.snapshot(TOOL)); + assertFalse(store.snapshot(TOOL).isOpen()); + } + + @Test + void failureCounterStartsAtOneAndIncrements() { + assertEquals(1L, store.recordFailure(TOOL)); + assertEquals(2L, store.recordFailure(TOOL)); + assertEquals(2L, store.failureCount(TOOL)); + } + + @Test + void resetClearsOnlyTheNamedToolsCounter() { + store.recordFailure(TOOL); + store.recordFailure(OTHER_TOOL); + + store.resetFailures(TOOL); + + assertEquals(0L, store.failureCount(TOOL)); + assertEquals(1L, store.failureCount(OTHER_TOOL)); + } + + @Test + void openStampsTimestampAndAdvancesGeneration() { + assertEquals(1L, store.open(TOOL, 1_000L)); + + ToolCircuitSnapshot first = store.snapshot(TOOL); + assertTrue(first.isOpen()); + assertEquals(1L, first.generation()); + assertEquals(1_000L, first.openedAtEpochMilli()); + + assertEquals(2L, store.open(TOOL, 5_000L)); + + ToolCircuitSnapshot second = store.snapshot(TOOL); + assertEquals(2L, second.generation()); + assertEquals(5_000L, second.openedAtEpochMilli()); + } + + @Test + void closeDiscardsGenerationSoBackoffRestarts() { + store.open(TOOL, 1_000L); + store.open(TOOL, 2_000L); + + store.close(TOOL); + + assertFalse(store.snapshot(TOOL).isOpen()); + assertEquals(0L, store.snapshot(TOOL).generation()); + assertEquals(1L, store.open(TOOL, 3_000L)); + } + + @Test + void toolsDoNotShareState() { + store.open(TOOL, 1_000L); + + assertTrue(store.snapshot(TOOL).isOpen()); + assertFalse(store.snapshot(OTHER_TOOL).isOpen()); + } + + @Test + void concurrentFailureCountsAreNotLost() throws Exception { + int threads = 8; + int perThread = 500; + + runConcurrently(threads, perThread, () -> store.recordFailure(TOOL)); + + assertEquals((long) threads * perThread, store.failureCount(TOOL)); + } + + @Test + void concurrentOpensYieldContiguousGenerations() throws Exception { + int threads = 8; + int perThread = 200; + AtomicLong maxGeneration = new AtomicLong(); + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + for (int t = 0; t < threads; t++) { + pool.submit( + () -> { + for (int i = 0; i < perThread; i++) { + long generation = store.open(TOOL, 1_000L + i); + maxGeneration.accumulateAndGet(generation, Math::max); + } + }); + } + pool.shutdown(); + assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + + // Every open must observe a distinct, gap-free generation, so the highest value seen equals + // the number of opens performed. + assertEquals((long) threads * perThread, maxGeneration.get()); + assertEquals((long) threads * perThread, store.snapshot(TOOL).generation()); + } + + private void runConcurrently(int threads, int perThread, Runnable task) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + for (int t = 0; t < threads; t++) { + pool.submit( + () -> { + for (int i = 0; i < perThread; i++) { + task.run(); + } + }); + } + pool.shutdown(); + assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/MutableClock.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/MutableClock.java new file mode 100644 index 0000000000..e5e7ba324d --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/MutableClock.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +/** + * Hand-advanced {@link Clock} so cooldown transitions can be exercised without sleeping. + * + *

Every circuit transition is a function of stored state plus "now", so driving time explicitly + * keeps these tests deterministic and instant. + */ +final class MutableClock extends Clock { + + private final ZoneId zone; + private Instant instant; + + MutableClock(Instant start) { + this(start, ZoneId.of("UTC")); + } + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + void advance(Duration amount) { + instant = instant.plus(amount); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId newZone) { + return new MutableClock(instant, newZone); + } + + @Override + public Instant instant() { + return instant; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java new file mode 100644 index 0000000000..ea89eb0415 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java @@ -0,0 +1,297 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultState; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.ReasoningInput; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolSchema; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +/** + * End-to-end behaviour of {@link ToolCircuitBreakerMiddleware}: outcomes observed on the acting + * stream must drive what the reasoning phase advertises to the model. + */ +class ToolCircuitBreakerMiddlewareTest { + + private static final String REPLY_ID = "reply-1"; + private static final String WEATHER = "query_weather"; + private static final String DATABASE = "query_database"; + + private final MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z")); + + // ==================== Reasoning: withholding tripped tools ==================== + + @Test + void trippedToolIsRemovedFromTheToolsOfferedToTheModel() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + + failTool(middleware, WEATHER); + + List offered = offeredToolNames(middleware, WEATHER, DATABASE); + assertEquals(List.of(DATABASE), offered); + } + + @Test + void healthyToolsArePassedThroughUntouched() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + + List offered = offeredToolNames(middleware, WEATHER, DATABASE); + assertEquals(List.of(WEATHER, DATABASE), offered); + } + + @Test + void unfilteredTurnForwardsTheOriginalInputWithoutCopying() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + ReasoningInput input = + new ReasoningInput( + List.of(userMsg("plan a trip")), List.of(schema(DATABASE)), null); + AtomicReference seen = new AtomicReference<>(); + + middleware + .onReasoning( + null, + null, + input, + received -> { + seen.set(received); + return Flux.empty(); + }) + .collectList() + .block(); + + assertSame(input, seen.get()); + } + + @Test + void filteringPreservesMessagesAndOptions() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + + Msg message = userMsg("what is the weather"); + GenerateOptions options = GenerateOptions.builder().build(); + ReasoningInput input = + new ReasoningInput( + List.of(message), List.of(schema(WEATHER), schema(DATABASE)), options); + AtomicReference seen = new AtomicReference<>(); + + middleware + .onReasoning( + null, + null, + input, + received -> { + seen.set(received); + return Flux.empty(); + }) + .collectList() + .block(); + + assertEquals(List.of(message), seen.get().messages()); + assertSame(options, seen.get().options()); + assertEquals(1, seen.get().tools().size()); + assertEquals(DATABASE, seen.get().tools().get(0).getName()); + } + + @Test + void toolIsOfferedAgainOnceTheCooldownElapses() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + assertEquals(List.of(DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); + + clock.advance(Duration.ofSeconds(60)); + + assertEquals(List.of(WEATHER, DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); + } + + @Test + void unsupervisedToolIsNeverWithheldHoweverOftenItFails() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + + for (int i = 0; i < 5; i++) { + failTool(middleware, DATABASE); + } + + assertEquals(List.of(WEATHER, DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); + } + + // ==================== Acting: counting outcomes ==================== + + @Test + void errorResultsTripTheCircuitOnceTheThresholdIsReached() { + ToolCircuitBreakerMiddleware middleware = middleware(3); + + failTool(middleware, WEATHER); + failTool(middleware, WEATHER); + assertFalse(middleware.getBreaker().isWithheld(WEATHER)); + + failTool(middleware, WEATHER); + assertTrue(middleware.getBreaker().isWithheld(WEATHER)); + } + + @Test + void deniedResultsDoNotCountAsDependencyFailures() { + ToolCircuitBreakerMiddleware middleware = middleware(2); + + emit(middleware, new ToolResultEndEvent(REPLY_ID, "c1", WEATHER, ToolResultState.DENIED)); + emit(middleware, new ToolResultEndEvent(REPLY_ID, "c2", WEATHER, ToolResultState.DENIED)); + emit(middleware, new ToolResultEndEvent(REPLY_ID, "c3", WEATHER, ToolResultState.DENIED)); + + assertEquals(ToolCircuitState.CLOSED, middleware.getBreaker().state(WEATHER)); + } + + @Test + void interruptedAndRunningResultsDoNotCountAsDependencyFailures() { + ToolCircuitBreakerMiddleware middleware = middleware(2); + + emit( + middleware, + new ToolResultEndEvent(REPLY_ID, "c1", WEATHER, ToolResultState.INTERRUPTED)); + emit(middleware, new ToolResultEndEvent(REPLY_ID, "c2", WEATHER, ToolResultState.RUNNING)); + emit( + middleware, + new ToolResultEndEvent(REPLY_ID, "c3", WEATHER, ToolResultState.INTERRUPTED)); + + assertEquals(ToolCircuitState.CLOSED, middleware.getBreaker().state(WEATHER)); + } + + @Test + void successResetsTheFailureStreak() { + ToolCircuitBreakerMiddleware middleware = middleware(3); + + failTool(middleware, WEATHER); + failTool(middleware, WEATHER); + succeedTool(middleware, WEATHER); + failTool(middleware, WEATHER); + failTool(middleware, WEATHER); + + assertEquals(ToolCircuitState.CLOSED, middleware.getBreaker().state(WEATHER)); + } + + @Test + void actingStreamIsForwardedUnchanged() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + ToolResultEndEvent event = + new ToolResultEndEvent(REPLY_ID, "c1", WEATHER, ToolResultState.ERROR); + + List forwarded = + middleware + .onActing( + null, null, new ActingInput(List.of()), ignored -> Flux.just(event)) + .collectList() + .block(); + + assertEquals(1, forwarded.size()); + assertSame(event, forwarded.get(0)); + } + + @Test + void probeFailureAfterCooldownExtendsTheWithholdingPeriod() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + clock.advance(Duration.ofSeconds(60)); + + // The half-open probe fails, so the second generation applies: 120s, not 60s. + failTool(middleware, WEATHER); + + clock.advance(Duration.ofSeconds(60)); + assertEquals(List.of(DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); + + clock.advance(Duration.ofSeconds(60)); + assertEquals(List.of(WEATHER, DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); + } + + // ==================== Helpers ==================== + + private ToolCircuitBreakerMiddleware middleware(int threshold) { + ToolCircuitBreakerConfig config = + ToolCircuitBreakerConfig.builder() + .monitorTools(WEATHER) + .failureThreshold(threshold) + .initialCooldown(Duration.ofSeconds(60)) + .backoffMultiplier(2.0) + .maxCooldown(Duration.ofSeconds(600)) + .build(); + return new ToolCircuitBreakerMiddleware( + new ToolCircuitBreaker(config, new InMemoryToolCircuitBreakerStore(), clock)); + } + + private void failTool(ToolCircuitBreakerMiddleware middleware, String toolName) { + emit(middleware, new ToolResultEndEvent(REPLY_ID, "call", toolName, ToolResultState.ERROR)); + } + + private void succeedTool(ToolCircuitBreakerMiddleware middleware, String toolName) { + emit( + middleware, + new ToolResultEndEvent(REPLY_ID, "call", toolName, ToolResultState.SUCCESS)); + } + + private void emit(ToolCircuitBreakerMiddleware middleware, AgentEvent event) { + middleware + .onActing(null, null, new ActingInput(List.of()), ignored -> Flux.just(event)) + .collectList() + .block(); + } + + private List offeredToolNames( + ToolCircuitBreakerMiddleware middleware, String... toolNames) { + List schemas = new ArrayList<>(); + for (String toolName : toolNames) { + schemas.add(schema(toolName)); + } + AtomicReference seen = new AtomicReference<>(); + middleware + .onReasoning( + null, + null, + new ReasoningInput(List.of(), schemas, null), + received -> { + seen.set(received); + return Flux.empty(); + }) + .collectList() + .block(); + return seen.get().tools().stream().map(ToolSchema::getName).toList(); + } + + private static ToolSchema schema(String name) { + return ToolSchema.builder().name(name).description(name).build(); + } + + private static Msg userMsg(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java new file mode 100644 index 0000000000..37c1ae712b --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java @@ -0,0 +1,331 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.circuitbreaker; + +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 java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** State-machine, backoff and supervision-scope behaviour of {@link ToolCircuitBreaker}. */ +class ToolCircuitBreakerTest { + + private static final String WEATHER = "query_weather"; + private static final String DATABASE = "query_database"; + + private final MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z")); + + // ==================== Supervision scope ==================== + + @Test + void breakerIsInertUntilToolsAreNamed() { + ToolCircuitBreaker breaker = breaker(ToolCircuitBreakerConfig.builder()); + + for (int i = 0; i < 10; i++) { + breaker.recordFailure(WEATHER); + } + + assertFalse(breaker.supervises(WEATHER)); + assertFalse(breaker.isWithheld(WEATHER)); + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + } + + @Test + void unnamedToolIsNotSupervisedWhenAnotherToolIs() { + ToolCircuitBreaker breaker = + breaker(ToolCircuitBreakerConfig.builder().monitorTools(WEATHER)); + + for (int i = 0; i < 10; i++) { + breaker.recordFailure(DATABASE); + } + + assertFalse(breaker.isWithheld(DATABASE)); + assertTrue(breaker.supervises(WEATHER)); + } + + @Test + void exclusionOverridesMonitorAllTools() { + ToolCircuitBreaker breaker = + breaker( + ToolCircuitBreakerConfig.builder() + .monitorAllTools(true) + .excludeTools(DATABASE)); + + assertTrue(breaker.supervises(WEATHER)); + assertFalse(breaker.supervises(DATABASE)); + } + + @Test + void exclusionOverridesExplicitMonitoring() { + ToolCircuitBreaker breaker = + breaker( + ToolCircuitBreakerConfig.builder() + .monitorTools(WEATHER) + .excludeTools(WEATHER)); + + assertFalse(breaker.supervises(WEATHER)); + } + + @Test + void disabledConfigSupervisesNothing() { + ToolCircuitBreaker breaker = + breaker( + ToolCircuitBreakerConfig.builder() + .enabled(false) + .monitorTools(WEATHER) + .failureThreshold(1)); + + breaker.recordFailure(WEATHER); + + assertFalse(breaker.supervises(WEATHER)); + assertFalse(breaker.isWithheld(WEATHER)); + } + + @Test + void nullToolNameIsNeverSupervised() { + ToolCircuitBreaker breaker = + breaker(ToolCircuitBreakerConfig.builder().monitorAllTools(true)); + + assertFalse(breaker.supervises(null)); + assertFalse(breaker.isWithheld(null)); + } + + // ==================== CLOSED -> OPEN ==================== + + @Test + void tripsOnlyOnceThresholdConsecutiveFailuresAreReached() { + ToolCircuitBreaker breaker = weatherBreaker(3); + + breaker.recordFailure(WEATHER); + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + assertFalse(breaker.isWithheld(WEATHER)); + + breaker.recordFailure(WEATHER); + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + assertFalse(breaker.isWithheld(WEATHER)); + + breaker.recordFailure(WEATHER); + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + assertTrue(breaker.isWithheld(WEATHER)); + } + + @Test + void successBreaksTheStreakSoScatteredFailuresNeverTrip() { + ToolCircuitBreaker breaker = weatherBreaker(3); + + breaker.recordFailure(WEATHER); + breaker.recordFailure(WEATHER); + breaker.recordSuccess(WEATHER); + breaker.recordFailure(WEATHER); + breaker.recordFailure(WEATHER); + + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + } + + // ==================== OPEN -> HALF_OPEN -> CLOSED ==================== + + @Test + void toolStaysWithheldForTheWholeCooldown() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + + clock.advance(Duration.ofSeconds(59)); + + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + assertTrue(breaker.isWithheld(WEATHER)); + } + + @Test + void cooldownElapsingOffersTheToolAgainAsAProbe() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + + clock.advance(Duration.ofSeconds(60)); + + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + assertFalse(breaker.isWithheld(WEATHER)); + } + + @Test + void successfulProbeClosesCircuitAndForgetsBackoff() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + + breaker.recordSuccess(WEATHER); + + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + + // Backoff was discarded, so the next incident starts from the initial cooldown again. + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + } + + @Test + void failedProbeReopensCircuitWithTheNextLongerCooldown() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + + breaker.recordFailure(WEATHER); + + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + + // Second generation waits 120s, so the original 60s is no longer enough. + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + } + + @Test + void failureWhileWithheldDoesNotDeepenBackoff() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + + // A call the model decided on in the same turn the circuit tripped still lands here. + breaker.recordFailure(WEATHER); + breaker.recordFailure(WEATHER); + + // Cooldown must still be the first generation's 60s, not 120s or 240s. + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + } + + @Test + void successWhileWithheldDoesNotCloseCircuitEarly() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + + breaker.recordSuccess(WEATHER); + + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + assertTrue(breaker.isWithheld(WEATHER)); + } + + @Test + void resetClearsStateAndAccumulatedBackoff() { + ToolCircuitBreaker breaker = weatherBreaker(1); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + breaker.recordFailure(WEATHER); + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + + breaker.reset(WEATHER); + + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + + // Backoff restarted: one trip then 60s is enough to probe again. + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + } + + // ==================== Exponential backoff ==================== + + @Test + void cooldownDoublesPerGenerationAndIsCapped() { + ToolCircuitBreaker breaker = weatherBreaker(1); + + assertEquals(Duration.ZERO, breaker.cooldownFor(0)); + assertEquals(Duration.ofSeconds(60), breaker.cooldownFor(1)); + assertEquals(Duration.ofSeconds(120), breaker.cooldownFor(2)); + assertEquals(Duration.ofSeconds(240), breaker.cooldownFor(3)); + assertEquals(Duration.ofSeconds(480), breaker.cooldownFor(4)); + assertEquals(Duration.ofSeconds(600), breaker.cooldownFor(5)); + assertEquals(Duration.ofSeconds(600), breaker.cooldownFor(6)); + } + + @Test + void hugeGenerationClampsToMaxInsteadOfOverflowing() { + ToolCircuitBreaker breaker = weatherBreaker(1); + + assertEquals(Duration.ofSeconds(600), breaker.cooldownFor(1_000L)); + assertEquals(Duration.ofSeconds(600), breaker.cooldownFor(Long.MAX_VALUE)); + } + + @Test + void multiplierOfOneGivesAFixedCooldown() { + ToolCircuitBreaker breaker = + breaker( + ToolCircuitBreakerConfig.builder() + .monitorTools(WEATHER) + .failureThreshold(1) + .backoffMultiplier(1.0) + .initialCooldown(Duration.ofSeconds(30)) + .maxCooldown(Duration.ofSeconds(600))); + + assertEquals(Duration.ofSeconds(30), breaker.cooldownFor(1)); + assertEquals(Duration.ofSeconds(30), breaker.cooldownFor(5)); + } + + // ==================== Configuration validation ==================== + + @Test + void configRejectsNonPositiveThreshold() { + assertThrows( + IllegalArgumentException.class, + () -> ToolCircuitBreakerConfig.builder().failureThreshold(0).build()); + } + + @Test + void configRejectsMultiplierBelowOne() { + assertThrows( + IllegalArgumentException.class, + () -> ToolCircuitBreakerConfig.builder().backoffMultiplier(0.5).build()); + } + + @Test + void configRejectsNonPositiveInitialCooldown() { + assertThrows( + IllegalArgumentException.class, + () -> ToolCircuitBreakerConfig.builder().initialCooldown(Duration.ZERO).build()); + } + + @Test + void configRejectsInvertedCooldownBounds() { + assertThrows( + IllegalArgumentException.class, + () -> + ToolCircuitBreakerConfig.builder() + .initialCooldown(Duration.ofSeconds(120)) + .maxCooldown(Duration.ofSeconds(60)) + .build()); + } + + // ==================== Helpers ==================== + + private ToolCircuitBreaker weatherBreaker(int threshold) { + return breaker( + ToolCircuitBreakerConfig.builder() + .monitorTools(WEATHER) + .failureThreshold(threshold) + .initialCooldown(Duration.ofSeconds(60)) + .backoffMultiplier(2.0) + .maxCooldown(Duration.ofSeconds(600))); + } + + private ToolCircuitBreaker breaker(ToolCircuitBreakerConfig.Builder config) { + return new ToolCircuitBreaker(config.build(), new InMemoryToolCircuitBreakerStore(), clock); + } +} diff --git a/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java b/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java new file mode 100644 index 0000000000..e3c9bcb6ed --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java @@ -0,0 +1,211 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.redis.circuitbreaker; + +import io.agentscope.core.tool.circuitbreaker.ToolCircuitBreakerStore; +import io.agentscope.core.tool.circuitbreaker.ToolCircuitSnapshot; +import io.agentscope.extensions.redis.state.RedisClientAdapter; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Redis-backed {@link ToolCircuitBreakerStore}, giving every replica one shared view of a broken + * tool. + * + *

With the in-process store each replica has to rediscover an outage for itself, so an N-replica + * deployment sends roughly N times the failing traffic and burns N times the tokens before the tool + * is withheld everywhere. Sharing the state through Redis means the first replica to trip a circuit + * withholds the tool for all of them, and the state survives a restart or a rescheduled pod. + * + *

Keys

+ * + *

Two keys per tool, both addressed individually so the store works unchanged on Redis Cluster — + * no multi-key script needs its arguments to share a hash slot: + * + *

+ * + *

Encoding generation and timestamp in one value keeps {@link #snapshot(String)} — the hot read, + * executed for every supervised tool on every reasoning turn — down to a single {@code GET}. + * + *

Atomicity

+ * + *

{@link #recordFailure(String)} and {@link #open(String, long)} are Lua scripts, so their + * read-modify-write steps cannot interleave. Doing {@code INCR} and {@code EXPIRE} as two round + * trips would leave a counter without a TTL whenever the second call is lost, and computing the next + * generation client-side would let two replicas tripping at once write the same generation. + * + *

Expiry

+ * + *

Both keys carry a TTL so tools that misbehave once do not accumulate state forever. Keep the + * TTL comfortably longer than the breaker's maximum cooldown: if an open circuit's key expires + * mid-cooldown the tool is offered again early, which fails open — safe, but not what was + * configured. The default of 24h clears the default 600s ceiling by a wide margin. + */ +public class RedisToolCircuitBreakerStore implements ToolCircuitBreakerStore { + + private static final Logger logger = + LoggerFactory.getLogger(RedisToolCircuitBreakerStore.class); + + private static final String DEFAULT_KEY_PREFIX = "agentscope:tool-cb:"; + private static final Duration DEFAULT_TTL = Duration.ofHours(24); + + private static final String FAILURE_SUFFIX = ":fail"; + private static final String CIRCUIT_SUFFIX = ":circuit"; + + /** + * Increment the failure counter and refresh its TTL in one step. + * + *

KEYS[1] = failure key; ARGV[1] = TTL seconds. Returns the new count. + */ + private static final String INCREMENT_FAILURE_SCRIPT = + "local count = redis.call('INCR', KEYS[1]) " + + "redis.call('EXPIRE', KEYS[1], ARGV[1]) " + + "return count"; + + /** + * Advance the generation and stamp the open instant in one step. + * + *

KEYS[1] = circuit key; ARGV[1] = open instant in epoch millis; ARGV[2] = TTL seconds. + * Returns the new generation. + */ + private static final String OPEN_NEXT_GENERATION_SCRIPT = + "local current = redis.call('GET', KEYS[1]) local generation = 0 if current then " + + " local sep = string.find(current, ':', 1, true) if sep then generation =" + + " tonumber(string.sub(current, 1, sep - 1)) or 0 end end generation = generation" + + " + 1 redis.call('SET', KEYS[1], generation .. ':' .. ARGV[1], 'EX', ARGV[2])" + + " return generation"; + + private final RedisClientAdapter client; + private final String keyPrefix; + private final long ttlSeconds; + + /** + * Create a store with the default key prefix ({@code agentscope:tool-cb:}) and a 24h TTL. + * + * @param client Redis client adapter + */ + public RedisToolCircuitBreakerStore(RedisClientAdapter client) { + this(client, DEFAULT_KEY_PREFIX, DEFAULT_TTL); + } + + /** + * Create a store with an explicit key prefix and TTL. + * + * @param client Redis client adapter + * @param keyPrefix prefix for every key, letting environments share one Redis instance + * @param stateTtl how long unused state is retained; must be positive and should exceed the + * breaker's maximum cooldown + */ + public RedisToolCircuitBreakerStore( + RedisClientAdapter client, String keyPrefix, Duration stateTtl) { + this.client = Objects.requireNonNull(client, "client must not be null"); + if (keyPrefix == null || keyPrefix.isBlank()) { + throw new IllegalArgumentException("keyPrefix must not be blank"); + } + if (stateTtl == null || stateTtl.isNegative() || stateTtl.isZero()) { + throw new IllegalArgumentException("stateTtl must be positive, got " + stateTtl); + } + this.keyPrefix = keyPrefix; + this.ttlSeconds = Math.max(1L, stateTtl.toSeconds()); + } + + @Override + public long recordFailure(String toolName) { + return client.evalScript( + INCREMENT_FAILURE_SCRIPT, + List.of(failureKey(toolName)), + List.of(Long.toString(ttlSeconds))); + } + + @Override + public void resetFailures(String toolName) { + client.deleteKeys(failureKey(toolName)); + } + + @Override + public long failureCount(String toolName) { + return parseLong(client.get(failureKey(toolName))); + } + + @Override + public long open(String toolName, long openedAtEpochMilli) { + return client.evalScript( + OPEN_NEXT_GENERATION_SCRIPT, + List.of(circuitKey(toolName)), + List.of(Long.toString(openedAtEpochMilli), Long.toString(ttlSeconds))); + } + + @Override + public void close(String toolName) { + client.deleteKeys(circuitKey(toolName)); + } + + @Override + public ToolCircuitSnapshot snapshot(String toolName) { + String value = client.get(circuitKey(toolName)); + if (value == null || value.isEmpty()) { + return ToolCircuitSnapshot.CLOSED; + } + int separator = value.indexOf(':'); + if (separator <= 0 || separator == value.length() - 1) { + // Unreadable value: treat as closed rather than withholding a tool forever on the + // strength of state nobody can interpret. + logger.warn( + "Ignoring malformed circuit state for tool={}, value={}. Treating the circuit" + + " as closed.", + toolName, + value); + return ToolCircuitSnapshot.CLOSED; + } + long generation = parseLong(value.substring(0, separator)); + long openedAt = parseLong(value.substring(separator + 1)); + if (generation <= 0L || openedAt <= 0L) { + logger.warn( + "Ignoring out-of-range circuit state for tool={}, value={}. Treating the" + + " circuit as closed.", + toolName, + value); + return ToolCircuitSnapshot.CLOSED; + } + return new ToolCircuitSnapshot(generation, openedAt); + } + + private String failureKey(String toolName) { + return keyPrefix + toolName + FAILURE_SUFFIX; + } + + private String circuitKey(String toolName) { + return keyPrefix + toolName + CIRCUIT_SUFFIX; + } + + private static long parseLong(String value) { + if (value == null || value.isEmpty()) { + return 0L; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return 0L; + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java b/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java new file mode 100644 index 0000000000..5b13b10e0b --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java @@ -0,0 +1,261 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.redis.circuitbreaker; + +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.tool.circuitbreaker.ToolCircuitSnapshot; +import io.agentscope.extensions.redis.state.RedisClientAdapter; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Client-side behaviour of {@link RedisToolCircuitBreakerStore}: key naming, argument passing and + * the decoding of persisted circuit values. + * + *

Scope note: the two Lua scripts are executed by Redis, so a fake client cannot run them. These + * tests cover the Java side — which keys are addressed, which arguments the scripts receive, and how + * stored values are decoded, including values a healthy writer would never produce. The scripts' + * server-side effects need a live Redis to verify. + */ +class RedisToolCircuitBreakerStoreTest { + + private static final String TOOL = "query_weather"; + + private final RecordingRedisClient client = new RecordingRedisClient(); + + // ==================== Key naming ==================== + + @Test + void keysCarryThePrefixAndDistinctSuffixes() { + RedisToolCircuitBreakerStore store = store(); + + store.recordFailure(TOOL); + store.open(TOOL, 1_000L); + + assertEquals( + List.of("cb:query_weather:fail", "cb:query_weather:circuit"), client.scriptKeys); + } + + @Test + void resetFailuresDeletesOnlyTheCounter() { + RedisToolCircuitBreakerStore store = store(); + + store.resetFailures(TOOL); + + assertEquals(List.of("cb:query_weather:fail"), client.deleted); + } + + @Test + void closeDeletesOnlyTheCircuitKey() { + RedisToolCircuitBreakerStore store = store(); + + store.close(TOOL); + + assertEquals(List.of("cb:query_weather:circuit"), client.deleted); + } + + // ==================== Script arguments ==================== + + @Test + void failureScriptReceivesTheTtlInSeconds() { + RedisToolCircuitBreakerStore store = + new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofMinutes(30)); + + store.recordFailure(TOOL); + + assertEquals(List.of("1800"), client.scriptArgs.get(0)); + } + + @Test + void openScriptReceivesTheTimestampThenTheTtl() { + RedisToolCircuitBreakerStore store = + new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofHours(24)); + + store.open(TOOL, 1_767_225_600_000L); + + assertEquals(List.of("1767225600000", "86400"), client.scriptArgs.get(0)); + } + + @Test + void subSecondTtlIsFlooredToOneSecondSoKeysNeverPersistForever() { + RedisToolCircuitBreakerStore store = + new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofMillis(200)); + + store.recordFailure(TOOL); + + assertEquals(List.of("1"), client.scriptArgs.get(0)); + } + + // ==================== Decoding persisted state ==================== + + @Test + void snapshotDecodesGenerationAndTimestamp() { + RedisToolCircuitBreakerStore store = store(); + client.values.put("cb:query_weather:circuit", "3:1767225600000"); + + ToolCircuitSnapshot snapshot = store.snapshot(TOOL); + + assertTrue(snapshot.isOpen()); + assertEquals(3L, snapshot.generation()); + assertEquals(1_767_225_600_000L, snapshot.openedAtEpochMilli()); + } + + @Test + void missingKeyDecodesAsClosed() { + RedisToolCircuitBreakerStore store = store(); + + assertEquals(ToolCircuitSnapshot.CLOSED, store.snapshot(TOOL)); + assertFalse(store.snapshot(TOOL).isOpen()); + } + + @Test + void unreadableValuesFailOpenRatherThanWithholdingForever() { + RedisToolCircuitBreakerStore store = store(); + String key = "cb:query_weather:circuit"; + + for (String malformed : + List.of("", "garbage", ":", "3:", ":1767225600000", "0:1767225600000", "3:0")) { + client.values.put(key, malformed); + assertEquals( + ToolCircuitSnapshot.CLOSED, + store.snapshot(TOOL), + "expected a closed circuit for stored value: '" + malformed + "'"); + } + } + + @Test + void nonNumericFailureCountReadsAsZero() { + RedisToolCircuitBreakerStore store = store(); + client.values.put("cb:query_weather:fail", "not-a-number"); + + assertEquals(0L, store.failureCount(TOOL)); + } + + @Test + void failureCountIsReadFromTheCounterKey() { + RedisToolCircuitBreakerStore store = store(); + client.values.put("cb:query_weather:fail", "7"); + + assertEquals(7L, store.failureCount(TOOL)); + } + + // ==================== Construction ==================== + + @Test + void constructorRejectsBlankPrefixAndNonPositiveTtl() { + assertThrows( + IllegalArgumentException.class, + () -> new RedisToolCircuitBreakerStore(client, " ", Duration.ofHours(1))); + assertThrows( + IllegalArgumentException.class, + () -> new RedisToolCircuitBreakerStore(client, "cb:", Duration.ZERO)); + assertThrows( + NullPointerException.class, + () -> new RedisToolCircuitBreakerStore(null, "cb:", Duration.ofHours(1))); + } + + private RedisToolCircuitBreakerStore store() { + return new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofHours(24)); + } + + /** Fake client recording the keys and arguments each call addresses. */ + private static final class RecordingRedisClient implements RedisClientAdapter { + + private final Map values = new HashMap<>(); + private final List scriptKeys = new ArrayList<>(); + private final List> scriptArgs = new ArrayList<>(); + private final List deleted = new ArrayList<>(); + + @Override + public long evalScript(String script, List keys, List args) { + scriptKeys.addAll(keys); + scriptArgs.add(List.copyOf(args)); + return 1L; + } + + @Override + public String get(String key) { + return values.get(key); + } + + @Override + public void deleteKeys(String... keys) { + for (String key : keys) { + deleted.add(key); + values.remove(key); + } + } + + @Override + public void set(String key, String value) { + values.put(key, value); + } + + @Override + public boolean keyExists(String key) { + return values.containsKey(key); + } + + @Override + public void rightPushList(String key, String value) { + throw new UnsupportedOperationException(); + } + + @Override + public List rangeList(String key, long start, long end) { + throw new UnsupportedOperationException(); + } + + @Override + public long getListLength(String key) { + throw new UnsupportedOperationException(); + } + + @Override + public void addToSet(String key, String member) { + throw new UnsupportedOperationException(); + } + + @Override + public Set getSetMembers(String key) { + throw new UnsupportedOperationException(); + } + + @Override + public long getSetSize(String key) { + throw new UnsupportedOperationException(); + } + + @Override + public Set findKeysByPattern(String pattern) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + // nothing to release + } + } +} From e7c82dc957068c7f551f4026c1b8682a1e754051 Mon Sep 17 00:00:00 2001 From: cx <415784429@qq.com> Date: Mon, 7 Sep 2026 14:54:14 +0800 Subject: [PATCH 2/2] fix(tool): make circuit-breaker transitions atomic and admit one recovery probe Review of #2984 found two concurrency defects. Both came from the same root cause: the store made individual methods atomic, but a policy transition spanned several of them, and the SPI had no conditional write. 1. The failure threshold did not really count consecutive failures. A CLOSED failure ran recordFailure, compared the returned count, called open and then resetFailures as separate operations, so a concurrent success could clear the streak in between without invalidating the stale threshold result. The circuit then tripped even though a success had broken the streak. 2. HALF_OPEN was derived from the elapsed timestamp alone, so every concurrent turn observed it, advertised the tool and probed at once. Each failing probe advanced the generation again, jumping straight to the cooldown ceiling and skipping the backoff ramp - the opposite of what more replicas should do. This also contradicted the documented "single probe" semantics. Hold all state for a tool in one immutable snapshot and publish every transition as one compare-and-set against the exact value the caller observed: - Replace the six imperative store methods with snapshot / compareAndSet / reset. A caller that loses the race re-reads and recomputes, so a stale decision can never commit. The threshold is now reached and the circuit opened in the same compare-and-set, so the counter is never persisted at the threshold and no caller can observe or act on that intermediate state. - Extend the snapshot with a probe token and lease. tryAcquireProbe compare-and- sets the claim, so exactly one turn may advertise a half-open tool while the others keep withholding it. Outcomes are conditional on the token, so a result from a superseded probe cannot close or re-open a newer one, and the lease lets another turn take over when a holder never reports. - Carry the claim on the per-call RuntimeContext, bound to the tool-call id the model chooses. A turn that never calls the advertised tool, a refused call and a cancelled call all hand the claim back rather than waiting out the lease; a suspended call keeps it because its outcome is still pending. - Encode the whole snapshot in one Redis key and compare-and-set it in one Lua script. Every script still touches a single key, so the store needs no hash tag and stays Redis Cluster safe. - Add probeTimeout (default 5 minutes, matching the toolkit's default tool execution timeout) and correct the class javadoc, which had claimed the concurrent open was harmless and cost at most one extra backoff step. Tests use deterministic coordination rather than stress loops: a barrier store suspends the tripping compare-and-set so a success can be interleaved into the exact window, and eight turns are released together after a cooldown to assert only one is offered the tool. Both were verified to fail against the old behaviour before the fix was kept. No scheduler, background thread or distributed lock is introduced, and opt-in supervision, per-turn filtering and the public lifecycle are unchanged. --- .../InMemoryToolCircuitBreakerStore.java | 54 ++- .../circuitbreaker/ToolCircuitBreaker.java | 323 +++++++++++++----- .../ToolCircuitBreakerConfig.java | 31 ++ .../ToolCircuitBreakerMiddleware.java | 181 ++++++++-- .../ToolCircuitBreakerStore.java | 69 ++-- .../circuitbreaker/ToolCircuitSnapshot.java | 40 ++- .../InMemoryToolCircuitBreakerStoreTest.java | 136 ++++---- .../ToolCircuitBreakerMiddlewareTest.java | 162 +++++++++ .../ToolCircuitBreakerTest.java | 163 +++++++++ .../RedisToolCircuitBreakerStore.java | 181 +++++----- .../RedisToolCircuitBreakerStoreTest.java | 180 ++++++---- 11 files changed, 1091 insertions(+), 429 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java index bbdd259cf6..027898260f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStore.java @@ -17,7 +17,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicBoolean; /** * Default in-process {@link ToolCircuitBreakerStore}, backed by a {@link ConcurrentHashMap}. @@ -31,45 +31,33 @@ */ public class InMemoryToolCircuitBreakerStore implements ToolCircuitBreakerStore { - private final Map failureCounts = new ConcurrentHashMap<>(); - private final Map circuits = new ConcurrentHashMap<>(); + private final Map states = new ConcurrentHashMap<>(); @Override - public long recordFailure(String toolName) { - return failureCounts.computeIfAbsent(toolName, name -> new AtomicLong()).incrementAndGet(); - } - - @Override - public void resetFailures(String toolName) { - failureCounts.remove(toolName); - } - - @Override - public long failureCount(String toolName) { - AtomicLong counter = failureCounts.get(toolName); - return counter == null ? 0L : counter.get(); - } - - @Override - public long open(String toolName, long openedAtEpochMilli) { - // compute() holds the bin lock, so the generation increment and the timestamp stamp are - // applied as one atomic step even when several failing calls trip the same tool at once. - return circuits.compute( - toolName, - (name, current) -> - new ToolCircuitSnapshot( - (current == null ? 0L : current.generation()) + 1L, - openedAtEpochMilli)) - .generation(); + public ToolCircuitSnapshot snapshot(String toolName) { + return states.getOrDefault(toolName, ToolCircuitSnapshot.CLOSED); } @Override - public void close(String toolName) { - circuits.remove(toolName); + public boolean compareAndSet( + String toolName, ToolCircuitSnapshot expected, ToolCircuitSnapshot update) { + AtomicBoolean committed = new AtomicBoolean(); + states.compute( + toolName, + (name, current) -> { + ToolCircuitSnapshot actual = + current == null ? ToolCircuitSnapshot.CLOSED : current; + if (!actual.equals(expected)) { + return current; + } + committed.set(true); + return ToolCircuitSnapshot.CLOSED.equals(update) ? null : update; + }); + return committed.get(); } @Override - public ToolCircuitSnapshot snapshot(String toolName) { - return circuits.getOrDefault(toolName, ToolCircuitSnapshot.CLOSED); + public void reset(String toolName) { + states.remove(toolName); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java index ea7e63b429..e2721fd738 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreaker.java @@ -18,6 +18,8 @@ import java.time.Clock; import java.time.Duration; import java.util.Objects; +import java.util.Optional; +import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,15 +40,14 @@ * *

A classic breaker sits between caller and dependency and fails fast once open. Here there is a * better option: stop advertising the tool. A tool absent from the schema list is a tool the model - * cannot ask for, which removes the failure loop at the source rather than absorbing it. The model - * needs no prompt telling it to avoid the tool and cannot argue with the decision. + * cannot ask for, which removes the failure loop at the source rather than absorbing it. * *

State machine

* *
  *   CLOSED --failureThreshold consecutive failures--> OPEN
- *   OPEN --cooldown elapsed--> HALF_OPEN            (tool advertised again, as a probe)
- *   HALF_OPEN --probe succeeds--> CLOSED            (failure counter cleared)
+ *   OPEN --cooldown elapsed--> HALF_OPEN            (one turn may probe; see below)
+ *   HALF_OPEN --probe succeeds--> CLOSED            (failure streak cleared)
  *   HALF_OPEN --probe fails--> OPEN                 (next generation, longer cooldown)
  * 
* @@ -54,13 +55,28 @@ * snapshot and the current time, so a cooldown that elapsed while the agent was idle is recognised * on the next read. There is no timer to leak and no scheduler to configure. * - *

Threading

+ *

Concurrency

* - *

Safe for concurrent use as long as the {@link ToolCircuitBreakerStore} is. Read-modify-write - * sequences are not globally serialised: two tool calls failing at the same instant may both observe - * the threshold and call {@link ToolCircuitBreakerStore#open(String, long)}. That is harmless — - * opening is idempotent apart from advancing the generation, so the worst case is one extra backoff - * step. + *

A breaker is shared by every concurrent turn, session and (with a distributed store) replica + * that can reach the tool, so every transition is published as one compare-and-set against the exact + * snapshot the caller observed. A caller that loses the race re-reads and recomputes, which is what + * makes two guarantees hold rather than merely being documented: + * + *

+ * + *

The lease bounds the damage when a probe never reports an outcome (a turn that never calls the + * advertised tool, a lost execution, a crashed replica): once it expires another caller may claim the + * probe. Results carrying a superseded token are ignored, so a late reply cannot close or re-open a + * newer probe. */ public class ToolCircuitBreaker { @@ -95,9 +111,10 @@ public ToolCircuitBreaker(ToolCircuitBreakerConfig config, ToolCircuitBreakerSto * @param config supervision and backoff policy * @param store state persistence * @param clock time source; inject a fixed or adjustable clock in tests to step through - * cooldowns without sleeping. Cooldowns compare a stored wall-clock stamp against this - * clock, so a backwards jump (a manual correction, not NTP slew) can hold a tool back for - * up to the size of that jump. Use {@link #reset(String)} to clear it immediately. + * cooldowns without sleeping. Cooldowns and probe leases compare stored wall-clock stamps + * against this clock, so a backwards jump (a manual correction, not NTP slew) can hold a + * tool back for up to the size of that jump. Use {@link #reset(String)} to clear it + * immediately. */ public ToolCircuitBreaker( ToolCircuitBreakerConfig config, ToolCircuitBreakerStore store, Clock clock) { @@ -128,7 +145,8 @@ public boolean supervises(String toolName) { /** * Current state of a tool's circuit, derived from stored state and the current time. * - *

Unsupervised tools always report {@link ToolCircuitState#CLOSED}. + *

Unsupervised tools always report {@link ToolCircuitState#CLOSED}. This is a pure query: it + * never claims the recovery probe, so it is safe to call for logging or metrics. * * @param toolName tool to inspect * @return the current state, never null @@ -137,21 +155,19 @@ public ToolCircuitState state(String toolName) { if (!supervises(toolName)) { return ToolCircuitState.CLOSED; } - ToolCircuitSnapshot snapshot = store.snapshot(toolName); - if (!snapshot.isOpen()) { - return ToolCircuitState.CLOSED; - } - return hasCooldownElapsed(snapshot) ? ToolCircuitState.HALF_OPEN : ToolCircuitState.OPEN; + return stateOf(store.snapshot(toolName), clock.millis()); } /** - * Whether the tool should be kept out of the schema list offered to the model. + * Whether the tool is inside a cooldown and must be kept out of the schema list. * - *

True only in {@link ToolCircuitState#OPEN}: a half-open circuit deliberately advertises the - * tool again so the model's next call doubles as the recovery probe. + *

A half-open circuit reports false here because the tool may be advertised again — but only + * to the single turn that wins {@link #tryAcquireProbe(String)}. Callers deciding what to + * advertise should therefore branch on {@link #state(String)} and claim the probe, not rely on + * this method alone; it exists as a pure query for observability. * * @param toolName tool to test - * @return true when the tool must not be advertised + * @return true when the tool is in an unexpired cooldown */ public boolean isWithheld(String toolName) { return state(toolName) == ToolCircuitState.OPEN; @@ -180,36 +196,141 @@ public Duration cooldownFor(long generation) { } /** - * Record a successful tool execution. + * Try to claim the single recovery probe for a half-open circuit. * - *

In {@link ToolCircuitState#HALF_OPEN} this closes the circuit. In {@link - * ToolCircuitState#CLOSED} it clears any partial failure streak, which is what makes the - * threshold count consecutive failures and stops an occasional blip from ever tripping a - * healthy tool. + *

The winner may advertise the tool for one turn and must report the outcome through {@link + * #recordSuccess(String, String)} / {@link #recordFailure(String, String)} with the returned + * token, or hand the permit back with {@link #releaseProbe(String, String)} if the tool is never + * called. Doing neither is not fatal: the claim carries a lease and expires. * - * @param toolName tool that succeeded + * @param toolName tool to probe + * @return the probe token when this caller owns the probe; empty when the circuit is not + * half-open or another caller already holds it */ - public void recordSuccess(String toolName) { + public Optional tryAcquireProbe(String toolName) { if (!supervises(toolName)) { - return; + return Optional.empty(); + } + while (true) { + ToolCircuitSnapshot current = store.snapshot(toolName); + long now = clock.millis(); + if (stateOf(current, now) != ToolCircuitState.HALF_OPEN + || current.hasActiveProbe(now)) { + return Optional.empty(); + } + String token = UUID.randomUUID().toString().replace("-", ""); + ToolCircuitSnapshot update = + new ToolCircuitSnapshot( + current.failureCount(), + current.generation(), + current.openedAtEpochMilli(), + token, + now + config.getProbeTimeout().toMillis()); + if (store.compareAndSet(toolName, current, update)) { + logger.debug( + "Recovery probe claimed: tool={}, generation={}", + toolName, + current.generation()); + return Optional.of(token); + } } - ToolCircuitState current = state(toolName); - if (current == ToolCircuitState.OPEN) { - // Withheld yet still executed: tolerated, see recordFailure. + } + + /** + * Hand back a probe claim without reporting an outcome, so another turn can retry immediately. + * + *

Used when the advertised tool was never called, or when the call was refused or cancelled + * and therefore tested nothing. Does nothing if the claim has already expired or been replaced. + * + * @param toolName tool whose probe is being released + * @param probeToken token returned by {@link #tryAcquireProbe(String)} + */ + public void releaseProbe(String toolName, String probeToken) { + if (probeToken == null || !supervises(toolName)) { return; } - if (current == ToolCircuitState.HALF_OPEN) { - store.close(toolName); - store.resetFailures(toolName); - logger.info("Tool circuit closed after successful probe: tool={}", toolName); + while (true) { + ToolCircuitSnapshot current = store.snapshot(toolName); + if (!probeToken.equals(current.probeToken())) { + return; + } + ToolCircuitSnapshot update = + new ToolCircuitSnapshot( + current.failureCount(), + current.generation(), + current.openedAtEpochMilli(), + null, + 0L); + if (store.compareAndSet(toolName, current, update)) { + logger.debug("Recovery probe released unused: tool={}", toolName); + return; + } + } + } + + /** + * Record a successful tool execution that did not hold a recovery probe. + * + * @param toolName tool that succeeded + */ + public void recordSuccess(String toolName) { + recordSuccess(toolName, null); + } + + /** + * Record a successful tool execution. + * + *

A probe success closes the circuit and discards the accumulated backoff. A success while + * closed clears any partial failure streak, which is what makes the threshold count consecutive + * failures and stops an occasional blip from ever tripping a healthy tool. + * + * @param toolName tool that succeeded + * @param probeToken token from {@link #tryAcquireProbe(String)}, or null when the call was not a + * recovery probe + */ + public void recordSuccess(String toolName, String probeToken) { + if (!supervises(toolName)) { return; } - if (store.failureCount(toolName) > 0L) { - store.resetFailures(toolName); - logger.debug("Tool circuit failure streak cleared by success: tool={}", toolName); + while (true) { + ToolCircuitSnapshot current = store.snapshot(toolName); + long now = clock.millis(); + ToolCircuitState state = stateOf(current, now); + if (state == ToolCircuitState.OPEN) { + // Withheld yet still executed: tolerated, see recordFailure. + return; + } + if (state == ToolCircuitState.HALF_OPEN) { + if (current.hasActiveProbe(now) + && !Objects.equals(probeToken, current.probeToken())) { + // Someone else owns the live probe; a superseded result must not close it. + return; + } + if (store.compareAndSet(toolName, current, ToolCircuitSnapshot.CLOSED)) { + logger.info("Tool circuit closed after successful probe: tool={}", toolName); + return; + } + continue; + } + if (current.failureCount() == 0L) { + return; + } + if (store.compareAndSet(toolName, current, ToolCircuitSnapshot.CLOSED)) { + logger.debug("Tool circuit failure streak cleared by success: tool={}", toolName); + return; + } } } + /** + * Record a failed tool execution that did not hold a recovery probe. + * + * @param toolName tool that failed + */ + public void recordFailure(String toolName) { + recordFailure(toolName, null); + } + /** * Record a failed tool execution, tripping the circuit once the threshold is reached. * @@ -218,56 +339,68 @@ public void recordSuccess(String toolName) { * towards a trip. * * @param toolName tool that failed + * @param probeToken token from {@link #tryAcquireProbe(String)}, or null when the call was not a + * recovery probe */ - public void recordFailure(String toolName) { + public void recordFailure(String toolName, String probeToken) { if (!supervises(toolName)) { return; } - ToolCircuitState current = state(toolName); - if (current == ToolCircuitState.OPEN) { - // The tool was withheld, so the model should not have been able to call it. This is - // still reachable: the model may have chosen the call in the same turn the circuit - // tripped. Ignore it rather than counting a failure the policy never authorised. - logger.debug( - "Ignoring failure of withheld tool, likely decided before the circuit opened:" - + " tool={}", - toolName); - return; - } - if (current == ToolCircuitState.HALF_OPEN) { - long generation = store.open(toolName, clock.millis()); - logger.warn( - "Tool circuit re-opened after failed probe: tool={}, generation={}," - + " cooldown={}", - toolName, - generation, - cooldownFor(generation)); - return; - } - long failures = store.recordFailure(toolName); - if (failures < config.getFailureThreshold()) { - logger.debug( - "Tool failure recorded: tool={}, consecutiveFailures={}/{}", - toolName, - failures, - config.getFailureThreshold()); - return; + while (true) { + ToolCircuitSnapshot current = store.snapshot(toolName); + long now = clock.millis(); + ToolCircuitState state = stateOf(current, now); + if (state == ToolCircuitState.OPEN) { + // The tool was withheld, so the model should not have been able to call it. This is + // still reachable: the model may have chosen the call in the same turn the circuit + // tripped. Ignore it rather than counting a failure the policy never authorised. + logger.debug( + "Ignoring failure of withheld tool, likely decided before the circuit" + + " opened: tool={}", + toolName); + return; + } + if (state == ToolCircuitState.HALF_OPEN) { + if (current.hasActiveProbe(now) + && !Objects.equals(probeToken, current.probeToken())) { + // Someone else owns the live probe; a superseded result must not re-open it. + return; + } + if (reopen(toolName, current, now)) { + return; + } + continue; + } + long failures = current.failureCount() + 1L; + if (failures < config.getFailureThreshold()) { + ToolCircuitSnapshot update = + new ToolCircuitSnapshot(failures, current.generation(), 0L, null, 0L); + if (store.compareAndSet(toolName, current, update)) { + logger.debug( + "Tool failure recorded: tool={}, consecutiveFailures={}/{}", + toolName, + failures, + config.getFailureThreshold()); + return; + } + continue; + } + // Trip in the same compare-and-set that counted the final failure, so no caller can + // observe "threshold reached but not open" and act on it. + if (reopen(toolName, current, now)) { + logger.warn( + "Tool circuit opened: tool={}, consecutiveFailures={}. The tool will not be" + + " offered to the model until the cooldown elapses.", + toolName, + failures); + return; + } } - long generation = store.open(toolName, clock.millis()); - // Clear the streak on trip so the counter always means "failures seen while closed". - store.resetFailures(toolName); - logger.warn( - "Tool circuit opened: tool={}, consecutiveFailures={}, generation={}, cooldown={}." - + " The tool will not be offered to the model until the cooldown elapses.", - toolName, - failures, - generation, - cooldownFor(generation)); } /** - * Force a tool back to {@link ToolCircuitState#CLOSED}, discarding its failure streak and - * accumulated backoff. + * Force a tool back to {@link ToolCircuitState#CLOSED}, discarding its failure streak, + * accumulated backoff and any probe claim. * *

Intended for operators who know a dependency is healthy again and do not want to wait out * the cooldown. @@ -275,8 +408,7 @@ public void recordFailure(String toolName) { * @param toolName tool to reset */ public void reset(String toolName) { - store.close(toolName); - store.resetFailures(toolName); + store.reset(toolName); logger.info("Tool circuit manually reset: tool={}", toolName); } @@ -289,8 +421,25 @@ public ToolCircuitBreakerConfig getConfig() { return config; } - private boolean hasCooldownElapsed(ToolCircuitSnapshot snapshot) { + private boolean reopen(String toolName, ToolCircuitSnapshot current, long now) { + long generation = current.generation() + 1L; + if (!store.compareAndSet(toolName, current, new ToolCircuitSnapshot(generation, now))) { + return false; + } + logger.warn( + "Tool circuit open: tool={}, generation={}, cooldown={}", + toolName, + generation, + cooldownFor(generation)); + return true; + } + + private ToolCircuitState stateOf(ToolCircuitSnapshot snapshot, long now) { + if (!snapshot.isOpen()) { + return ToolCircuitState.CLOSED; + } Duration cooldown = cooldownFor(snapshot.generation()); - return clock.millis() >= snapshot.openedAtEpochMilli() + cooldown.toMillis(); + boolean elapsed = now >= snapshot.openedAtEpochMilli() + cooldown.toMillis(); + return elapsed ? ToolCircuitState.HALF_OPEN : ToolCircuitState.OPEN; } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java index f847a8e477..42e5eb095d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerConfig.java @@ -54,6 +54,7 @@ public final class ToolCircuitBreakerConfig { private final Duration initialCooldown; private final double backoffMultiplier; private final Duration maxCooldown; + private final Duration probeTimeout; private ToolCircuitBreakerConfig(Builder builder) { this.enabled = builder.enabled; @@ -64,6 +65,7 @@ private ToolCircuitBreakerConfig(Builder builder) { this.initialCooldown = builder.initialCooldown; this.backoffMultiplier = builder.backoffMultiplier; this.maxCooldown = builder.maxCooldown; + this.probeTimeout = builder.probeTimeout; } /** @@ -148,6 +150,19 @@ public Duration getMaxCooldown() { return maxCooldown; } + /** + * Maximum time one half-open recovery probe owns the permit. + * + *

Configure this no shorter than the supervised tool's execution timeout. Expiry prevents a + * reasoning turn that never calls the advertised tool, or a lost execution, from withholding + * recovery forever. + * + * @return positive probe timeout + */ + public Duration getProbeTimeout() { + return probeTimeout; + } + /** Builder for {@link ToolCircuitBreakerConfig}. */ public static final class Builder { @@ -159,6 +174,7 @@ public static final class Builder { private Duration initialCooldown = Duration.ofSeconds(60); private double backoffMultiplier = 2.0; private Duration maxCooldown = Duration.ofSeconds(600); + private Duration probeTimeout = Duration.ofMinutes(5); private Builder() {} @@ -286,6 +302,17 @@ public Builder maxCooldown(Duration maxCooldown) { return this; } + /** + * Set how long a claimed half-open probe remains exclusive. + * + * @param probeTimeout positive duration, normally no shorter than tool execution timeout + * @return this builder + */ + public Builder probeTimeout(Duration probeTimeout) { + this.probeTimeout = probeTimeout; + return this; + } + /** * Validate and build the configuration. * @@ -308,6 +335,10 @@ public ToolCircuitBreakerConfig build() { throw new IllegalArgumentException( "maxCooldown must be positive, got " + maxCooldown); } + if (probeTimeout == null || probeTimeout.isNegative() || probeTimeout.isZero()) { + throw new IllegalArgumentException( + "probeTimeout must be positive, got " + probeTimeout); + } if (backoffMultiplier < 1.0 || !Double.isFinite(backoffMultiplier)) { throw new IllegalArgumentException( "backoffMultiplier must be a finite value of at least 1.0, got " diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java index cf711a7528..eafc28a8f3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddleware.java @@ -18,6 +18,7 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ToolCallStartEvent; import io.agentscope.core.event.ToolResultEndEvent; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.middleware.ActingInput; @@ -25,8 +26,12 @@ import io.agentscope.core.middleware.ReasoningInput; import io.agentscope.core.model.ToolSchema; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,10 +44,11 @@ *

It occupies two interception points, which together close the state machine: * *

* *

Filtering happens per turn on a copy of the schema list. Nothing registered on the {@link @@ -51,6 +57,20 @@ * authoritative. Recovery needs no repair step for the same reason: once the breaker stops * withholding a tool, the unfiltered list is already correct. * + *

Recovery probes

+ * + *

When a cooldown elapses the tool is advertised again, but only to the one turn that wins the + * probe claim; concurrent turns keep withholding it. The claim is carried on the per-call {@link + * RuntimeContext} and bound to the tool-call id the model chooses, so the matching result completes + * exactly that probe. Three paths hand the claim back early rather than waiting for its lease to + * expire: the model never calling the advertised tool, a call refused by permission rules, and a + * cancelled call — none of them tested the dependency. A suspended call keeps the claim, because its + * outcome is still pending. + * + *

A null {@code RuntimeContext} (possible when the middleware is driven directly rather than by an + * agent) disables the binding: outcomes are then recorded without a token, and any probe claim is + * released when the reasoning stream terminates. + * *

Usage * *

{@code
@@ -77,6 +97,7 @@ public class ToolCircuitBreakerMiddleware implements MiddlewareBase {
             LoggerFactory.getLogger(ToolCircuitBreakerMiddleware.class);
 
     private final ToolCircuitBreaker breaker;
+    private final String probeAttributeKey;
 
     /**
      * Wrap a breaker as middleware.
@@ -85,6 +106,12 @@ public class ToolCircuitBreakerMiddleware implements MiddlewareBase {
      */
     public ToolCircuitBreakerMiddleware(ToolCircuitBreaker breaker) {
         this.breaker = Objects.requireNonNull(breaker, "breaker must not be null");
+        // Scope the context attribute to this instance so two breakers on one agent cannot claim
+        // each other's probe bindings.
+        this.probeAttributeKey =
+                ToolCircuitBreakerMiddleware.class.getName()
+                        + ".probes#"
+                        + Integer.toHexString(System.identityHashCode(breaker));
     }
 
     /**
@@ -117,26 +144,57 @@ public Flux onReasoning(
         }
         List visible = new ArrayList<>(tools.size());
         List withheld = null;
+        Map claimed = null;
         for (ToolSchema tool : tools) {
-            if (tool != null && breaker.isWithheld(tool.getName())) {
-                if (withheld == null) {
-                    withheld = new ArrayList<>(2);
+            String name = tool == null ? null : tool.getName();
+            ToolCircuitState state = name == null ? ToolCircuitState.CLOSED : breaker.state(name);
+            if (state == ToolCircuitState.CLOSED) {
+                visible.add(tool);
+                continue;
+            }
+            Optional probe =
+                    state == ToolCircuitState.HALF_OPEN
+                            ? breaker.tryAcquireProbe(name)
+                            : Optional.empty();
+            if (probe.isPresent()) {
+                if (claimed == null) {
+                    claimed = new LinkedHashMap<>(2);
                 }
-                withheld.add(tool.getName());
+                claimed.put(name, probe.get());
+                visible.add(tool);
                 continue;
             }
-            visible.add(tool);
+            if (withheld == null) {
+                withheld = new ArrayList<>(2);
+            }
+            withheld.add(name);
         }
-        if (withheld == null) {
+        if (withheld == null && claimed == null) {
             return next.apply(input);
         }
-        logger.debug(
-                "Withholding tripped tools from this reasoning turn: {} of {} tools hidden,"
-                        + " hidden={}",
-                withheld.size(),
-                tools.size(),
-                withheld);
-        return next.apply(new ReasoningInput(input.messages(), visible, input.options()));
+        if (withheld != null) {
+            logger.debug(
+                    "Withholding tripped tools from this reasoning turn: {} of {} tools hidden,"
+                            + " hidden={}",
+                    withheld.size(),
+                    tools.size(),
+                    withheld);
+        }
+        ReasoningInput forwarded =
+                withheld == null
+                        ? input
+                        : new ReasoningInput(input.messages(), visible, input.options());
+        if (claimed == null) {
+            return next.apply(forwarded);
+        }
+        Map probes = claimed;
+        Map selected = new ConcurrentHashMap<>();
+        // Resolve the binding map up front: RuntimeContext offers no atomic putIfAbsent, and here
+        // we are still single-threaded, before the returned stream is subscribed.
+        Map bindings = ctx == null ? null : probeBindings(ctx);
+        return next.apply(forwarded)
+                .doOnNext(event -> bindProbe(bindings, event, probes, selected))
+                .doFinally(signal -> releaseUnusedProbes(probes, selected));
     }
 
     @Override
@@ -145,18 +203,57 @@ public Flux onActing(
             RuntimeContext ctx,
             ActingInput input,
             Function> next) {
-        return next.apply(input).doOnNext(this::recordOutcome);
+        return next.apply(input).doOnNext(event -> recordOutcome(ctx, event));
+    }
+
+    /**
+     * Remember which tool call carries a claimed probe, so the matching result can complete it.
+     *
+     * 

Marking the tool as selected also stops {@link #releaseUnusedProbes} from handing the claim + * back when the reasoning stream ends: the call is in flight and its outcome still pending. + */ + private void bindProbe( + Map bindings, + AgentEvent event, + Map probes, + Map selected) { + if (!(event instanceof ToolCallStartEvent toolCall)) { + return; + } + String name = toolCall.getToolCallName(); + String token = name == null ? null : probes.get(name); + if (token == null) { + return; + } + selected.put(name, Boolean.TRUE); + String callId = toolCall.getToolCallId(); + if (bindings == null || callId == null) { + // Without a context there is nowhere to bind the token; the lease bounds the fallout. + return; + } + bindings.put(callId, new ProbeBinding(name, token)); + } + + /** Hand back claims for tools the model never called, so the next turn can retry at once. */ + private void releaseUnusedProbes(Map probes, Map selected) { + probes.forEach( + (name, token) -> { + if (!selected.containsKey(name)) { + breaker.releaseProbe(name, token); + } + }); } /** * Feed one tool result into the breaker. * *

Only {@link ToolResultState#ERROR} counts as a failure. {@code DENIED} is a policy refusal - * and {@code INTERRUPTED} a cancellation — neither is evidence about the dependency, and - * counting them would let a user who declines a confirmation prompt trip the circuit. {@code - * RUNNING} marks a suspended call whose outcome is not known yet. + * and {@code INTERRUPTED} a cancellation — neither is evidence about the dependency, and counting + * them would let a user who declines a confirmation prompt trip the circuit; both hand a probe + * claim back instead. {@code RUNNING} marks a suspended call whose outcome is not known yet, so + * its claim is left in place to be completed later or to expire. */ - private void recordOutcome(AgentEvent event) { + private void recordOutcome(RuntimeContext ctx, AgentEvent event) { if (!(event instanceof ToolResultEndEvent result)) { return; } @@ -165,10 +262,42 @@ private void recordOutcome(AgentEvent event) { if (toolName == null || state == null) { return; } - if (state == ToolResultState.ERROR) { - breaker.recordFailure(toolName); - } else if (state == ToolResultState.SUCCESS) { - breaker.recordSuccess(toolName); + if (state == ToolResultState.RUNNING) { + return; + } + String token = consumeProbeToken(ctx, result.getToolCallId(), toolName); + switch (state) { + case ERROR -> breaker.recordFailure(toolName, token); + case SUCCESS -> breaker.recordSuccess(toolName, token); + case DENIED, INTERRUPTED -> breaker.releaseProbe(toolName, token); + default -> { + // RUNNING handled above; no other states exist. + } + } + } + + private String consumeProbeToken(RuntimeContext ctx, String callId, String toolName) { + if (ctx == null || callId == null) { + return null; + } + Map bindings = ctx.get(probeAttributeKey); + if (bindings == null) { + return null; } + ProbeBinding binding = bindings.remove(callId); + return binding != null && toolName.equals(binding.toolName()) ? binding.token() : null; } + + @SuppressWarnings("unchecked") + private Map probeBindings(RuntimeContext ctx) { + Map bindings = ctx.get(probeAttributeKey); + if (bindings == null) { + bindings = new ConcurrentHashMap<>(); + ctx.put(probeAttributeKey, bindings); + } + return bindings; + } + + /** A recovery-probe claim awaiting the result of one tool call. */ + private record ProbeBinding(String toolName, String token) {} } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java index 58d6aacbc0..01e9360ab8 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerStore.java @@ -19,8 +19,9 @@ * Persistence contract for tool circuit-breaker state. * *

Implementations are pure state holders: they must not apply the backoff policy, decide when a - * circuit trips, or consult a clock. All policy lives in {@link ToolCircuitBreaker}, which keeps - * this SPI stable when the policy evolves and makes the policy unit-testable without a store. + * circuit trips, or consult a clock. All policy lives in {@link ToolCircuitBreaker}. The breaker + * computes immutable snapshots and commits them with {@link #compareAndSet}; this makes a complete + * state transition atomic without moving policy into the persistence layer. * *

{@link InMemoryToolCircuitBreakerStore} is the default and is sufficient for a single * process. A distributed implementation (for example the Redis-backed store in @@ -29,66 +30,38 @@ * *

Threading

* - *

Implementations must be safe for concurrent use from multiple threads. {@link - * #recordFailure(String)} and {@link #open(String, long)} must be atomic, since concurrent tool - * calls in one ReAct turn race on both. + *

Implementations must be safe for concurrent use from multiple threads and processes. {@link + * #compareAndSet(String, ToolCircuitSnapshot, ToolCircuitSnapshot)} must compare and replace the + * complete snapshot atomically. */ public interface ToolCircuitBreakerStore { /** - * Atomically increment the consecutive-failure counter and return the new value. - * - * @param toolName tool being counted - * @return the counter value after incrementing, starting at 1 - */ - long recordFailure(String toolName); - - /** - * Clear the consecutive-failure counter. - * - *

Called whenever a tool succeeds, which is what makes the threshold count - * consecutive failures rather than lifetime failures. - * - * @param toolName tool to reset - */ - void resetFailures(String toolName); - - /** - * Read the consecutive-failure counter without modifying it. + * Read all state for one tool in a single round trip. * * @param toolName tool to read - * @return current count, or {@code 0} when nothing is recorded - */ - long failureCount(String toolName); - - /** - * Atomically move the circuit to OPEN: increment the backoff generation and stamp the open - * instant, returning the new generation. - * - *

The caller supplies the timestamp so that it shares a clock with the cooldown comparison - * in {@link ToolCircuitBreaker}; a store must never substitute its own clock. - * - * @param toolName tool to trip - * @param openedAtEpochMilli instant the circuit opened, in epoch milliseconds - * @return the backoff generation after incrementing, starting at 1 + * @return current snapshot, never null; {@link ToolCircuitSnapshot#CLOSED} when no state exists */ - long open(String toolName, long openedAtEpochMilli); + ToolCircuitSnapshot snapshot(String toolName); /** - * Reset the circuit to CLOSED, discarding both the open timestamp and the backoff generation. + * Atomically replace the current snapshot if it still equals {@code expected}. * - *

Dropping the generation means a tool that recovers starts its next incident from the - * initial cooldown instead of inheriting an old, long backoff. + *

{@link ToolCircuitSnapshot#CLOSED} is the logical value of a missing entry. Implementations + * should remove storage when {@code update} is CLOSED so healthy tools do not accumulate state. * - * @param toolName tool to close + * @param toolName tool to update + * @param expected snapshot the caller observed + * @param update complete replacement snapshot + * @return true when the replacement was committed; false when another caller changed the state */ - void close(String toolName); + boolean compareAndSet( + String toolName, ToolCircuitSnapshot expected, ToolCircuitSnapshot update); /** - * Read the trip state in a single round trip. + * Unconditionally discard all state for one tool. * - * @param toolName tool to read - * @return current snapshot, never null; {@link ToolCircuitSnapshot#CLOSED} when no state exists + * @param toolName tool to reset */ - ToolCircuitSnapshot snapshot(String toolName); + void reset(String toolName); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java index ddd27e8e9e..b2edd810d2 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitSnapshot.java @@ -16,22 +16,42 @@ package io.agentscope.core.tool.circuitbreaker; /** - * Immutable point-in-time view of a tool's trip state, read in a single store round trip. + * Immutable point-in-time view of all state for one tool, read in a single store round trip. * *

The cooldown duration is deliberately not part of the snapshot: it is derived from * {@code generation} by {@link ToolCircuitBreaker#cooldownFor(long)}, so changing the backoff * policy takes effect immediately and never has to be migrated in the store. * + * @param failureCount consecutive failures recorded while the circuit is closed * @param generation number of times the circuit has tripped, driving exponential backoff; * {@code 0} means it has never tripped * @param openedAtEpochMilli wall-clock instant the circuit was last opened, or {@code 0} when * the circuit is not open — a positive value is the sole marker of the OPEN state, so no * separate boolean has to be kept consistent with it + * @param probeToken opaque owner token for the current half-open probe, or {@code null} when no + * probe is claimed + * @param probeLeaseUntilEpochMilli wall-clock instant at which the current probe claim expires, or + * {@code 0} when no probe is claimed */ -public record ToolCircuitSnapshot(long generation, long openedAtEpochMilli) { +public record ToolCircuitSnapshot( + long failureCount, + long generation, + long openedAtEpochMilli, + String probeToken, + long probeLeaseUntilEpochMilli) { - /** Snapshot of a tool that has never tripped. */ - public static final ToolCircuitSnapshot CLOSED = new ToolCircuitSnapshot(0L, 0L); + /** Snapshot of a healthy tool with no partial failure streak. */ + public static final ToolCircuitSnapshot CLOSED = new ToolCircuitSnapshot(0L, 0L, 0L, null, 0L); + + /** + * Create an open snapshot without a failure streak or claimed probe. + * + * @param generation trip generation + * @param openedAtEpochMilli instant the circuit opened + */ + public ToolCircuitSnapshot(long generation, long openedAtEpochMilli) { + this(0L, generation, openedAtEpochMilli, null, 0L); + } /** * Whether the circuit is currently open, ignoring whether its cooldown has elapsed. @@ -45,4 +65,16 @@ public record ToolCircuitSnapshot(long generation, long openedAtEpochMilli) { public boolean isOpen() { return openedAtEpochMilli > 0L; } + + /** + * Whether a recovery probe currently owns an unexpired lease. + * + * @param nowEpochMilli current wall-clock instant + * @return true when another caller must not acquire the probe + */ + public boolean hasActiveProbe(long nowEpochMilli) { + return probeToken != null + && !probeToken.isEmpty() + && probeLeaseUntilEpochMilli > nowEpochMilli; + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java index 9a5a286660..c1f44ba3a3 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/InMemoryToolCircuitBreakerStoreTest.java @@ -22,10 +22,10 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; -/** Contract of {@link InMemoryToolCircuitBreakerStore}, including its atomicity guarantees. */ +/** Compare-and-set contract of {@link InMemoryToolCircuitBreakerStore}. */ class InMemoryToolCircuitBreakerStoreTest { private static final String TOOL = "query_weather"; @@ -34,89 +34,98 @@ class InMemoryToolCircuitBreakerStoreTest { private final InMemoryToolCircuitBreakerStore store = new InMemoryToolCircuitBreakerStore(); @Test - void unknownToolReadsAsClosedWithNoFailures() { - assertEquals(0L, store.failureCount(TOOL)); + void unknownToolReadsAsClosed() { assertEquals(ToolCircuitSnapshot.CLOSED, store.snapshot(TOOL)); assertFalse(store.snapshot(TOOL).isOpen()); } @Test - void failureCounterStartsAtOneAndIncrements() { - assertEquals(1L, store.recordFailure(TOOL)); - assertEquals(2L, store.recordFailure(TOOL)); - assertEquals(2L, store.failureCount(TOOL)); - } + void compareAndSetCommitsWhenTheObservedValueStillHolds() { + ToolCircuitSnapshot update = new ToolCircuitSnapshot(2L, 0L, 0L, null, 0L); - @Test - void resetClearsOnlyTheNamedToolsCounter() { - store.recordFailure(TOOL); - store.recordFailure(OTHER_TOOL); + assertTrue(store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, update)); - store.resetFailures(TOOL); + assertEquals(update, store.snapshot(TOOL)); + } - assertEquals(0L, store.failureCount(TOOL)); - assertEquals(1L, store.failureCount(OTHER_TOOL)); + @Test + void compareAndSetRejectsAStaleExpectation() { + ToolCircuitSnapshot first = new ToolCircuitSnapshot(1L, 0L, 0L, null, 0L); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, first); + + // A caller that still believes the tool is untouched must not be able to commit. + boolean committed = + store.compareAndSet( + TOOL, + ToolCircuitSnapshot.CLOSED, + new ToolCircuitSnapshot(9L, 0L, 0L, null, 0L)); + + assertFalse(committed); + assertEquals(first, store.snapshot(TOOL)); } @Test - void openStampsTimestampAndAdvancesGeneration() { - assertEquals(1L, store.open(TOOL, 1_000L)); + void updatingToClosedRemovesTheEntry() { + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(3L, 1_000L)); - ToolCircuitSnapshot first = store.snapshot(TOOL); - assertTrue(first.isOpen()); - assertEquals(1L, first.generation()); - assertEquals(1_000L, first.openedAtEpochMilli()); + assertTrue(store.compareAndSet(TOOL, store.snapshot(TOOL), ToolCircuitSnapshot.CLOSED)); - assertEquals(2L, store.open(TOOL, 5_000L)); + assertEquals(ToolCircuitSnapshot.CLOSED, store.snapshot(TOOL)); + // An absent entry must still be a valid expectation, proving it was removed rather than + // stored as an explicit zero value. + assertTrue( + store.compareAndSet( + TOOL, + ToolCircuitSnapshot.CLOSED, + new ToolCircuitSnapshot(1L, 0L, 0L, null, 0L))); + } - ToolCircuitSnapshot second = store.snapshot(TOOL); - assertEquals(2L, second.generation()); - assertEquals(5_000L, second.openedAtEpochMilli()); + @Test + void probeClaimIsPartOfTheComparedValue() { + ToolCircuitSnapshot open = new ToolCircuitSnapshot(4L, 1_000L); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, open); + ToolCircuitSnapshot claimed = new ToolCircuitSnapshot(0L, 4L, 1_000L, "token-a", 9_000L); + assertTrue(store.compareAndSet(TOOL, open, claimed)); + + // Another caller holding the pre-claim value must lose, which is what makes the single + // recovery probe exclusive. + assertFalse( + store.compareAndSet( + TOOL, open, new ToolCircuitSnapshot(0L, 4L, 1_000L, "token-b", 9_000L))); + assertEquals("token-a", store.snapshot(TOOL).probeToken()); } @Test - void closeDiscardsGenerationSoBackoffRestarts() { - store.open(TOOL, 1_000L); - store.open(TOOL, 2_000L); + void resetDiscardsStateUnconditionally() { + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(2L, 1_000L)); - store.close(TOOL); + store.reset(TOOL); - assertFalse(store.snapshot(TOOL).isOpen()); - assertEquals(0L, store.snapshot(TOOL).generation()); - assertEquals(1L, store.open(TOOL, 3_000L)); + assertEquals(ToolCircuitSnapshot.CLOSED, store.snapshot(TOOL)); } @Test void toolsDoNotShareState() { - store.open(TOOL, 1_000L); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(1L, 1_000L)); assertTrue(store.snapshot(TOOL).isOpen()); assertFalse(store.snapshot(OTHER_TOOL).isOpen()); } @Test - void concurrentFailureCountsAreNotLost() throws Exception { - int threads = 8; - int perThread = 500; - - runConcurrently(threads, perThread, () -> store.recordFailure(TOOL)); - - assertEquals((long) threads * perThread, store.failureCount(TOOL)); - } - - @Test - void concurrentOpensYieldContiguousGenerations() throws Exception { - int threads = 8; - int perThread = 200; - AtomicLong maxGeneration = new AtomicLong(); + void exactlyOneOfManyConcurrentCompareAndSetsWins() throws Exception { + int threads = 16; + ToolCircuitSnapshot expected = ToolCircuitSnapshot.CLOSED; + AtomicInteger winners = new AtomicInteger(); ExecutorService pool = Executors.newFixedThreadPool(threads); try { - for (int t = 0; t < threads; t++) { + for (int i = 0; i < threads; i++) { + long generation = i + 1L; pool.submit( () -> { - for (int i = 0; i < perThread; i++) { - long generation = store.open(TOOL, 1_000L + i); - maxGeneration.accumulateAndGet(generation, Math::max); + if (store.compareAndSet( + TOOL, expected, new ToolCircuitSnapshot(generation, 1_000L))) { + winners.incrementAndGet(); } }); } @@ -126,27 +135,6 @@ void concurrentOpensYieldContiguousGenerations() throws Exception { pool.shutdownNow(); } - // Every open must observe a distinct, gap-free generation, so the highest value seen equals - // the number of opens performed. - assertEquals((long) threads * perThread, maxGeneration.get()); - assertEquals((long) threads * perThread, store.snapshot(TOOL).generation()); - } - - private void runConcurrently(int threads, int perThread, Runnable task) throws Exception { - ExecutorService pool = Executors.newFixedThreadPool(threads); - try { - for (int t = 0; t < threads; t++) { - pool.submit( - () -> { - for (int i = 0; i < perThread; i++) { - task.run(); - } - }); - } - pool.shutdown(); - assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS)); - } finally { - pool.shutdownNow(); - } + assertEquals(1, winners.get()); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java index ea89eb0415..e082386867 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerMiddlewareTest.java @@ -20,7 +20,9 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ToolCallStartEvent; import io.agentscope.core.event.ToolResultEndEvent; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; @@ -34,6 +36,12 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -134,6 +142,63 @@ void toolIsOfferedAgainOnceTheCooldownElapses() { assertEquals(List.of(WEATHER, DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); } + @Test + void onlyOneConcurrentReasoningTurnReceivesTheHalfOpenProbe() throws Exception { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + clock.advance(Duration.ofSeconds(60)); + + int turns = 8; + ExecutorService pool = Executors.newFixedThreadPool(turns); + CountDownLatch ready = new CountDownLatch(turns); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch observed = new CountDownLatch(turns); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger offers = new AtomicInteger(); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < turns; i++) { + futures.add( + pool.submit( + () -> { + ready.countDown(); + await(start); + middleware + .onReasoning( + null, + RuntimeContext.empty(), + new ReasoningInput( + List.of(), + List.of(schema(WEATHER)), + null), + received -> { + if (!received.tools().isEmpty()) { + offers.incrementAndGet(); + } + observed.countDown(); + await(release); + return Flux.empty(); + }) + .collectList() + .block(); + })); + } + + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(observed.await(5, TimeUnit.SECONDS)); + release.countDown(); + for (Future future : futures) { + future.get(5, TimeUnit.SECONDS); + } + + assertEquals(1, offers.get()); + } finally { + release.countDown(); + pool.shutdownNow(); + } + } + @Test void unsupervisedToolIsNeverWithheldHoweverOftenItFails() { ToolCircuitBreakerMiddleware middleware = middleware(1); @@ -231,6 +296,92 @@ void probeFailureAfterCooldownExtendsTheWithholdingPeriod() { assertEquals(List.of(WEATHER, DATABASE), offeredToolNames(middleware, WEATHER, DATABASE)); } + @Test + void aTurnThatNeverCallsTheToolHandsTheProbeBack() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + clock.advance(Duration.ofSeconds(60)); + + // The model is offered the tool but selects nothing, so the reasoning stream ends without a + // ToolCallStartEvent. + assertEquals(List.of(WEATHER), offeredToolNames(middleware, WEATHER)); + + // Without the release the claim would still be live and this turn would be withheld. + assertEquals(List.of(WEATHER), offeredToolNames(middleware, WEATHER)); + } + + @Test + void aSelectedToolKeepsItsProbeAndItsResultClosesTheCircuit() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + clock.advance(Duration.ofSeconds(60)); + RuntimeContext ctx = RuntimeContext.empty(); + + // Reasoning advertises the tool and the model selects it. + ToolCallStartEvent selection = new ToolCallStartEvent(REPLY_ID, "call-1", WEATHER); + middleware + .onReasoning( + null, + ctx, + new ReasoningInput(List.of(), List.of(schema(WEATHER)), null), + received -> Flux.just(selection)) + .collectList() + .block(); + + // The probe is bound to that tool-call id, so the matching result completes it. + middleware + .onActing( + null, + ctx, + new ActingInput(List.of()), + ignored -> + Flux.just( + new ToolResultEndEvent( + REPLY_ID, + "call-1", + WEATHER, + ToolResultState.SUCCESS))) + .collectList() + .block(); + + assertEquals(ToolCircuitState.CLOSED, middleware.getBreaker().state(WEATHER)); + } + + @Test + void aDeniedProbeIsHandedBackInsteadOfCountingAsAFailure() { + ToolCircuitBreakerMiddleware middleware = middleware(1); + failTool(middleware, WEATHER); + clock.advance(Duration.ofSeconds(60)); + RuntimeContext ctx = RuntimeContext.empty(); + middleware + .onReasoning( + null, + ctx, + new ReasoningInput(List.of(), List.of(schema(WEATHER)), null), + received -> Flux.just(new ToolCallStartEvent(REPLY_ID, "call-1", WEATHER))) + .collectList() + .block(); + + middleware + .onActing( + null, + ctx, + new ActingInput(List.of()), + ignored -> + Flux.just( + new ToolResultEndEvent( + REPLY_ID, + "call-1", + WEATHER, + ToolResultState.DENIED))) + .collectList() + .block(); + + // The dependency was never tested: still half-open, and probing is possible again. + assertEquals(ToolCircuitState.HALF_OPEN, middleware.getBreaker().state(WEATHER)); + assertEquals(List.of(WEATHER), offeredToolNames(middleware, WEATHER)); + } + // ==================== Helpers ==================== private ToolCircuitBreakerMiddleware middleware(int threshold) { @@ -294,4 +445,15 @@ private static Msg userMsg(String text) { .content(TextBlock.builder().text(text).build()) .build(); } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for concurrent test phase"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while coordinating concurrent test", e); + } + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java index 37c1ae712b..6700adf03f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/circuitbreaker/ToolCircuitBreakerTest.java @@ -17,11 +17,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; /** State-machine, backoff and supervision-scope behaviour of {@link ToolCircuitBreaker}. */ @@ -139,6 +145,37 @@ void successBreaksTheStreakSoScatteredFailuresNeverTrip() { assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); } + @Test + void successDuringThresholdFailurePreventsAStaleOpen() throws Exception { + ThresholdReturnBarrierStore store = new ThresholdReturnBarrierStore(); + ToolCircuitBreaker breaker = + new ToolCircuitBreaker( + ToolCircuitBreakerConfig.builder() + .monitorTools(WEATHER) + .failureThreshold(3) + .build(), + store, + clock); + breaker.recordFailure(WEATHER); + breaker.recordFailure(WEATHER); + + ExecutorService pool = Executors.newSingleThreadExecutor(); + Future thresholdFailure = pool.submit(() -> breaker.recordFailure(WEATHER)); + try { + assertTrue(store.thresholdRecorded.await(5, TimeUnit.SECONDS)); + + breaker.recordSuccess(WEATHER); + store.allowThresholdResult.countDown(); + thresholdFailure.get(5, TimeUnit.SECONDS); + + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + assertEquals(1L, store.snapshot(WEATHER).failureCount()); + } finally { + store.allowThresholdResult.countDown(); + pool.shutdownNow(); + } + } + // ==================== OPEN -> HALF_OPEN -> CLOSED ==================== @Test @@ -313,6 +350,82 @@ void configRejectsInvertedCooldownBounds() { .build()); } + @Test + void onlyOneCallerHoldsTheRecoveryProbe() { + ToolCircuitBreaker breaker = probeBreaker(); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + + String held = breaker.tryAcquireProbe(WEATHER).orElseThrow(); + + assertTrue(breaker.tryAcquireProbe(WEATHER).isEmpty()); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + assertFalse(held.isEmpty()); + } + + @Test + void probeIsNotOfferedWhileTheCircuitIsStillCoolingDown() { + ToolCircuitBreaker breaker = probeBreaker(); + breaker.recordFailure(WEATHER); + + clock.advance(Duration.ofSeconds(59)); + + assertTrue(breaker.tryAcquireProbe(WEATHER).isEmpty()); + } + + @Test + void expiredProbeIsReclaimableAndTheSupersededTokenCannotCompleteIt() { + ToolCircuitBreaker breaker = probeBreaker(); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + String stale = breaker.tryAcquireProbe(WEATHER).orElseThrow(); + + // The holder never reports an outcome; once the lease expires another turn may retry. + clock.advance(Duration.ofSeconds(30)); + String live = breaker.tryAcquireProbe(WEATHER).orElseThrow(); + assertNotEquals(stale, live); + + // A late result from the superseded probe must neither close nor re-open the new one. + breaker.recordSuccess(WEATHER, stale); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + breaker.recordFailure(WEATHER, stale); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + + breaker.recordSuccess(WEATHER, live); + assertEquals(ToolCircuitState.CLOSED, breaker.state(WEATHER)); + } + + @Test + void releasingAProbeLetsTheNextCallerRetryImmediately() { + ToolCircuitBreaker breaker = probeBreaker(); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + String held = breaker.tryAcquireProbe(WEATHER).orElseThrow(); + assertTrue(breaker.tryAcquireProbe(WEATHER).isEmpty()); + + breaker.releaseProbe(WEATHER, held); + + assertTrue(breaker.tryAcquireProbe(WEATHER).isPresent()); + } + + @Test + void failingProbeReopensWithTheNextGenerationAndDropsTheClaim() { + ToolCircuitBreaker breaker = probeBreaker(); + breaker.recordFailure(WEATHER); + clock.advance(Duration.ofSeconds(60)); + String held = breaker.tryAcquireProbe(WEATHER).orElseThrow(); + + breaker.recordFailure(WEATHER, held); + + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + // Second generation waits 120s, so the original 60s is no longer enough. + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.OPEN, breaker.state(WEATHER)); + clock.advance(Duration.ofSeconds(60)); + assertEquals(ToolCircuitState.HALF_OPEN, breaker.state(WEATHER)); + assertTrue(breaker.tryAcquireProbe(WEATHER).isPresent()); + } + // ==================== Helpers ==================== private ToolCircuitBreaker weatherBreaker(int threshold) { @@ -325,7 +438,57 @@ private ToolCircuitBreaker weatherBreaker(int threshold) { .maxCooldown(Duration.ofSeconds(600))); } + private ToolCircuitBreaker probeBreaker() { + return breaker( + ToolCircuitBreakerConfig.builder() + .monitorTools(WEATHER) + .failureThreshold(1) + .initialCooldown(Duration.ofSeconds(60)) + .backoffMultiplier(2.0) + .maxCooldown(Duration.ofSeconds(600)) + .probeTimeout(Duration.ofSeconds(30))); + } + private ToolCircuitBreaker breaker(ToolCircuitBreakerConfig.Builder config) { return new ToolCircuitBreaker(config.build(), new InMemoryToolCircuitBreakerStore(), clock); } + + /** + * Delegating store that suspends the compare-and-set which would trip the circuit, so a test can + * interleave a success into the exact window the reviewer described. + */ + private static final class ThresholdReturnBarrierStore implements ToolCircuitBreakerStore { + + private final InMemoryToolCircuitBreakerStore delegate = + new InMemoryToolCircuitBreakerStore(); + private final CountDownLatch thresholdRecorded = new CountDownLatch(1); + private final CountDownLatch allowThresholdResult = new CountDownLatch(1); + + @Override + public ToolCircuitSnapshot snapshot(String toolName) { + return delegate.snapshot(toolName); + } + + @Override + public boolean compareAndSet( + String toolName, ToolCircuitSnapshot expected, ToolCircuitSnapshot update) { + if (!expected.isOpen() && update.isOpen()) { + thresholdRecorded.countDown(); + try { + if (!allowThresholdResult.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to commit the trip"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while holding the trip", e); + } + } + return delegate.compareAndSet(toolName, expected, update); + } + + @Override + public void reset(String toolName) { + delegate.reset(toolName); + } + } } diff --git a/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java b/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java index e3c9bcb6ed..d7b75747de 100644 --- a/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java +++ b/agentscope-extensions/agentscope-extensions-redis/src/main/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStore.java @@ -31,35 +31,35 @@ *

With the in-process store each replica has to rediscover an outage for itself, so an N-replica * deployment sends roughly N times the failing traffic and burns N times the tokens before the tool * is withheld everywhere. Sharing the state through Redis means the first replica to trip a circuit - * withholds the tool for all of them, and the state survives a restart or a rescheduled pod. + * withholds the tool for all of them, exactly one replica probes for recovery, and the state survives + * a restart or a rescheduled pod. * - *

Keys

+ *

Key layout

* - *

Two keys per tool, both addressed individually so the store works unchanged on Redis Cluster — - * no multi-key script needs its arguments to share a hash slot: + *

One key per tool, {@code {prefix}{tool}}, holding the whole snapshot as a delimited value: * - *

    - *
  • {@code {prefix}{tool}:fail} — consecutive failure counter - *
  • {@code {prefix}{tool}:circuit} — {@code ":"}; the key's - * presence is what marks the circuit open, so no separate flag can fall out of sync - *
+ *
+ *   "<failureCount>:<generation>:<openedAt>:<probeToken>:<probeLeaseUntil>"
+ * 
* - *

Encoding generation and timestamp in one value keeps {@link #snapshot(String)} — the hot read, - * executed for every supervised tool on every reasoning turn — down to a single {@code GET}. + *

Keeping the entire state in one key is what lets a complete transition be one compare-and-set, + * and it means every script touches a single key — so the store needs no hash tags and works + * unchanged on Redis Cluster. A missing key is the encoding of {@link ToolCircuitSnapshot#CLOSED}, + * so a recovered tool leaves nothing behind. * *

Atomicity

* - *

{@link #recordFailure(String)} and {@link #open(String, long)} are Lua scripts, so their - * read-modify-write steps cannot interleave. Doing {@code INCR} and {@code EXPIRE} as two round - * trips would leave a counter without a TTL whenever the second call is lost, and computing the next - * generation client-side would let two replicas tripping at once write the same generation. + *

{@link #compareAndSet} compares the stored value against the caller's expected encoding and + * replaces it in one Lua script. Doing the comparison client-side would reintroduce exactly the races + * the breaker's compare-and-set protocol exists to remove: two replicas could each read the same + * state and both commit a transition based on it. * *

Expiry

* - *

Both keys carry a TTL so tools that misbehave once do not accumulate state forever. Keep the - * TTL comfortably longer than the breaker's maximum cooldown: if an open circuit's key expires - * mid-cooldown the tool is offered again early, which fails open — safe, but not what was - * configured. The default of 24h clears the default 600s ceiling by a wide margin. + *

Non-closed states carry a TTL so tools that misbehave once do not accumulate state forever. Keep + * the TTL comfortably longer than the breaker's maximum cooldown: if an open circuit's key expires + * mid-cooldown the tool is offered again early, which fails open — safe, but not what was configured. + * The default of 24h clears the default 600s ceiling by a wide margin. */ public class RedisToolCircuitBreakerStore implements ToolCircuitBreakerStore { @@ -69,31 +69,28 @@ public class RedisToolCircuitBreakerStore implements ToolCircuitBreakerStore { private static final String DEFAULT_KEY_PREFIX = "agentscope:tool-cb:"; private static final Duration DEFAULT_TTL = Duration.ofHours(24); - private static final String FAILURE_SUFFIX = ":fail"; - private static final String CIRCUIT_SUFFIX = ":circuit"; + /** Encoding of {@link ToolCircuitSnapshot#CLOSED}: an absent key. */ + private static final String ABSENT = ""; - /** - * Increment the failure counter and refresh its TTL in one step. - * - *

KEYS[1] = failure key; ARGV[1] = TTL seconds. Returns the new count. - */ - private static final String INCREMENT_FAILURE_SCRIPT = - "local count = redis.call('INCR', KEYS[1]) " - + "redis.call('EXPIRE', KEYS[1], ARGV[1]) " - + "return count"; + private static final int FIELD_COUNT = 5; /** - * Advance the generation and stamp the open instant in one step. + * Replace the stored value only if it still equals what the caller observed. * - *

KEYS[1] = circuit key; ARGV[1] = open instant in epoch millis; ARGV[2] = TTL seconds. - * Returns the new generation. + *

KEYS[1] = circuit key; ARGV[1] = expected encoding ({@code ""} for absent); ARGV[2] = new + * encoding ({@code ""} to delete); ARGV[3] = TTL seconds. Returns 1 when committed, 0 when the + * value had changed. */ - private static final String OPEN_NEXT_GENERATION_SCRIPT = - "local current = redis.call('GET', KEYS[1]) local generation = 0 if current then " - + " local sep = string.find(current, ':', 1, true) if sep then generation =" - + " tonumber(string.sub(current, 1, sep - 1)) or 0 end end generation = generation" - + " + 1 redis.call('SET', KEYS[1], generation .. ':' .. ARGV[1], 'EX', ARGV[2])" - + " return generation"; + private static final String COMPARE_AND_SET_SCRIPT = + "local current = redis.call('GET', KEYS[1]) " + + "if current == false then current = '' end " + + "if current ~= ARGV[1] then return 0 end " + + "if ARGV[2] == '' then " + + " redis.call('DEL', KEYS[1]) " + + "else " + + " redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) " + + "end " + + "return 1"; private final RedisClientAdapter client; private final String keyPrefix; @@ -113,7 +110,7 @@ public RedisToolCircuitBreakerStore(RedisClientAdapter client) { * * @param client Redis client adapter * @param keyPrefix prefix for every key, letting environments share one Redis instance - * @param stateTtl how long unused state is retained; must be positive and should exceed the + * @param stateTtl how long non-closed state is retained; must be positive and should exceed the * breaker's maximum cooldown */ public RedisToolCircuitBreakerStore( @@ -130,46 +127,55 @@ public RedisToolCircuitBreakerStore( } @Override - public long recordFailure(String toolName) { - return client.evalScript( - INCREMENT_FAILURE_SCRIPT, - List.of(failureKey(toolName)), - List.of(Long.toString(ttlSeconds))); - } - - @Override - public void resetFailures(String toolName) { - client.deleteKeys(failureKey(toolName)); - } - - @Override - public long failureCount(String toolName) { - return parseLong(client.get(failureKey(toolName))); + public ToolCircuitSnapshot snapshot(String toolName) { + return decode(toolName, client.get(circuitKey(toolName))); } @Override - public long open(String toolName, long openedAtEpochMilli) { + public boolean compareAndSet( + String toolName, ToolCircuitSnapshot expected, ToolCircuitSnapshot update) { return client.evalScript( - OPEN_NEXT_GENERATION_SCRIPT, - List.of(circuitKey(toolName)), - List.of(Long.toString(openedAtEpochMilli), Long.toString(ttlSeconds))); + COMPARE_AND_SET_SCRIPT, + List.of(circuitKey(toolName)), + List.of(encode(expected), encode(update), Long.toString(ttlSeconds))) + == 1L; } @Override - public void close(String toolName) { + public void reset(String toolName) { client.deleteKeys(circuitKey(toolName)); } - @Override - public ToolCircuitSnapshot snapshot(String toolName) { - String value = client.get(circuitKey(toolName)); + /** + * Encode a snapshot, mapping CLOSED to the absent-key marker so "missing" and "closed" compare + * equal. + */ + private static String encode(ToolCircuitSnapshot snapshot) { + if (snapshot == null || ToolCircuitSnapshot.CLOSED.equals(snapshot)) { + return ABSENT; + } + String token = snapshot.probeToken() == null ? "" : snapshot.probeToken(); + return snapshot.failureCount() + + ":" + + snapshot.generation() + + ":" + + snapshot.openedAtEpochMilli() + + ":" + + token + + ":" + + snapshot.probeLeaseUntilEpochMilli(); + } + + /** + * Decode a stored value. Anything unreadable is treated as closed rather than withholding a tool + * forever on the strength of state nobody can interpret. + */ + private ToolCircuitSnapshot decode(String toolName, String value) { if (value == null || value.isEmpty()) { return ToolCircuitSnapshot.CLOSED; } - int separator = value.indexOf(':'); - if (separator <= 0 || separator == value.length() - 1) { - // Unreadable value: treat as closed rather than withholding a tool forever on the - // strength of state nobody can interpret. + String[] parts = value.split(":", -1); + if (parts.length != FIELD_COUNT) { logger.warn( "Ignoring malformed circuit state for tool={}, value={}. Treating the circuit" + " as closed.", @@ -177,35 +183,32 @@ public ToolCircuitSnapshot snapshot(String toolName) { value); return ToolCircuitSnapshot.CLOSED; } - long generation = parseLong(value.substring(0, separator)); - long openedAt = parseLong(value.substring(separator + 1)); - if (generation <= 0L || openedAt <= 0L) { + try { + long failureCount = Long.parseLong(parts[0]); + long generation = Long.parseLong(parts[1]); + long openedAt = Long.parseLong(parts[2]); + String token = parts[3].isEmpty() ? null : parts[3]; + long probeLease = Long.parseLong(parts[4]); + if (failureCount < 0L || generation < 0L || openedAt < 0L || probeLease < 0L) { + logger.warn( + "Ignoring out-of-range circuit state for tool={}, value={}. Treating the" + + " circuit as closed.", + toolName, + value); + return ToolCircuitSnapshot.CLOSED; + } + return new ToolCircuitSnapshot(failureCount, generation, openedAt, token, probeLease); + } catch (NumberFormatException e) { logger.warn( - "Ignoring out-of-range circuit state for tool={}, value={}. Treating the" - + " circuit as closed.", + "Ignoring unparsable circuit state for tool={}, value={}. Treating the circuit" + + " as closed.", toolName, value); return ToolCircuitSnapshot.CLOSED; } - return new ToolCircuitSnapshot(generation, openedAt); - } - - private String failureKey(String toolName) { - return keyPrefix + toolName + FAILURE_SUFFIX; } private String circuitKey(String toolName) { - return keyPrefix + toolName + CIRCUIT_SUFFIX; - } - - private static long parseLong(String value) { - if (value == null || value.isEmpty()) { - return 0L; - } - try { - return Long.parseLong(value.trim()); - } catch (NumberFormatException e) { - return 0L; - } + return keyPrefix + toolName; } } diff --git a/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java b/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java index 5b13b10e0b..3287dd15bd 100644 --- a/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java +++ b/agentscope-extensions/agentscope-extensions-redis/src/test/java/io/agentscope/extensions/redis/circuitbreaker/RedisToolCircuitBreakerStoreTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -31,172 +32,215 @@ import org.junit.jupiter.api.Test; /** - * Client-side behaviour of {@link RedisToolCircuitBreakerStore}: key naming, argument passing and - * the decoding of persisted circuit values. + * Client-side behaviour of {@link RedisToolCircuitBreakerStore}: key layout, the arguments handed to + * the compare-and-set script, and the encoding of persisted state. * - *

Scope note: the two Lua scripts are executed by Redis, so a fake client cannot run them. These - * tests cover the Java side — which keys are addressed, which arguments the scripts receive, and how - * stored values are decoded, including values a healthy writer would never produce. The scripts' - * server-side effects need a live Redis to verify. + *

Scope note: the Lua script is executed by Redis, so a fake client cannot run it. These tests + * cover the Java side — which key is addressed, what the script receives, and how stored values are + * encoded and decoded, including values a healthy writer would never produce. The script's + * server-side effect needs a live Redis to verify. */ class RedisToolCircuitBreakerStoreTest { private static final String TOOL = "query_weather"; + private static final String KEY = "cb:query_weather"; private final RecordingRedisClient client = new RecordingRedisClient(); - // ==================== Key naming ==================== + // ==================== Key layout ==================== @Test - void keysCarryThePrefixAndDistinctSuffixes() { + void allStateLivesUnderOneKeySoClusterNeedsNoHashTag() { RedisToolCircuitBreakerStore store = store(); - store.recordFailure(TOOL); - store.open(TOOL, 1_000L); + store.snapshot(TOOL); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(1L, 1_000L)); + store.reset(TOOL); - assertEquals( - List.of("cb:query_weather:fail", "cb:query_weather:circuit"), client.scriptKeys); + assertEquals(List.of(KEY), client.reads); + assertEquals(List.of(List.of(KEY)), client.scriptKeys); + assertEquals(List.of(KEY), client.deleted); } @Test - void resetFailuresDeletesOnlyTheCounter() { - RedisToolCircuitBreakerStore store = store(); + void defaultConstructorUsesTheDocumentedPrefix() { + RedisToolCircuitBreakerStore store = new RedisToolCircuitBreakerStore(client); - store.resetFailures(TOOL); + store.reset(TOOL); - assertEquals(List.of("cb:query_weather:fail"), client.deleted); + assertEquals(List.of("agentscope:tool-cb:query_weather"), client.deleted); } + // ==================== Compare-and-set arguments ==================== + @Test - void closeDeletesOnlyTheCircuitKey() { + void closedIsEncodedAsTheAbsentKeyOnBothSidesOfTheSwap() { RedisToolCircuitBreakerStore store = store(); - store.close(TOOL); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(2L, 5_000L)); - assertEquals(List.of("cb:query_weather:circuit"), client.deleted); + // Expected "" makes "missing" and "closed" compare equal; update carries the new state. + assertEquals(List.of("", "0:2:5000::0", "86400"), client.scriptArgs.get(0)); } - // ==================== Script arguments ==================== - @Test - void failureScriptReceivesTheTtlInSeconds() { - RedisToolCircuitBreakerStore store = - new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofMinutes(30)); + void updatingToClosedRequestsDeletionViaAnEmptyUpdate() { + RedisToolCircuitBreakerStore store = store(); + ToolCircuitSnapshot open = new ToolCircuitSnapshot(3L, 7_000L); - store.recordFailure(TOOL); + store.compareAndSet(TOOL, open, ToolCircuitSnapshot.CLOSED); - assertEquals(List.of("1800"), client.scriptArgs.get(0)); + assertEquals(List.of("0:3:7000::0", "", "86400"), client.scriptArgs.get(0)); } @Test - void openScriptReceivesTheTimestampThenTheTtl() { - RedisToolCircuitBreakerStore store = - new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofHours(24)); + void probeClaimIsCarriedInTheEncodedValue() { + RedisToolCircuitBreakerStore store = store(); + ToolCircuitSnapshot claimed = new ToolCircuitSnapshot(0L, 4L, 1_000L, "tok", 9_000L); - store.open(TOOL, 1_767_225_600_000L); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, claimed); - assertEquals(List.of("1767225600000", "86400"), client.scriptArgs.get(0)); + assertEquals(List.of("", "0:4:1000:tok:9000", "86400"), client.scriptArgs.get(0)); } @Test - void subSecondTtlIsFlooredToOneSecondSoKeysNeverPersistForever() { - RedisToolCircuitBreakerStore store = - new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofMillis(200)); + void ttlIsPassedInSecondsAndFlooredToOne() { + new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofMinutes(30)) + .compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(1L, 1L)); + assertEquals("1800", client.scriptArgs.get(0).get(2)); + + client.scriptArgs.clear(); + new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofMillis(200)) + .compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(1L, 1L)); + assertEquals("1", client.scriptArgs.get(0).get(2)); + } - store.recordFailure(TOOL); + @Test + void scriptResultDecidesWhetherTheSwapCommitted() { + RedisToolCircuitBreakerStore store = store(); - assertEquals(List.of("1"), client.scriptArgs.get(0)); + client.scriptResult = 1L; + assertTrue( + store.compareAndSet( + TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(1L, 1_000L))); + + client.scriptResult = 0L; + assertFalse( + store.compareAndSet( + TOOL, ToolCircuitSnapshot.CLOSED, new ToolCircuitSnapshot(1L, 1_000L))); } - // ==================== Decoding persisted state ==================== + // ==================== Decoding ==================== @Test - void snapshotDecodesGenerationAndTimestamp() { + void snapshotDecodesEveryField() { RedisToolCircuitBreakerStore store = store(); - client.values.put("cb:query_weather:circuit", "3:1767225600000"); + client.values.put(KEY, "2:3:1767225600000:tok:1767225660000"); ToolCircuitSnapshot snapshot = store.snapshot(TOOL); - assertTrue(snapshot.isOpen()); + assertEquals(2L, snapshot.failureCount()); assertEquals(3L, snapshot.generation()); assertEquals(1_767_225_600_000L, snapshot.openedAtEpochMilli()); + assertEquals("tok", snapshot.probeToken()); + assertEquals(1_767_225_660_000L, snapshot.probeLeaseUntilEpochMilli()); + assertTrue(snapshot.isOpen()); } @Test - void missingKeyDecodesAsClosed() { + void anEmptyProbeFieldDecodesToNoClaim() { RedisToolCircuitBreakerStore store = store(); + client.values.put(KEY, "0:1:1000::0"); - assertEquals(ToolCircuitSnapshot.CLOSED, store.snapshot(TOOL)); - assertFalse(store.snapshot(TOOL).isOpen()); + ToolCircuitSnapshot snapshot = store.snapshot(TOOL); + + assertNull(snapshot.probeToken()); + assertFalse(snapshot.hasActiveProbe(0L)); } @Test - void unreadableValuesFailOpenRatherThanWithholdingForever() { - RedisToolCircuitBreakerStore store = store(); - String key = "cb:query_weather:circuit"; - - for (String malformed : - List.of("", "garbage", ":", "3:", ":1767225600000", "0:1767225600000", "3:0")) { - client.values.put(key, malformed); - assertEquals( - ToolCircuitSnapshot.CLOSED, - store.snapshot(TOOL), - "expected a closed circuit for stored value: '" + malformed + "'"); - } + void missingKeyDecodesAsClosed() { + assertEquals(ToolCircuitSnapshot.CLOSED, store().snapshot(TOOL)); } @Test - void nonNumericFailureCountReadsAsZero() { + void encodingRoundTripsThroughDecoding() { RedisToolCircuitBreakerStore store = store(); - client.values.put("cb:query_weather:fail", "not-a-number"); + ToolCircuitSnapshot original = new ToolCircuitSnapshot(2L, 3L, 1_000L, "tok", 9_000L); + store.compareAndSet(TOOL, ToolCircuitSnapshot.CLOSED, original); + + // Feed the encoding the store just produced back through the read path. + client.values.put(KEY, client.scriptArgs.get(0).get(1)); - assertEquals(0L, store.failureCount(TOOL)); + assertEquals(original, store.snapshot(TOOL)); } @Test - void failureCountIsReadFromTheCounterKey() { + void unreadableValuesFailOpenRatherThanWithholdingForever() { RedisToolCircuitBreakerStore store = store(); - client.values.put("cb:query_weather:fail", "7"); - assertEquals(7L, store.failureCount(TOOL)); + for (String malformed : + List.of( + "", + "garbage", + "0:1:1000", + "0:1:1000::0:extra", + "x:1:1000::0", + "0:-1:1000::0", + "0:1:-5::0")) { + client.values.put(KEY, malformed); + assertEquals( + ToolCircuitSnapshot.CLOSED, + store.snapshot(TOOL), + "expected a closed circuit for stored value: '" + malformed + "'"); + } } // ==================== Construction ==================== @Test - void constructorRejectsBlankPrefixAndNonPositiveTtl() { + void constructorRejectsInvalidArguments() { + assertThrows( + NullPointerException.class, + () -> new RedisToolCircuitBreakerStore(null, "cb:", Duration.ofHours(1))); assertThrows( IllegalArgumentException.class, () -> new RedisToolCircuitBreakerStore(client, " ", Duration.ofHours(1))); + assertThrows( + IllegalArgumentException.class, + () -> new RedisToolCircuitBreakerStore(client, null, Duration.ofHours(1))); assertThrows( IllegalArgumentException.class, () -> new RedisToolCircuitBreakerStore(client, "cb:", Duration.ZERO)); assertThrows( - NullPointerException.class, - () -> new RedisToolCircuitBreakerStore(null, "cb:", Duration.ofHours(1))); + IllegalArgumentException.class, + () -> new RedisToolCircuitBreakerStore(client, "cb:", null)); } private RedisToolCircuitBreakerStore store() { return new RedisToolCircuitBreakerStore(client, "cb:", Duration.ofHours(24)); } - /** Fake client recording the keys and arguments each call addresses. */ + /** Fake client recording the key and arguments each call addresses. */ private static final class RecordingRedisClient implements RedisClientAdapter { private final Map values = new HashMap<>(); - private final List scriptKeys = new ArrayList<>(); + private final List reads = new ArrayList<>(); + private final List> scriptKeys = new ArrayList<>(); private final List> scriptArgs = new ArrayList<>(); private final List deleted = new ArrayList<>(); + private long scriptResult = 1L; @Override public long evalScript(String script, List keys, List args) { - scriptKeys.addAll(keys); + scriptKeys.add(List.copyOf(keys)); scriptArgs.add(List.copyOf(args)); - return 1L; + return scriptResult; } @Override public String get(String key) { + reads.add(key); return values.get(key); }