-
Notifications
You must be signed in to change notification settings - Fork 128
perf(core): cache sorted hooks to optimize HookNotifier execution #271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
perf(core): cache sorted hooks to optimize HookNotifier execution #271
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Regarding Codecov missing lines: |
| this.sortedHooks = | ||
| builder.hooks.stream().sorted(Comparator.comparingInt(Hook::priority)).toList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Hooks are managed within AgentBase and should not be directly modified in ReActAgent.
- Currently, many extensions add or remove hooks after a ReActAgent is instantiated. A mechanism is needed to support this capability.
|
Thanks for the review! I’ve addressed the requested changes by:
All checks are now passing locally and in CI. |
AlbumenJ
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addHookandaddHookInternalappear to be redundant. Can they be merged?getHooksshould return an unmodifiable list. Please also refactor any code inAgentScopethat modifies hooks viagetHooksto useaddHookorremoveHookinstead.
| if (!hooksDirty.get() && cachedSortedHooks != null) { | ||
| return cachedSortedHooks; | ||
| } | ||
|
|
||
| List<Hook> sorted = hooks.stream().sorted(Comparator.comparingInt(Hook::priority)).toList(); | ||
|
|
||
| cachedSortedHooks = sorted; | ||
| hooksDirty.set(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concurrent issue here
| */ | ||
| public List<Hook> getHooks() { | ||
| return hooks; | ||
| return List.copyOf(hooks); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR attempts to optimize hook notification performance in ReActAgent by caching sorted hooks, but introduces critical bugs in the refactored code. The changes add a lazy caching mechanism to AgentBase and refactor ReActAgent.HookNotifier to use Reactor's Flux API. However, the refactoring breaks event chaining behavior where hooks should receive events modified by previous hooks.
Key changes:
- Adds hook sorting cache with dirty-flag invalidation in
AgentBase - Exposes new
addHook/removeHookpublic API methods - Refactors
HookNotifiermethods from for-loops toFlux.fromIterable().concatMap()
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| agentscope-core/src/main/java/io/agentscope/core/agent/AgentBase.java | Adds caching mechanism for sorted hooks with cachedSortedHooks field, hooksDirty flag, and new addHook/removeHook methods; modifies getHooks() to return immutable copy |
| agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java | Refactors HookNotifier methods to use Flux API, but breaks event chaining by passing the same event object to all hooks instead of chaining modifications |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!hooksDirty.get() && cachedSortedHooks != null) { | ||
| return cachedSortedHooks; | ||
| } | ||
|
|
||
| List<Hook> sorted = hooks.stream().sorted(Comparator.comparingInt(Hook::priority)).toList(); | ||
|
|
||
| cachedSortedHooks = sorted; | ||
| hooksDirty.set(false); | ||
| return sorted; |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The caching implementation has a race condition. Between checking hooksDirty.get() and reading cachedSortedHooks, another thread could modify the hooks list, set the dirty flag, and clear the cache. This violates the documented single-threaded execution constraint and could cause the method to return a stale or null cached list.
Since the class documentation states agents are not designed for concurrent execution, consider either:
- Removing the volatile and atomic constructs to align with the single-threaded design
- If thread-safety is needed, implement proper synchronization or use a thread-safe caching pattern
| if (!hooksDirty.get() && cachedSortedHooks != null) { | |
| return cachedSortedHooks; | |
| } | |
| List<Hook> sorted = hooks.stream().sorted(Comparator.comparingInt(Hook::priority)).toList(); | |
| cachedSortedHooks = sorted; | |
| hooksDirty.set(false); | |
| return sorted; | |
| List<Hook> currentCache = cachedSortedHooks; | |
| if (!hooksDirty.get() && currentCache != null) { | |
| return currentCache; | |
| } | |
| synchronized (this) { | |
| if (!hooksDirty.get() && cachedSortedHooks != null) { | |
| return cachedSortedHooks; | |
| } | |
| List<Hook> sorted = hooks.stream() | |
| .sorted(Comparator.comparingInt(Hook::priority)) | |
| .toList(); | |
| cachedSortedHooks = sorted; | |
| hooksDirty.set(false); | |
| return sorted; | |
| } |
| public void addHook(Hook hook) { | ||
| hooks.add(hook); | ||
| hooksDirty.set(true); | ||
| } | ||
|
|
||
| public void removeHook(Hook hook) { | ||
| hooks.remove(hook); | ||
| hooksDirty.set(true); |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new public methods addHook and removeHook expose hook list mutation without any documentation about when and how they should be used. This could lead to misuse, especially since:
- The class documentation states hooks list is modified during streaming operations without synchronization
- The methods don't validate the hook parameter for null
- There's no guidance on thread-safety constraints
Consider adding Javadoc to document:
- Expected usage patterns (e.g., should only be called before agent execution starts)
- Thread-safety constraints
- Null parameter handling behavior
| public List<Hook> getHooks() { | ||
| return hooks; | ||
| return List.copyOf(hooks); | ||
| } |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Javadoc for getHooks() should be updated to reflect that it now returns an immutable copy rather than the original mutable list. The previous behavior allowed callers to directly manipulate the hooks list, but now returns List.copyOf(hooks) which creates an immutable snapshot.
This is a breaking API change that should be documented so callers understand they can no longer modify the returned list directly and must use the new addHook/removeHook methods instead.
| return Flux.fromIterable(getSortedHooks()) | ||
| .concatMap(hook -> hook.onEvent(event)) | ||
| .last(event) | ||
| .map(PreReasoningEvent::getInputMessages); |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The refactored hook notification logic has a subtle bug. The new implementation passes the same initial event object to all hooks via concatMap, rather than chaining the modified events from one hook to the next.
In the original code:
- Each hook receives the event modified by the previous hook
- Events are properly chained: hook1 → modified event → hook2 → modified event → hook3
In the new code:
- All hooks receive the same initial
eventobject - Hook modifications are discarded except for the last hook
- The
.last(event)fallback returns the unmodified event if there are no hooks
This breaks the documented behavior where "each hook receiving the event modified by previous hooks" (line 446 in AgentBase.java). The fix requires passing the mutated event through the chain, not the original immutable event reference.
| return Flux.fromIterable(getSortedHooks()) | ||
| .concatMap(hook -> hook.onEvent(event)) | ||
| .last(event) | ||
| .map(PostReasoningEvent::getReasoningMessage); |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as in notifyPreReasoning: the event chaining is broken. All hooks receive the same initial event object instead of receiving the event modified by previous hooks. This violates the expected hook behavior where modifications from earlier hooks should be visible to later hooks.
| return Flux.fromIterable(getSortedHooks()) | ||
| .concatMap(hook -> hook.onEvent(event)) | ||
| .last(event) | ||
| .map(PreActingEvent::getToolUse); |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as in other notify methods: the event chaining is broken. All hooks receive the same initial event object instead of receiving the event modified by previous hooks.
| PostActingEvent event = | ||
| new PostActingEvent(ReActAgent.this, toolkit, toolUse, toolResult); | ||
|
|
||
| return Flux.fromIterable(getSortedHooks()) | ||
| .concatMap(hook -> hook.onEvent(event)) | ||
| .last(event) | ||
| .map(PostActingEvent::getToolResult); |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as in other notify methods: the event chaining is broken. All hooks receive the same initial event object instead of receiving the event modified by previous hooks.
| PostActingEvent event = | |
| new PostActingEvent(ReActAgent.this, toolkit, toolUse, toolResult); | |
| return Flux.fromIterable(getSortedHooks()) | |
| .concatMap(hook -> hook.onEvent(event)) | |
| .last(event) | |
| .map(PostActingEvent::getToolResult); | |
| PostActingEvent initialEvent = | |
| new PostActingEvent(ReActAgent.this, toolkit, toolUse, toolResult); | |
| Mono<PostActingEvent> chainedEvent = Mono.just(initialEvent); | |
| for (Hook hook : getSortedHooks()) { | |
| chainedEvent = chainedEvent.flatMap(hook::onEvent).cast(PostActingEvent.class); | |
| } | |
| return chainedEvent.map(PostActingEvent::getToolResult); |
| public void addHook(Hook hook) { | ||
| hooks.add(hook); | ||
| hooksDirty.set(true); | ||
| } | ||
|
|
||
| public void removeHook(Hook hook) { | ||
| hooks.remove(hook); | ||
| hooksDirty.set(true); |
Copilot
AI
Dec 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new hook caching mechanism (cachedSortedHooks, hooksDirty) and the public addHook/removeHook methods lack test coverage. Given that:
- Hooks are already tested in the codebase (HookEventTest.java)
- The caching mechanism is a performance optimization with cache invalidation logic
- The new public API methods allow runtime hook modification
Consider adding tests to verify:
- Cache invalidation works correctly when hooks are added/removed
- Hook priority ordering is preserved in cached results
- Multiple calls to
getSortedHooks()return the cached instance when no modifications occur
|
I’ve addressed all the requested changes from the review and updated the implementation accordingly. |

1.0.4-SNAPSHOT
Background:
ReActAgent.HookNotifierpreviously re-sorted the hook list on every hookevent notification. In streaming reasoning scenarios (e.g. 100+ chunks),
this resulted in unnecessary CPU overhead because the hook list is immutable
after agent construction.
Changes:
ReActAgentconstructionBenefits:
How to Test:
mvn -pl agentscope-core spotless:apply mvn -pl agentscope-core test