Skip to content

[Bug]:Toolkit active tool groups look like instance-level mutable state — how can a singleton HarnessAgent safely change the visible/registered tools per request? #3443

Closed

Description

@RICHES-2020

Title: Toolkit active tool groups look like instance-level mutable state — how can a singleton HarnessAgent safely change the visible/registered tools per request?

Summary

The 2.0 docs say a single ReActAgent/HarnessAgent instance is stateless and can safely serve many (userId, sessionId) pairs concurrently. While building a "tool search" feature (the model calls a tool_search tool, which activates or registers the matching business tools for the following reasoning iterations), I found by reading the code that the set of active tool groups appears to live on the shared Toolkit. I think this may break that guarantee whenever tool groups are changed at runtime.

Disclaimer: This comes from reading v2.0.2. I have not yet built a runnable reproduction. If I missed a guard, please point me to it.

Environment

  • agentscope-java v2.0.2 (tag), agentscope-harness
  • HarnessAgent as a singleton Spring bean, RedisAgentStateStore
  • Java 25

Use case

  • The tool catalog is large (hundreds of business tools). Exposing all schemas on every call hurts context size and tool-selection accuracy.
  • I register each business tool in its own ToolGroup (inactive, EXTERNAL scope). A tool_search tool (vector search) activates the matching groups, so they show up in the next reasoning iteration of the same call.
  • The agent is a single shared instance, as the docs recommend.

What the docs promise

  • release-notes.md / change-log.md (A.7): "A single Agent instance can safely serve multiple (userId, sessionId) combinations concurrently without cross-session interference."
  • quickstart.md: calls to different sessions run in parallel on one instance.
  • harness/workspace.md: "A single HarnessAgent instance can serve thousands of concurrent users with zero cross-user data leakage."
  • By contrast, the v1 key-concepts.md says "Agent and Toolkit are stateful, the same instance cannot be called concurrently."

What the code appears to do (v2.0.2)

  • ToolGroupManager: private volatile List<String> activeGroups — one list per Toolkit instance.
  • Toolkit.updateToolGroups(...) delegates to that shared groupManager.
  • ReActAgent L499, when loading a session: toolkit.setActiveGroups(loaded.getToolContext().getActivatedGroups()) overwrites the shared toolkit's active groups.
  • ReActAgent L2561, after each acting step: syncToolkitToState(state) copies toolkit.getActiveGroups() back into this session's AgentState. saveStateToSession (L435) and consumeSystemMsgAfterPreCall (L620) do the same.
  • Reasoning (L2090) reads state.getToolContext().getActivatedGroups(), which is per session and fine. The write path goes through the shared toolkit.
  • The comment at L1477 shows the per-call-scope design is intentional for soTool ("Lives on this scope rather than the shared toolkit so concurrent structured-output calls do not collide"), but active groups don't get the same treatment.
  • Toolkit.registerAgentTool(...) (runtime tool registration) also mutates the shared toolRegistry.

Hypothesized interleaving (not yet reproduced)

Sessions A and B run concurrently on one HarnessAgent:

  1. A starts → toolkit.setActiveGroups(groupsA)
  2. B starts → toolkit.setActiveGroups(groupsB); the shared list is now groupsB
  3. A's tool calls toolkit.updateToolGroups(["send_email"], true); the shared list is now groupsB + send_email
  4. After A's acting step, syncToolkitToState(A.state) writes groupsB + send_email into A's state, which is later persisted

Result: A loses its own groups and inherits B's. B's next sync can pick up send_email from A. Serialization of same-(userId, sessionId) calls doesn't help, because the sessions differ.

I believe this only matters when groups are changed at runtime (custom tools, reset_equipped_tools). Static group configuration is not affected.

Questions

  1. Is this a known limitation? Is the "stateless singleton" guarantee intended to cover runtime changes to active groups or registered tools?
  2. What is the officially supported way to change the tool set per request/session on a shared agent?
  3. A tool method can receive AgentState (see ToolMethodInvoker), so I could write state.getToolContext().setActivatedGroups(...) directly. But the syncToolkitToState calls above would overwrite it from the shared toolkit. Is that the intended contract?

