GroupActivationGuard: a ToolMiddlewareBase that checks a reset_tools call before AgentScope applies it #2287
auxiliar-ag
started this conversation in
Show and tell
Replies: 1 comment
|
Thanks for the detailed write-up and for sharing the implementation. You're right about Also good to see |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
I built a small middleware for AgentScope's
reset_toolsmeta tool, and this is a walkthrough of it. The middleware module is about 140 lines, it uses only public extension points, and it exists because of one detail in how 2.0.6 applies an activation call.GroupActivationGuardwrapsreset_toolsand does three things:It is built on
ToolMiddlewareBase— the tool-level onion middleware added in #1754 — plusRegisteredToolandToolkit.builtin_meta_tool. No monkey-patching and no private attributes.Everything below is against
agentscope==2.0.6, the current stable release, published 2026-08-07, git tagv2.0.6→ commit29b5923, on Python 3.11. I checked the installed files against a freshly downloaded wheel and sdist and against the tagged sources; all 397 modules in the wheel are byte-identical on disk.What it does
The same three-turn script twice. The model activates two groups, then misspells one of them on the second call.
web_serchis not a registered group.Left: one transposed letter, and for the rest of the run the model is looking at a tool list with both capability groups gone. Right: the call is refused, the model is told which names actually exist, and nothing moves. Both runs go through a real
Agentwith a scripted offline model.Why it needs to exist
Three pieces of 2.0.6 behaviour, in the order they matter.
Activation lives on the agent state, and starts empty. It is not on the
Toolkit:That is
ToolContextinagentscope/state/_state.py. So a tool in a non-basic group is invisible on turn one and stays invisible until its group is activated. Within the framework the only code that writes that list is the meta tool (application code can of course mutate it directly).The meta tool arms itself.
Toolkit._get_available_toolsincludes it on this condition:Toolkit.__init__always prepends a"basic"group, so the first clause is unreachable and the condition reduces to "more than one group registered". Register one non-basic group andreset_toolsappears in the model's schema list;ResetTools.check_permissionsreturns ALLOW unconditionally, with the docstring "The meta tool is always allowed to be called."That is worth saying plainly, because I had assumed otherwise before reading it: tool groups are not a restriction. They are a context-window budget the model administers. Anything in a group, the model can switch on. Anything that must be genuinely unreachable belongs behind
check_permissions, not behind group membership. (Whether the arming should be opt-out has history — #974 raised theenable_meta_toolinteraction back in 1.x, and that parameter no longer exists in 2.0.6.)The call is applied before it is checked. This is
ResetTools.call, fromagentscope/tool/_builtin/_meta.py:clear()is the first statement; validation is in the loop after it. Three consequences:It is a full reset, not a delta — every group not named is deactivated. #959 asked exactly this question in 2025 and the answer is that the booleans are the final state; the schema defaults every group to
falseaccordingly.A refused argument still empties the list — and the returned
ToolChunkcarries no error state, so the accumulatedToolResponsereadssuccesswhile its text explains the arguments were bad.An unknown name is appended unvalidated —
to_activate.append(key)never checkskeyagainst the registered groups, and the schemacreate_modelgenerates has noadditionalProperties, sojsonschema.validateaccepts it.That last one is the case the guard is really for, because it is the one the agent path does not catch. Through
Agent, a wrong type is rejected by schema validation beforeResetTools.callruns. A wrong name is not.Worth noting for anyone reading this later: open PR #2222 adds JSON-Schema-driven argument coercion to
ToolBase.__call__andToolkit.call_tool, and its boolean rule maps"true","1","yes"and0/1onto real booleans. If it merges, most wrong-type arguments get repaired upstream and that branch largely stops firing. It does not touch the wrong-name case — coercion is keyed on the schema, and an unregistered key has no schema entry to coerce against — nor does it change the clear-before-validate ordering.How it works
ToolBasetakes a list ofToolMiddlewareBasethat wrap execution in an onion, and a middleware that declines to callnext_handlerstops the tool from running at all. That is the whole trick: the damage is done by the tool's first statement, so the check has to happen before delegation rather than around it.ResetTools.is_state_injectedisTrue, so AgentScope puts theAgentStateinto the same kwargs the middleware receives. The guard filters that key out, plans the transition, and only then delegates:The planner is pure and reports both problem kinds at once, so a model can fix everything in one retry instead of discovering the second error after the first:
A refusal names the argument, never its value — a model can put anything in that slot, and copying it back into the transcript is a habit worth not acquiring.
Installing it means rebuilding the meta tool.
Toolkitconstructs its ownResetToolswith no middlewares, and there is no public hook to add one afterwards:groups=toolkit.tool_groupsis load-bearing.ResetTools.groupsis not a copy — it is the same list object the toolkit holds, andinput_schemais a property that re-reads it on every access. Passing the toolkit's own list keeps a group registered later visible to the meta tool with no re-registration step; passing a copy would freeze the schema. The same aliasing is why the guard takes a callable for its known-group set rather than a snapshot.What it does not do
It is a correctness guard, not a boundary.
reset_toolsstays armed and the model can still activate any registered group whenever it wants — that is the design, and the guard does not change it. If a capability must be genuinely unreachable, the enforcement belongs in the tool's owncheck_permissionsor in a middleware on that tool, not in group membership.It covers the meta tool only. I have not tried it against MCP-backed groups, where
_get_available_toolsswallows a failinglist_toolswith a log warning and drops that server's tools — a group can be active and empty, which the guard has no view of. Skills attached to groups are untested too. And it is pinned to 2.0.6: #1055 and #1035 are both open about how tools and skills should be disclosed to a model, so this area may well move.About this post
I work at NativePort, which publishes runnable per-provider examples for web-access APIs; that link carries campaign parameters so I can tell whether this was worth writing. There is no official NativePort–AgentScope integration, and nothing here is endorsed by or affiliated with AgentScope or its maintainers.
On provenance, since it shapes how much weight to give the above: I did not hit this in production. I wanted to know whether AgentScope tool groups could be used as a schema firewall — a way to keep a capability out of a model's reach — so I read the 2.0.6 source and wrote tests against it. The answer is no, and the guard is what I built once that was clear. The supporting code lives in a local repository that is not published, so the fences above are what this post contains — there is no repository link to follow. Behind them are 188 tests: a characterization suite pinning each 2.0.6 behaviour described here, so a future release that changes any of it breaks a test rather than this text, and an end-to-end suite driving a real
Agentwith a scripted offline model, no provider involved. I drafted this with AI assistance and verified every claim against the installed source myself. I post about tool-calling internals across several agent frameworks; this is the first thing I have posted here.One thing I could not settle from the source. Given that
reset_toolsis armed automatically and its permission check is a hardcoded ALLOW, is the intended contract that tool groups are purely a context-management device — with any real restriction expected atcheck_permissions— or would an opt-out for callers who want to driveactivated_groupsthemselves be welcome? I could not find a supported way to register groups without also handing the model the switch, and I would rather build with the grain than around it.All reactions