Skip to content

fix(core): add per-session tool group activation API - #3206

Open
Q1Xuan wants to merge 2 commits into
agentscope-ai:mainfrom
Q1Xuan:fix/core-per-session-tool-groups-3167
Open

Q1Xuan wants to merge 2 commits into
agentscope-ai:mainfrom
Q1Xuan:fix/core-per-session-tool-groups-3167

Conversation

@Q1Xuan

@Q1Xuan Q1Xuan commented Sep 19, 2026

Copy link
Copy Markdown

AgentScope-Java Version

2.0.3-SNAPSHOT (main @ 70686e4a)

Description

Fixes #3167

Problem. Tool group activation is resolved from each (userId, sessionId) slot's AgentState.toolContext.activatedGroups, while the toolkit's own activation flags are per-call scratch state that activateSlotForContext re-seeds from that AgentState at every call entry. Calling agent.getToolkit().updateToolGroups(...) between calls therefore looks successful (toolkit.getActiveGroups() reports the change) but is silently discarded at the next call and never reaches the model — and there was no supported API to change a session's active groups from application code.

This implements options 1 + 2 from the issue evaluation (new convenience API + warning + docs). Option 3 (making the shared toolkit flags authoritative again) was deliberately not taken, as it would regress per-session isolation for concurrent calls.

Changes

  • ReActAgent: new getActiveToolGroups / updateToolGroups(groups, active) / setActiveToolGroups(groups), keyed by (userId, sessionId) or RuntimeContext. They validate group names against the toolkit (IllegalArgumentException on unknown names), reload the latest persisted state first when an AgentStateStore is configured, mutate the session's AgentState and persist it — the same get → mutate → save pattern already used by setPermissionMode / replacePermissionContext. Other sessions are unaffected. A request that leaves the list unchanged is a no-op (no store write); deactivation honours ToolkitConfig.allowToolDeletion(false) like Toolkit.updateToolGroups.
  • ReActAgent: an update while a call on the same session is in flight on this instance is rejected with IllegalStateException (the in-flight call's own syncToolkitToState + stale-version save would otherwise silently revert it under OVERWRITE). Call admission in beforeAgentExecution and the setters share a striped per-slot lock, so an update either lands before the call loads its state or observes the call as in flight — no window in between.
  • AgentBase: new releaseCallScope(Object callScope) hook, invoked exactly once (via Mono.using) for calls that actually ran beforeAgentExecution. ReActAgent keeps its in-flight bookkeeping there instead of in afterAgentExecution(), which also runs for calls cancelled while queued at the same-session gate and previously unbalanced the counter (false out-of-call warning + consumed one-shot flag).
  • ReActAgent: logs a one-shot warning when the toolkit's active groups are found changed outside of any call (compared against a snapshot taken at call boundaries, gated by the in-flight counter so concurrent sessions and in-call meta-tool / skill activations are not flagged). Diagnostics only; no behavioural effect.
  • ReActAgent: APPEND_MERGE now carries the writer's activated groups into the merged state (alongside the permission context) so a conflicting per-session update is not dropped.
  • Toolkit: getConfig() accessor.
  • HarnessAgent: delegates the new methods; getToolkit() Javadoc (core + harness) now states that mutating the returned toolkit between calls has no effect and points to the per-session API.
  • Docs (docs/v2/{en,zh}/docs/building-blocks/tool.md): new "Activating groups programmatically (per session)" section with a warning about the old pattern and the in-flight / no-op / allowToolDeletion semantics.

No existing public signatures changed; everything is additive.

Not in scope (pre-existing, will file separately): concurrent calls on distinct sessions share the toolkit's activation flags; a session entering while another has an in-call reset_equipped_tools / skill activation resets the flags, and the earlier session's final syncToolkitToState then persists the reset list. This exists on main independently of this PR and needs the meta tool / skills to target the call's state rather than the shared toolkit.

How to test

  • mvn -pl agentscope-core test -Dtest=ReActAgentPerSessionStateTest — 13 new tests: activation visible on the next call of the target session only, replacement semantics persisted across an engine restart over the same store, RuntimeContext / default-session overloads, unknown-group rejection without touching state, update applied on top of the latest persisted state (not a stale cache), out-of-call toolkit mutation discarded and flagged, in-call reset_equipped_tools activation synced to the session and not flagged, update rejected while the same session's call is in flight (other session still updatable; persisted state untouched), concurrent session entering during another's in-call activation not flagged (one-shot diagnostic still armed afterwards), same-session call cancelled at the gate keeps tracking balanced, idempotent setters skip the store, allowToolDeletion(false) honoured, APPEND_MERGE conflict keeps the requested activation and the competing writer's context.
  • mvn -pl agentscope-harness test -Dtest=HarnessAgentTest — new delegation test (target session activated, other session untouched, stored on AgentState).
  • Full agentscope-core suite: 2377 tests, 0 failures; HarnessAgentTest: 52/52; mvn spotless:check clean.

Checklist

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

🤖 Generated with Claude Code

Fixes agentscope-ai#3167

Tool group activation is resolved from each (userId, sessionId) slot's
AgentState, while the toolkit's own activation flags are per-call scratch
state re-seeded from that AgentState at every call entry. Calling
agent.getToolkit().updateToolGroups(...) between calls therefore appeared
to succeed (toolkit.getActiveGroups() reported the change) but never
reached the model, and there was no supported API to change a session's
active groups from application code.

- ReActAgent: add getActiveToolGroups / updateToolGroups /
  setActiveToolGroups keyed by (userId, sessionId) or RuntimeContext.
  They validate group names against the toolkit, reload the latest
  persisted state when a store is configured, mutate the session's
  AgentState and persist it, following the existing get -> mutate -> save
  pattern used by setPermissionMode.
- ReActAgent: log a one-shot warning when the toolkit's active groups are
  found changed outside of any call, pointing to the per-session API.
- HarnessAgent: delegate the new methods; document on getToolkit() that
  mutating the returned toolkit between calls has no effect.
- Docs (en/zh): new "Activating groups programmatically (per session)"
  section in building-blocks/tool.md with a warning about the old pattern.
- Tests: per-session activation visibility and isolation, replacement
  semantics with persistence across engine restarts, RuntimeContext and
  default-session overloads, unknown-group rejection, reload-on-top-of-
  latest-persisted-state, discarded out-of-call toolkit mutation, and
  meta-tool (reset_equipped_tools) in-call activation not being flagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 19, 2026 08:35
@CLAassistant

CLAassistant commented Sep 19, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Unresolved critical correctness issues in ReActAgent can violate session isolation and configured tool-deletion behavior.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 High severity · 1 Low severity

Open (4)
What changed in this PR

Adds per-session tool-group activation APIs for ReActAgent and HarnessAgent, with persistence, diagnostics, tests, and bilingual documentation.

Changes:

  • Adds session- and RuntimeContext-scoped activation, update, and replacement APIs.
  • Adds warnings for ineffective direct toolkit mutations.
  • Updates delegation, tests, and tool documentation.
File Summary
docs/​v2/​zh/​docs/​building-blocks/​tool.md Documents per-session tool-group activation.
docs/​v2/​en/​docs/​building-blocks/​tool.md Documents per-session tool-group activation.
agentscope-harness/​src/​test/​java/​io/​agentscope/​harness/​agent/​HarnessAgentTest.java Tests delegation and session isolation.
agentscope-harness/​src/​main/​java/​io/​agentscope/​harness/​agent/​HarnessAgent.java Delegates the new APIs and updates Javadocs.
agentscope-core/​src/​test/​java/​io/​agentscope/​core/​agent/​ReActAgentPerSessionStateTest.java Tests activation, persistence, and diagnostics.
agentscope-core/​src/​main/​java/​io/​agentscope/​core/​ReActAgent.java Implements APIs and diagnostics. Critical issues remain around concurrent state persistence, deletion protection, and conflict-policy merging; a lifecycle accounting nit also remains.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +718 to 722
// The toolkit's activation flags are per-call scratch state seeded from the session:
// the model-facing tool surface is resolved from state.getToolContext() (see
// reasoning()), and in-call changes (meta tool, skills) flow back via
// syncToolkitToState. Anything written to the flags between calls is discarded here.
toolkit.setActiveGroups(loaded.getToolContext().getActivatedGroups());
Comment on lines +4462 to +4466
} else {
groups.remove(groupName);
}
}
state.getToolContext().setActivatedGroups(groups);
Comment on lines +4466 to +4467
state.getToolContext().setActivatedGroups(groups);
saveAgentState(userId, sid);
Comment on lines +892 to +894
// Floor at zero: a call cancelled while waiting on the same-session gate never ran
// beforeAgentExecution but still reaches this cleanup.
inFlightCalls.updateAndGet(n -> Math.max(0, n - 1));

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Adds the missing per-session tool-group activation API (getActiveToolGroups / updateToolGroups / setActiveToolGroups on ReActAgent, delegated by HarnessAgent), following the same load-latest → mutate → saveAgentState shape already used by clearContext / setPermissionMode, plus a one-shot diagnostic for the silently-discarded agent.getToolkit().updateToolGroups(...) pattern and matching en/zh docs. The direction is right, the Javadoc is honest about the caveats, and the 7 core + 1 harness tests cover the sequential paths well. Two concurrency questions keep this from being a clean approve; both are about what happens when an update lands while a call on the same (userId, sessionId) slot is in flight. CLA signed; validate / Check License / Module Sync green, ubuntu and windows builds still pending at review time.

Findings

  • [Warning] ReActAgent.java:4467 — under the default ConflictPolicy.OVERWRITE, an update during an in-flight call on the same session is silently reverted by that call's own syncToolkitToState + stale-version save; the javadoc asks callers not to do this but nothing enforces it.
  • [Warning] ReActAgent.java:894inFlightCalls is incremented in beforeAgentExecution but decremented unconditionally in afterAgentExecution, so a call cancelled at the same-session gate can drop the counter to 0 while another call runs, firing a false out-of-call warning and consuming the one-shot flag.
  • [Info] ReActAgent.java:4498 — the setters persist even when nothing changed (extra agent_state IO, version bump, and the CAS conflict above); an equality early-return would make them idempotent.
  • [Info] ReActAgentPerSessionStateTest.java:640 — update-while-in-flight and two concurrent sessions (single per-agent toolkitGroupsAtLastCallBoundary) are the two behaviours the safety story rests on, and neither is asserted.

Suggestions

For the in-flight case, either reject the call for a slot that has a live call (a per-slot in-flight set you already half-have), or apply the mutation to the slot's cached state and let the next activateSlotForContext pick it up without the in-flight call writing its own list back afterwards. For the diagnostic, carrying a per-call boolean on CallExecution (or reading it back from the Reactor context in the cleanup hook) avoids the counter asymmetry entirely.


Automated review by github-manager-bot

}
}
state.getToolContext().setActivatedGroups(groups);
saveAgentState(userId, sid);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

An out-of-call update on a slot that has a call in flight is silently reverted under the default ConflictPolicy.OVERWRITE, which is exactly the failure mode this PR exists to remove. Sequence: the in-flight CallExecution holds its own AgentState seeded with G0; updateToolGroups reloads + persists G0+X; the in-flight call then hits syncToolkitToState(...) (consumeSystemMsgAfterPreCall, the per-iteration write-back, and saveStateToSession), which writes the toolkit's G0 back into its state and persists with the now-stale scope.loadedVersion -> CAS conflict -> OVERWRITE -> the session is back on G0 and the user's activation is lost, with only a generic agent_state CAS conflict — OVERWRITE applied line to explain it. The javadoc asks callers to invoke this after the current call completes, but nothing enforces it and the outcome is a lost write rather than a no-op. Suggested: keep a per-slot in-flight marker (you already have the counter idea here) and either throw/reject for a slot with a live call, or defer the mutation into stateCache and let the next activateSlotForContext pick it up. Either way a test for "update while the same session's call is in flight" would pin the chosen behaviour.

}
// Floor at zero: a call cancelled while waiting on the same-session gate never ran
// beforeAgentExecution but still reaches this cleanup.
inFlightCalls.updateAndGet(n -> Math.max(0, n - 1));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

inFlightCalls is incremented only in beforeAgentExecution but decremented unconditionally in afterAgentExecution, and Math.max(0, n - 1) hides the imbalance instead of surfacing it. A call cancelled at the same-session gate still decrements, so with call X in flight (1) and call Y cancelled at the gate, the counter reaches 0 while X is genuinely running; the next beforeAgentExecution then runs warnIfToolkitGroupsChangedOutsideCall() while X's in-call meta-tool / skill activation is still on the toolkit flags, producing a false "changed outside of a call" warning and consuming the one-shot warnedToolkitGroupsChangedOutsideCall flag — after which a real instance of the misuse goes unlogged for the lifetime of the agent. Since this is diagnostics-only, a per-call boolean carried on the CallExecution (or read back from the Reactor context in the cleanup hook) is a safer gate than a global counter.

Comment thread agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java Outdated
@DisplayName(
"in-call toolkit activation via the meta tool is synced to the session and not"
+ " flagged as misuse")
void inCallToolkitActivationIsNotFlagged() throws Exception {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good coverage of the sequential paths (target-session-only visibility, replacement semantics across a restart, RuntimeContext / default-session overloads, unknown-group rejection without touching state, latest-persisted reload, in-call activation not flagged). The two cases the safety of the new API actually rests on are not asserted: (1) an update while a call on the same (userId, sessionId) is in flight, and (2) two sessions called concurrently, where the single per-agent toolkitGroupsAtLastCallBoundary ping-pongs between the two slots' values. Adding those two would turn the current "best-effort diagnostics" claim into something enforced.

@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.95238% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...e/src/main/java/io/agentscope/core/ReActAgent.java 80.80% 9 Missing and 10 partials ⚠️
.../main/java/io/agentscope/core/agent/AgentBase.java 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

…eeping

Follow-up to the review on agentscope-ai#3206.

- ReActAgent.updateToolGroups / setActiveToolGroups now run under a
  per-slot mutation lock and throw IllegalStateException when a call on
  the same (userId, sessionId) is in flight on this instance. Previously
  the in-flight call's own syncToolkitToState + stale-version save
  reverted the update under the default OVERWRITE policy. Admission in
  beforeAgentExecution takes the same lock, so an update either lands
  before the call loads its state or sees the call as in flight.
- Both setters return early (no store write) when the resolved list
  equals the current one, and honour ToolkitConfig.allowToolDeletion
  like Toolkit.updateToolGroups. Toolkit gains getConfig().
- AgentBase: new releaseCallScope(Object) hook, invoked exactly once via
  Mono.using for calls that ran beforeAgentExecution. ReActAgent moves
  the in-flight counter decrement and boundary snapshot there, so a call
  cancelled while queued at the same-session gate (which only reaches
  afterAgentExecution) no longer drops the counter and fires a false
  out-of-call warning.
- APPEND_MERGE now carries the writer's activated groups into the
  merged state, alongside the permission context.
- Tests: update rejected while same-session call in flight, concurrent
  session with in-call activation not flagged, cancelled queued call
  keeps tracking balanced, idempotent setters skip the store,
  allowToolDeletion(false) honoured, APPEND_MERGE conflict keeps the
  activation. Docs updated accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Q1Xuan

Q1Xuan commented Sep 19, 2026

Copy link
Copy Markdown
Author

Thanks for the reviews — addressed in de81492. Summary of what I verified and changed, per finding:

In-flight update silently reverted (oss-maintainer ⚠️ ReActAgent.java:4467) — confirmed. The in-flight CallExecution holds its own state seeded with G0; the API persisted G0+X; the call's syncToolkitToState + stale-version save then hit the CAS conflict and OVERWRITE put G0 back. Fixed by rejecting: updateToolGroups / setActiveToolGroups now throw IllegalStateException (session untouched) when a call on the same (userId, sessionId) is in flight on this instance. The check is not a bare contains — call admission in beforeAgentExecution and the setters share a striped per-slot lock, so an update either lands before the call loads its state or observes the call as in flight; there is no window where the call loads G0 after the check but before the save. Cross-instance in-flight calls can't be detected; the javadoc says so and defers to the configured ConflictPolicy.

Unbalanced inFlightCalls (oss-maintainer ⚠️ ReActAgent.java:894, Copilot low) — confirmed: afterAgentExecution() runs from Mono.using cleanup for every subscription, including one cancelled while queued at the gate that never ran beforeAgentExecution. Fixed by adding AgentBase.releaseCallScope(Object callScope), invoked exactly once (nested Mono.using, eager) for calls that were actually admitted, before the terminal signal reaches the caller. ReActAgent moves the counter decrement, the boundary snapshot, and the per-slot in-flight removal there; afterAgentExecution() keeps only activeRc / hook unbinding. Math.max(0, …) is gone.

Setters persist when nothing changed (oss-maintainer ℹ️ ReActAgent.java:4498) — early return when the resolved list equals the current one; no store write, no version bump. Javadoc now states that setActiveToolGroups(uid, sid, List.of()) deactivates every group.

allowToolDeletion(false) bypassed (Copilot) — agreed, and cheap to honour: updateToolGroups(…, false) mirrors Toolkit.updateToolGroups (warn + ignore); setActiveToolGroups keeps the groups it would otherwise drop (warn) and still activates the listed ones. Toolkit gained getConfig() for this.

APPEND_MERGE drops toolContext (Copilot) — confirmed; the merge path only carried appended context + permission context. It now carries the writer's activated groups as well, same treatment as the permission context.

Shared toolkit flags leak across concurrent sessions (Copilot high, ReActAgent.java:722) — real, but pre-existing on main and not introduced here: activateSlotForContext re-seeding the shared flags and syncToolkitToState reading them back are both unchanged by this PR. I reproduced it: session A activates admin via reset_equipped_tools mid-call and is held; session B enters and re-seeds the flags to [basic]; A's final save then persists [basic] and loses its own activation. Fixing it means the meta tool / SkillBox must target the call's AgentState instead of the shared toolkit, which is a separate refactor — I've noted it in the PR description as out of scope and will open an issue for it. The per-session API itself is unaffected (it writes the slot's state directly and never touches the flags).