Proposed directions (happy to discuss)

  • A. Per-call tool-group state. Make AgentState.getToolContext().activatedGroups the single source of truth. Add an official API for tools to activate/deactivate groups against the per-call state (e.g. via injected RuntimeContext/AgentState, or a call-scoped ToolkitView). Remove the toolkit.setActiveGroups / syncToolkitToState round trip.
  • B. Call-scoped toolkit overlay. Let a call register extra tools (and activate groups) in a CallExecution-scoped overlay on top of the immutable shared registry, similar to how soTool is handled.
  • C. Documentation. If the intended answer is "one agent instance per session", say so in the 2.0 docs and describe the cost (Toolkit.copy() plus per-instance model/HTTP client).
  • D. Middleware. Document MiddlewareBase.onModelCall as the supported place to filter ModelCallInput.tools() per call. It carries the tool list (L2267), but it only controls visibility, not registration.

Current workarounds and their costs

  • A per-session cached HarnessAgent (each build copies the toolkit), which gives up the singleton model.
  • Filtering ModelCallInput.tools() in an onModelCall middleware, which doesn't cover runtime registration.

I'm willing to contribute a concurrent repro test (two sessions, a tool that activates a group, a latch to force the interleaving) and a PR for option A or B if the maintainers agree on a direction.

