Repository navigation
[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
Description
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 HarnessAgentas 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,EXTERNALscope). Atool_searchtool (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 singleHarnessAgentinstance can serve thousands of concurrent users with zero cross-user data leakage."- By contrast, the v1
key-concepts.mdsays "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 perToolkitinstance.Toolkit.updateToolGroups(...)delegates to that sharedgroupManager.ReActAgentL499, when loading a session:toolkit.setActiveGroups(loaded.getToolContext().getActivatedGroups())overwrites the shared toolkit's active groups.ReActAgentL2561, after each acting step:syncToolkitToState(state)copiestoolkit.getActiveGroups()back into this session'sAgentState.saveStateToSession(L435) andconsumeSystemMsgAfterPreCall(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 sharedtoolRegistry.
Hypothesized interleaving (not yet reproduced)
Sessions A and B run concurrently on one HarnessAgent:
- A starts →
toolkit.setActiveGroups(groupsA) - B starts →
toolkit.setActiveGroups(groupsB); the shared list is nowgroupsB - A's tool calls
toolkit.updateToolGroups(["send_email"], true); the shared list is nowgroupsB + send_email - After A's acting step,
syncToolkitToState(A.state)writesgroupsB + send_emailinto 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
- Is this a known limitation? Is the "stateless singleton" guarantee intended to cover runtime changes to active groups or registered tools?
- What is the officially supported way to change the tool set per request/session on a shared agent?
- A tool method can receive
AgentState(seeToolMethodInvoker), so I could writestate.getToolContext().setActivatedGroups(...)directly. But thesyncToolkitToStatecalls 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().activatedGroupsthe single source of truth. Add an official API for tools to activate/deactivate groups against the per-call state (e.g. via injectedRuntimeContext/AgentState, or a call-scopedToolkitView). Remove thetoolkit.setActiveGroups/syncToolkitToStateround 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 howsoToolis 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.onModelCallas the supported place to filterModelCallInput.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 anonModelCallmiddleware, 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
jujn commentedon Oct 8, 2026
2.0.4 seems to have solved this problem: #3279
RICHES-2020 commentedon Oct 8, 2026
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.
ReActAgentno longer callstoolkit.setActiveGroups(...)orsyncToolkitToState(...). Reasoning now reads active groups only from the per-sessionAgentState(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):
ToolRequestConfig(viaRuntimeContext) only acceptsSchemaOnlyTool, 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.Toolkit.updateToolGroups(...)still mutates the sharedToolGroupManager. 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?Toolkit.registerAgentTool(...)at runtime still writes to the shared registry.My use case: a
tool_searchtool (vector search over a large tool catalog) that makes the matching tools callable for the rest of the current session, on a singletonHarnessAgent.Questions:
AgentStateinto the tool method and callstate.getToolContext().setActivatedGroups(...))? If so, could the docs mention it?For now I'm using one cached
HarnessAgentper session as a workaround. Happy to send a PR or a doc update if you tell me the preferred direction.RICHES-2020 commentedon Oct 8, 2026
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.
jujn commentedon Oct 8, 2026
cc @xzxiaoshan
xzxiaoshan commentedon Oct 8, 2026
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:
Yes, the singleton contract stands. Since v2.0, the baseline definition is that a single
ReActAgent/HarnessAgentinstance 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 sharedToolkit, which violated that contract.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
Toolkitstate. 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 afterupdateToolGroups) should no longer occur.The fix just shipped in v2.0.4, see in particular:
AgentRun/RunControland explicit runtime context propagation" (fix(runtime): isolate contexts and control individual agent runs #3279)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.
RICHES-2020 commentedon Oct 9, 2026
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
HarnessAgentinstance across concurrent users and sessions, withRedisAgentStateStorefor 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:
tool_searchtool with the shared agent.tool_search.tool_searchqueries Qdrant to find the relevant business tools and activates the corresponding tool groups.tool_searchreturns, 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.HarnessAgentandRedisAgentStateStore, without modifying shared mutable tool state on the singletonToolkit.2. Tool activation state across requests
There is also an important requirement concerning
AgentStatepersistence and restoration.My application persists agent state using
RedisAgentStateStore. Tool groups are dynamically activated bytool_searchduring a request.I need to understand the intended lifecycle of this activation state:
AgentStateand unintentionally affect subsequent requests.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.In other words, I need both concurrent request isolation and sequential request isolation, including when the same session's
AgentStateis 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?ToolRequestConfigappears to be supplied throughRuntimeContextbefore the agent run begins. If that is the case, how should tools discovered duringtool_searchbe added to the current run's tool view without modifying sharedToolkitstate?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 throughRedisAgentStateStore?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:
tool_searchexecutes.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.
xzxiaoshan commentedon Oct 9, 2026
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_searchtool. What exists today is (1) the OpenAI Responses hostedtool_searchserver 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-leveltool_searchat some point, but there is no concrete design or timeline yet — see C. In the meantime, yourtool_searchremains 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
AgentStateon every iteration (ReActAgentpassesstate.getToolContext().getActivatedGroups()intoToolkit.getToolSchemas(...)at each model call), and tool methods can declare aRuntimeContextparameter, which the framework injects with the live, call-scopedAgentState— the same instance the run is using. So the intended path is exactly what you suspected in your earlier comment: inside yourtool_searchtool, after querying Qdrant, activate the matching groups withTools 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
Toolkitat 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), andToolRequestConfigaccepts 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_groupsis by design a session-level switch: it is persisted withAgentState(tool_context.activated_groups) and restored with the session, so a later request on the samesessionIdinherits 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
onAgentStateReadymiddleware notification. It fires exactly once per call, after this call'sAgentState(restored from the state store or freshly created) is bound to theRuntimeContextand before reasoning starts, and receives the very instance the run will use: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
AgentStateare untouched.C. Future API.
#3298 proposes a per-request capability grant layer carried on
RuntimeContext(developer-implemented resolver, computed atcall()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.
RICHES-2020 commentedon Oct 12, 2026
Thanks @ for the detailed answer — confirming the result on my side.
Result: B is solved with
onAgentStateReadyon 2.1.0-BETA1.onAgentStateReadyis not available in v2.0.4'sMiddlewareBase, so I upgraded to 2.1.0-BETA1, where it exists.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 viaaddActivatedGroups(...), and the newly activated tools become visible in the next iteration of the same run (A also works as described).sessionId(query order + send email, then create customer): the second request no longer showssend_emailin the model's tool list.Notes for anyone hitting the same thing:
AgentStateidentity hash differs betweenonAgentStateReadyand inside the tool (contextSizealso differs, so it is a later snapshot of the same call — samesessionId/replyId). This did not matter in practice; the activation was effective.org.xerial:sqlite-jdbcexplicitly, otherwiseSqliteSessionStoragefails withNoClassDefFoundError: org/sqlite/SQLiteConfig. It may be worth documenting or declaring this dependency.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!