Tests (oss-maintainer ℹ️ ReActAgentPerSessionStateTest.java:640) — added the two requested plus four for the other findings: update rejected while the same session's call is in flight (other session still updatable, persisted state untouched); a second session entering while another holds an in-call activation is not flagged, and the one-shot diagnostic still fires for a genuine mutation afterwards; a same-session call cancelled at the gate keeps tracking balanced; idempotent setters skip the store; allowToolDeletion(false) honoured; APPEND_MERGE conflict keeps the requested activation and the competing writer's context. All except the concurrent-session one fail against the previous revision. Core suite 2377/0, HarnessAgentTest 52/52, spotless clean.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-review of the new commit de81492d (previous round was on b032f330). Thanks for the thorough response — all three findings from the last round are genuinely fixed, and the fix went further than asked:

  • In-flight update silently reverted → resolved. Admission and the out-of-call setters now share slotMutationLocks, the update is rejected with IllegalStateException while the same session has a call in flight, and the toolGroupUpdateRejectedWhileSameSessionCallInFlight test asserts the persisted session is left untouched and that a later update lands.
  • Unbalanced inFlightCalls on a gate-cancelled call → resolved via the new AgentBase.releaseCallScope hook, with cancelledQueuedCallDoesNotUnbalanceInFlightTracking covering the exact path.
  • Needless store writes → resolved by the updated.equals(current) no-op short-circuit, plus allowToolDeletion(false) is now honoured and APPEND_MERGE carries the activation list. toolGroupSettersAreIdempotent and toolGroupDeactivationHonoursAllowToolDeletion cover both.

What is left is about the shape of the new synchronisation, not the correctness of the fix: the big one is that blocking store I/O now happens inside a 64-stripe monitor that used to be off the hot path, so distinct sessions can now wait on each other. The rest is a rollback gap after the in-flight registration and a per-subscription/idempotency question around the new Mono.using. None of it looks like it breaks the behaviour the PR is about, so I am leaving these as inline comments rather than change requests — happy to see this land once a maintainer has weighed in on the first one.

Two non-blocking notes: Codecov reports 80.95% patch coverage with 20 lines uncovered — likely the IllegalStateException / allowToolDeletion branches under other overloads is worth a look; and CI is green (build on ubuntu + windows, validate, CLA signed).

Automated review by github-manager-bot

CallExecution scope;
// Admission (registering the call as in flight + loading its state) is atomic with respect
// to out-of-call mutators on the same slot, see slotMutationLocks.
synchronized (slotMutationLock(slot)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Admission now runs activateSlotForContext — which always reads the session's AgentState from the configured AgentStateStoreinside the new striped monitor. Because the stripe is slot.hashCode() % 64, an unrelated session that hashes to the same stripe is blocked for the full duration of this blocking store read, so a slow file/Redis/JDBC store turns into head-of-line blocking between sessions on the hottest path in the agent. Before this PR, distinct sessions never waited on each other (serializeOnKey only serialised same-slot calls). Could the critical section be narrowed to the in-flight registration only (register under the lock, do the store load outside it, re-check after loading), or the stripe count raised / made per-slot with clean-up?

inFlightCalls.decrementAndGet();
throw e;
}
inFlightBySlot.put(scope.slotKey, scope);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The rollback catch only covers activateSlotForContext; everything after the put (ctx.setAgentState, bindRuntimeContextToHooks, state.interruptControl().reset()) can still throw while the slot is already registered and inFlightCalls already incremented. Such a throw never reaches releaseCallScope (the scope is never returned to Mono.using), so the slot stays in inFlightBySlot forever: every later updateToolGroups / setActiveToolGroups on that session then fails with a misleading IllegalStateException, and inFlightCalls never returns to 0 so the one-shot out-of-call diagnostic stops firing. Either move the put to the end of admission or wrap the remainder in the same try { … } catch (RuntimeException) { rollback; throw; }.

// Release the scope exactly once on any terminal signal of the admitted call (complete,
// error or cancel), before the signal propagates to the caller. Calls cancelled while still
// waiting on the serialization gate never reach this point, so they never see this hook.
return Mono.using(() -> scope, s -> body, this::releaseCallScope, true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Mono.using invokes its resource supplier per subscription, but here the supplier is the constant () -> scope for a scope that was built exactly once, in beforeAgentExecution. The cleanup is therefore not tied to the acquisition: any extra subscription of the returned Mono (an operator that re-subscribes upstream, .cache(), a retry added later) calls releaseCallScope(scope) again and decrements inFlightCalls a second time. Since the Math.max(0, …) floor was deliberately removed, the counter then goes negative and getAndIncrement() == 0 never becomes true again, permanently disarming the drift diagnostic. Would it be safer to make the release idempotent for the scope — e.g. gate it on inFlightBySlot.remove(ce.slotKey, ce) returning true, or set a one-shot flag on CallExecution?

*/
public List<String> getActiveToolGroups(String userId, String sessionId) {
String sid = (sessionId == null || sessionId.isBlank()) ? defaultSessionId : sessionId;
return getAgentState(userId, sid).getToolContext().getActivatedGroups();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This getter goes through getAgentState, unlike the setters which go through loadLatestAgentState. Two consequences worth a look: (1) with a store configured it can return a list that this node cached before another node persisted a change, while the next call() on that session activates the newer list — so getActiveToolGroups can disagree with what the model is about to see, which is the exact class of surprise this API is meant to remove; (2) as a side effect it computeIfAbsents a fresh AgentState (and a slotVersions entry) into the cache for any userId/sessionId a caller probes, so a read-only lookup can grow the per-session cache and later make saveAgentState persist an otherwise-non-existent session. Consider reloading from the store when one is configured (mirroring the setters), or stating in the Javadoc that the value is this instance's cached view.

String userId, String sessionId, List<String> groupNames, boolean active) {
Objects.requireNonNull(groupNames, "groupNames must not be null");
validateToolGroupsExist(groupNames);
if (!active && !toolkit.getConfig().isAllowToolDeletion()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This guard returns before the slot lock, so a deactivation attempted while the session has a call in flight is silently dropped with a log line rather than throwing IllegalStateException. The method Javadoc (@throws IllegalStateException if a call on this session is currently in flight) and the new wording in docs/v2/{en,zh}/docs/building-blocks/tool.md both promise the throw, so with allowToolDeletion(false) the documented contract and the behaviour diverge. Moving this check into the applyToolGroupsToSession mutation lambda (after the in-flight rejection), or carving the case out in the docs/Javadoc, would keep them consistent.

// owned by this writer (in-call meta-tool / skill activation, or the per-session
// updateToolGroups / setActiveToolGroups API); carry it over so the merge does not
// silently drop it.
baseline.getToolContext()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Under APPEND_MERGE the conversation of both writers is kept, but the activated-group list becomes strictly last-writer-wins: a concurrent updateToolGroups from another node is dropped even though its messages survive. That is a reasonable trade-off (the list is a whole-value setting), yet it is a new silent-loss path for exactly the API that exists to make activation explicit. Worth one line in the updateToolGroups Javadoc next to the existing "calls in flight on other instances cannot be detected" note, so operators on a distributed harness are not surprised.

@oss-maintainer

Copy link
Copy Markdown
Collaborator

⚠️ Merge conflict detected

This PR currently conflicts with main and cannot be merged (mergeable_state=dirty). Please rebase or merge main into fix/core-per-session-tool-groups-3167 and resolve the conflicts:

git fetch origin
git checkout fix/core-per-session-tool-groups-3167
git rebase origin/main
# resolve conflicts, then:
git push --force-with-lease

No further code changes are needed for the review itself — the last review on head de81492 still stands. This is a one-time reminder; @mention me after the conflicts are resolved and I will re-review.


Automated notification by github-manager-bot

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.

[Bug]:updateToolGroups() does not take effect on existing harnessAgent.getToolkit() instance

4 participants