Activity

  1. jujn commented on Oct 8, 2026

    @jujn
    Collaborator

    2.0.4 seems to have solved this problem: #3279

  2. RICHES-2020 commented on Oct 8, 2026

    @RICHES-2020
    Author

    Update after checking v2.0.4 — the cross-session concern in the original description no longer applies.

    I re-read the v2.0.4 source after #3279 / #3283. ReActAgent no longer calls toolkit.setActiveGroups(...) or syncToolkitToState(...). Reasoning now reads active groups only from the per-session AgentState (scope.state.getToolContext().getActivatedGroups()). Sorry for the noise on the original claim.

    What I still can't find (based on code reading only, not run):

    1. ToolRequestConfig (via RuntimeContext) only accepts SchemaOnlyTool, i.e. external tools the framework hands back to the caller rather than executing. I don't see a way to add a framework-executed tool per request.
    2. Toolkit.updateToolGroups(...) still mutates the shared ToolGroupManager. Since the toolkit-to-state sync is gone, I believe calling it from inside a tool no longer changes which tools the model sees in that session. Is that right?
    3. Toolkit.registerAgentTool(...) at runtime still writes to the shared registry.

    My use case: a tool_search tool (vector search over a large tool catalog) that makes the matching tools callable for the rest of the current session, on a singleton HarnessAgent.

    Questions:

    • Is the intended path for a tool to write the per-session state directly (inject AgentState into the tool method and call state.getToolContext().setActivatedGroups(...))? If so, could the docs mention it?
    • Or is there a planned API for session-scoped activation of tool groups / executable tools?

    For now I'm using one cached HarnessAgent per session as a workaround. Happy to send a PR or a doc update if you tell me the preferred direction.

  3. RICHES-2020 commented on Oct 8, 2026

    @RICHES-2020
    Author

    2.0.4 seems to have solved this problem: #3279

    Thanks, confirmed: #3279/#3283 fixed the cross-session leakage I was worried about. The remaining question is only about capability: is there a supported way for a tool to activate tool groups or register framework-executed tools for the current session only? ToolRequestConfig seems to cover schema-only external tools. If this is out of scope, feel free to close.

  4. jujn commented on Oct 8, 2026

    @jujn
    Collaborator
  5. xzxiaoshan commented on Oct 8, 2026

    @xzxiaoshan
    Collaborator

    Hi @RICHES-2020 , thanks for this exceptionally well-researched report — the walkthrough of the toolkit↔state sync path is accurate and very helpful.

    To answer your questions directly:

    1. Yes, the singleton contract stands. Since v2.0, the baseline definition is that a single ReActAgent/HarnessAgent instance is stateless and safe to share across concurrent (userId, sessionId) pairs. You correctly identified that in v2.0.2, runtime mutations of active tool groups round-tripped through instance-level mutable state on the shared Toolkit, which violated that contract.

    2. This has been fixed on main. Tool runtime isolation is now implemented: tool configuration is request-scoped, so tool visibility (including activated tool groups and runtime-registered tools) is isolated per invocation instead of going through shared Toolkit state. Concurrent sessions can no longer see or inherit each other's active groups — the exact interleaving you sketched (session A picking up B's groups after updateToolGroups) should no longer occur.

    The fix just shipped in v2.0.4, see in particular:

    Please upgrade to v2.0.4 and retry your tool-search flow. If you still observe any cross-session leakage on 2.0.4, we'd very much welcome a repro test — and thanks again for the offer to contribute; feel free to open a follow-up issue or PR if anything remains.

  6. RICHES-2020 commented on Oct 9, 2026

    @RICHES-2020
    Author

    Hi, thanks for the fix in v2.0.4 and for clarifying the request-scoped tool configuration design.

    After upgrading to v2.0.4, I would like to clarify whether the following workflow is supported, or could be supported in a future release.

    1. Use case and requirements

    My application uses a single shared HarnessAgent instance across concurrent users and sessions, with RedisAgentStateStore for agent state persistence and Qdrant for dynamic tool discovery.

    There may be hundreds or thousands of business tools, so I do not want to register all tools with the agent in advance. Instead, I want the LLM to discover and activate tools dynamically during a single agent run.

    The expected workflow is:

    1. At startup, register only the tool_search tool with the shared agent.
    2. The LLM autonomously decides when it needs a specialized tool and calls tool_search.
    3. tool_search queries Qdrant to find the relevant business tools and activates the corresponding tool groups.
    4. After tool_search returns, the newly discovered tools immediately become available to the LLM in the same agent run, so the LLM can call them in subsequent iterations without restarting the agent or starting a new request.
    5. Tool visibility and activation must be isolated per invocation, so concurrent requests cannot see or inherit each other's activated tools.
    6. The solution must work with HarnessAgent and RedisAgentStateStore, without modifying shared mutable tool state on the singleton Toolkit.

    2. Tool activation state across requests

    There is also an important requirement concerning AgentState persistence and restoration.

    My application persists agent state using RedisAgentStateStore. Tool groups are dynamically activated by tool_search during a request.

    I need to understand the intended lifecycle of this activation state:

    1. During a request, the agent can activate the tool groups needed for that request.
    2. When the request finishes, these temporary tool group activations must not remain in the persisted AgentState and unintentionally affect subsequent requests.
    3. When another request arrives with the same sessionId, the agent must not inherit tool groups activated only for the previous request. It should start with the intended baseline tool visibility and discover the tools needed for the new request.
    4. Cleanup must occur before the relevant state is persisted, or the framework must otherwise ensure that transient tool activation state is excluded from persisted state.
    5. This must not remove conversation history, memory, or other state that is intentionally persistent across requests.

    In other words, I need both concurrent request isolation and sequential request isolation, including when the same session's AgentState is restored from Redis.

    3. Questions about v2.0.4 and future support

    Could you please clarify the following?

    A. Dynamic tool discovery within one agent run

    Does v2.0.4 support updating the current request-scoped tool view from inside tool_search, after the LLM has already started running, so that the newly discovered tools become callable in the next LLM iteration?

    ToolRequestConfig appears to be supplied through RuntimeContext before the agent run begins. If that is the case, how should tools discovered during tool_search be added to the current run's tool view without modifying shared Toolkit state?

    B. Tool activation state and persistence

    Does v2.0.4 guarantee that temporary tool group activations are isolated from persisted AgentState, so that they do not leak into the next request when the same session is restored through RedisAgentStateStore?

    If not, what is the recommended API or lifecycle hook to clean up or exclude transient tool activation state before persistence?

    C. Future API support

    If either capability is not currently supported, would you consider providing an officially supported API for:

    • Dynamically adding or activating tools in the current invocation after tool_search executes.
    • Isolating temporary tool activation state across both concurrent and sequential requests.
    • Ensuring transient tool activation state does not persist across requests, while preserving conversation history and other persistent agent state.

    These capabilities would allow applications to use a shared singleton agent, retrieve tools on demand, avoid exposing a large tool catalog to the LLM, and safely persist agent state across requests.

    Thank you for your work on AgentScope Java and for considering these requirements.

  7. xzxiaoshan commented on Oct 9, 2026

    @xzxiaoshan
    Collaborator

    Hi @RICHES-2020, thanks for the detailed follow-up — your reading of the v2.0.4 code is again accurate. Let me answer A / B / C directly, including what is not supported today.

    First, to set expectations correctly: the framework does not currently ship a local, built-in tool_search tool. What exists today is (1) the OpenAI Responses hosted tool_search server tool — provider-side search, tools are still all registered, only parameter schemas are deferred via @Tool(deferLoading = true); and (2) Higress gateway-side tool search. Neither covers a Qdrant-style local catalog. We do intend to provide a framework-level tool_search at some point, but there is no concrete design or timeline yet — see C. In the meantime, your tool_search remains application code, and the framework's job is to give it a safe activation path, which it does:

    A. Mid-run activation within one run — supported today, with one constraint.

    The reasoning loop resolves the tool surface from the per-session AgentState on every iteration (ReActAgent passes state.getToolContext().getActivatedGroups() into Toolkit.getToolSchemas(...) at each model call), and tool methods can declare a RuntimeContext parameter, which the framework injects with the live, call-scoped AgentState — the same instance the run is using. So the intended path is exactly what you suspected in your earlier comment: inside your tool_search tool, after querying Qdrant, activate the matching groups with

    rc.getAgentState().getToolContext().addActivatedGroups(matchingGroups);

    Tools in newly activated groups become callable in the next iteration of the same run. Activation writes only the per-session state, so concurrent requests remain isolated (v2.0.4 contract).

    The constraint: group and tool definitions must be registered on the shared Toolkit at build time. The recommended pattern is: register all catalog tools inside named tool groups and leave those groups deactivated — the model only ever sees active groups plus ungrouped tools, so a large catalog costs no context tokens, only registry memory. What is not request-scoped today: Toolkit.registerAgentTool(...) at runtime writes the shared registry (as you noted), and ToolRequestConfig accepts schema-only external tools and is supplied before the run — both of your readings are correct.

    B. Sequential isolation across requests — not automatic; reset the baseline per call.

    activated_groups is by design a session-level switch: it is persisted with AgentState (tool_context.activated_groups) and restored with the session, so a later request on the same sessionId inherits previous activations. There is currently no framework hook that strips transient activations before persistence.

    The supported way to get per-request baseline semantics is the onAgentStateReady middleware notification. It fires exactly once per call, after this call's AgentState (restored from the state store or freshly created) is bound to the RuntimeContext and before reasoning starts, and receives the very instance the run will use:

    MiddlewareBase baselineReset = new MiddlewareBase() {
        @Override
        public void onAgentStateReady(
                Agent agent, RuntimeContext ctx, AgentState state, List<Msg> inputMessages) {
            // Start every request from the intended baseline, dropping groups
            // activated by previous requests of the same session.
            state.getToolContext().setActivatedGroups(BASELINE_GROUPS);
        }
    };

    Because the reset runs inside the call lifecycle — after state restore, before the first reasoning step — no inherited activations can leak into the run, and the state persisted at the end carries only the baseline plus whatever this run legitimately activated. Conversation history, summary, and the rest of AgentState are untouched.

    C. Future API.

    #3298 proposes a per-request capability grant layer carried on RuntimeContext (developer-implemented resolver, computed at call() entry, never persisted). It targets the narrowing axis — per-user authorization boundaries — and is complementary to dynamic discovery, not a replacement for it: visible capability = grant passes AND activated.

    The two gaps you identified — request-scoped registration of framework-executed tools mid-run, and an explicit transient-vs-persistent lifetime for activation state — are not covered by #3298. As for a local tool_search: we would like to ship one eventually, but there is no concrete plan yet, so I don't want to promise an API shape or a date.

    Thanks again for the thorough write-up — it is very helpful for understanding where the framework needs to grow.

  8. RICHES-2020 commented on Oct 12, 2026

    @RICHES-2020
    Author

    Thanks @ for the detailed answer — confirming the result on my side.

    Result: B is solved with onAgentStateReady on 2.1.0-BETA1.

    • onAgentStateReady is not available in v2.0.4's MiddlewareBase, so I upgraded to 2.1.0-BETA1, where it exists.
    • I reset the activated groups there with state.getToolContext().setActivatedGroups(List.of()). Each request now starts from the baseline, and the groups activated by the previous request on the same session no longer leak in.
    • tool_search (our own Qdrant-backed tool) activates groups via addActivatedGroups(...), and the newly activated tools become visible in the next iteration of the same run (A also works as described).
    • Verified with two consecutive requests on the same sessionId (query order + send email, then create customer): the second request no longer shows send_email in the model's tool list.

    Notes for anyone hitting the same thing:

    1. The AgentState identity hash differs between onAgentStateReady and inside the tool (contextSize also differs, so it is a later snapshot of the same call — same sessionId/replyId). This did not matter in practice; the activation was effective.
    2. Upgrading to 2.1.0-BETA1 on Windows required adding org.xerial:sqlite-jdbc explicitly, otherwise SqliteSessionStorage fails with NoClassDefFoundError: org/sqlite/SQLiteConfig. It may be worth documenting or declaring this dependency.
    3. Conversation history still contains earlier tool_search / tool calls, so the model may be tempted to skip re-searching. Prompt wording helps; I'm keeping an eye on it.

    The remaining gaps from C (a built-in local tool_search, and an explicit transient-vs-persistent lifetime for activation state) are still nice-to-haves for me, but they no longer block this use case, so I'm closing this issue. Thanks again!

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions