Skip to content

feat(tool): add tool-level circuit breaker to stop offering repeatedly failing tools - #2984

Open
pure11011 wants to merge 1 commit into
agentscope-ai:mainfrom
pure11011:feat/tool-circuit-breaker
Open

feat(tool): add tool-level circuit breaker to stop offering repeatedly failing tools#2984
pure11011 wants to merge 1 commit into
agentscope-ai:mainfrom
pure11011:feat/tool-circuit-breaker

Conversation

@pure11011

@pure11011 pure11011 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

AgentScope-Java Version

2.0.3-SNAPSHOT (current main, ea511ec)

Description

Implements #2983.

Problem. A ReAct loop is reason → call tool → read result → reason again. When a tool returns an error the model usually calls it again, because one failure looks incidental from where it stands, and nothing in the framework remembers that a 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; ten consecutive failures are ten wasted reasoning rounds. 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 — so none of them can express "this tool has failed N times in a row, leave it alone for a while".

Change. An opt-in, per-tool breaker with CLOSED → OPEN → HALF_OPEN transitions and exponential backoff, added as new classes only. Nothing existing changes behaviour.

New in io.agentscope.core.tool.circuitbreaker:

  • ToolCircuitBreaker — the state machine and backoff policy. A plain object with no reactive or agent dependencies, so it is unit-testable against an injected Clock.
  • ToolCircuitBreakerConfig — supervised/excluded tool sets, threshold, initial cooldown, multiplier, ceiling, with validation in build().
  • ToolCircuitBreakerStore — persistence SPI, plus InMemoryToolCircuitBreakerStore as the default.
  • ToolCircuitSnapshot / ToolCircuitState — the persisted state and the derived lifecycle state.
  • ToolCircuitBreakerMiddleware — the adapter onto MiddlewareBase.

New in agentscope-extensions-redis: RedisToolCircuitBreakerStore, so replicas share one view of a broken tool instead of each rediscovering the outage and sending N times the failing traffic.

Three design points that reviewers will want to weigh:

1. The breaker withholds the tool rather than rejecting the call. A classic breaker sits between caller and dependency and fails fast once open. Here there is a better option: drop the tool from the schema list the model receives. A tool the model cannot see is a tool it cannot ask for, so the retry loop disappears at the source instead of being absorbed, and no prompt has to argue the model out of it.

onReasoning filters ReasoningInput.tools() into a per-turn copy; the Toolkit is never touched. Mutating ToolGroup membership is the other way to hide a tool, and I deliberately avoided it: group membership is shared mutable state, so a circuit tripped for one session would remove the tool from every concurrent session and would overwrite the registrations the application declared. Per-turn filtering also means recovery needs no repair step — once the breaker stops withholding, the unfiltered list is already correct — which removes a whole class of state-thrashing bugs.

2. Failures are classified by typed state, not by matching error text. onActing reads the ToolResultState already carried on ToolResultEndEvent. Only ERROR counts. DENIED is a permission refusal and INTERRUPTED a cancellation; neither is evidence about the dependency, and counting DENIED would let a user who declines a confirmation prompt trip the circuit. RUNNING marks a suspended call whose outcome is not known yet.

3. Supervision is opt-in, and the default configuration is inert. Only tools named via monitorTools(...) are watched, unless monitorAllTools(true) is set; excludeTools(...) overrides both. Withholding a flaky weather API degrades an agent gracefully, but withholding the database or filesystem tool cripples it, and opt-out supervision would also cover framework-supplied per-call tools such as the structured-output tool — losing that one stops the agent completing at all. Requiring the set to be named means a breaker cannot take down a tool nobody considered. The monitorAllTools javadoc spells this out.

Cooldown is min(initialCooldown * multiplier^(generation-1), maxCooldown), derived from the stored generation rather than persisted, so changing the policy takes effect immediately and never needs migrating. Defaults of 60s / x2 / 600s give 60s, 120s, 240s, 480s, 600s… cooldownFor clamps non-finite and over-ceiling values, so a large generation cannot isolate a tool indefinitely. State is derived from the stored snapshot plus the current time, so a cooldown that elapsed while the agent was idle is picked up on the next read: no scheduler, no timer, no background thread, and nothing to leak.

Both store implementations make the two read-modify-write operations atomic — ConcurrentHashMap.compute/computeIfAbsent in memory, single-key Lua scripts in Redis. The Redis keys are addressed one at a time, so no script needs its arguments to share a hash slot and the store works unchanged on Redis Cluster.

Checklist

Please check the following items before code is ready to be reviewed.

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test) — agentscope-core 2360 tests and agentscope-extensions-redis 12 tests pass locally; full reactor build runs in CI
  • Javadoc comments are complete and follow project conventions — mvn -pl agentscope-core clean verify builds the javadoc jar without warnings
  • Related documentation has been updated (e.g. links, examples, etc.) — no docs page added yet; the API is documented in class-level javadoc with a usage example on ToolCircuitBreakerMiddleware. Happy to add a page under docs/ if maintainers want one, once the API shape is agreed.
  • Code is ready for review

…y 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.
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

@CryoThrust CryoThrust left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One concurrency edge case to consider before merge: the implementation documents HALF_OPEN as a single recovery probe, but state() only derives HALF_OPEN from the elapsed timestamp and does not claim a probe. Once the cooldown elapses, concurrent reasoning turns can all observe HALF_OPEN, advertise the tool, and invoke it before any result is recorded. Each successful call will close the circuit, while concurrent failures can call open() repeatedly and advance the generation/backoff more than once. The same race exists across replicas with the Redis store.

Could the store/middleware expose an atomic half-open permit (for example, compare-and-set OPEN -> HALF_OPEN with a lease/token) so only one in-flight probe is allowed, while other turns continue withholding the tool until that probe succeeds or its lease expires? If the intended policy is to allow concurrent probes, the state-machine/Javadoc should say so and the tests should cover the behavior, since it differs from the stated “single probe” semantics.

@CryoThrust CryoThrust left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A second concurrency issue affects the “consecutive failures” contract, independently of the HALF_OPEN probe race above. recordFailure() performs recordFailure() -> checks the returned count -> open() -> resetFailures() as separate store operations, while recordSuccess() can call resetFailures() between those steps. A possible interleaving is: (1) failure A increments the closed counter to the threshold; (2) a successful call B observes CLOSED and clears the counter; (3) failure A still calls open() using its stale threshold result and trips the circuit even though the failures were not consecutive. The same window exists with the Redis store because the increment and open scripts are separate commands. Please make the threshold transition conditional/atomic (for example, a store operation that records the outcome and opens only if the same counter version still reaches the threshold, or a CAS/version token), or document and test a weaker policy. A deterministic concurrent test with a barrier between the threshold check and open() would expose this regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants