Skip to content

Commit 5984625

Browse files
committed
feat(beta): add ToolPolicyMiddleware.update_policy for runtime swaps
Address @Lancetnik follow-up on #2533: the dynamic-policy justification needs a public API to be useful. Adds: - ToolPolicyMiddleware.update_policy(config: ToolPolicyConfig) Atomically replaces the active policy. Subsequent tool calls see the new config; per-invocation instances already inside on_tool_execution keep their captured snapshot, so in-flight executions are never torn across a config change. - ToolPolicyMiddleware.config property Read-only access to the active ToolPolicyConfig. Intended hooks: rate-limit kill switches, per-tenant rule updates from an ops dashboard, role changes during a long-running session -- all without rebuilding the agent. Thread-safety: _policy is assigned last so readers in __call__ never observe a state where _config is new but _policy is stale (Python attribute assignment is atomic for single references). Tests: 19/19 pass, covering update-then-permit, in-flight-snapshot isolation, restriction clearing, and the config property.
1 parent fe072f5 commit 5984625

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

autogen/beta/middleware/builtin/tool_policy.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ class ToolPolicyMiddleware(MiddlewareFactory):
7777
retained across invocations. To observe denied calls, pass an
7878
``on_blocked`` callback when constructing the middleware.
7979
80+
The policy can be replaced at runtime via :meth:`update_policy` --
81+
useful for rate-limit kill switches, per-tenant rules driven by an
82+
ops dashboard, or role changes that land mid-session without
83+
rebuilding the agent.
84+
8085
Example::
8186
8287
def audit(call: ToolCallEvent, reason: str) -> None:
@@ -89,6 +94,9 @@ def audit(call: ToolCallEvent, reason: str) -> None:
8994
on_blocked=audit,
9095
)
9196
agent = MyAgent(middleware=[mw])
97+
98+
# Later, swap the policy without rebuilding the agent:
99+
mw.update_policy(ToolPolicyConfig(blocked_tools=["delete_all", "shell_exec"]))
92100
"""
93101

94102
def __init__(
@@ -104,6 +112,34 @@ def __init__(
104112
self._policy = _ToolPolicy(self._config)
105113
self._on_blocked = on_blocked
106114

115+
@property
116+
def config(self) -> ToolPolicyConfig:
117+
"""Return the currently active policy configuration."""
118+
return self._config
119+
120+
def update_policy(self, config: ToolPolicyConfig) -> None:
121+
"""Atomically replace the tool policy at runtime.
122+
123+
Subsequent tool calls use the new policy. Calls that have already
124+
entered ``on_tool_execution`` continue with the policy snapshot
125+
their per-invocation instance captured at construction time, so
126+
in-flight executions are never torn across a configuration change.
127+
128+
This is the intended hook for dynamic policy sources: rate-limit
129+
kill switches, per-tenant rule updates from an ops dashboard,
130+
role changes during a long-running session, etc.
131+
132+
Args:
133+
config: The new :class:`ToolPolicyConfig` to enforce. Passing
134+
a fresh config with defaults (empty blocklist, no allowlist)
135+
effectively clears all restrictions.
136+
"""
137+
new_policy = _ToolPolicy(config)
138+
# Assign _policy last so readers in __call__ never observe a state
139+
# where _config is new but _policy is stale.
140+
self._config = config
141+
self._policy = new_policy
142+
107143
def __call__(self, event: "BaseEvent", context: "Context") -> "BaseMiddleware":
108144
return _ToolPolicyInstance(event, context, self._policy, self._on_blocked)
109145

test/beta/middleware/builtins/test_tool_policy_middleware.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,71 @@ async def call_next(event: ToolCallEvent, context: mock.MagicMock) -> ToolResult
239239
assert recorded == []
240240
assert result is good_result
241241

242+
@pytest.mark.asyncio()
243+
async def test_update_policy_takes_effect_on_next_call(self) -> None:
244+
# Given a middleware that initially allows "search" only
245+
factory = ToolPolicyMiddleware(allowed_tools=["search"])
246+
ctx = _make_context()
247+
initial_event = _make_event()
248+
249+
async def call_next(event: ToolCallEvent, context: mock.MagicMock) -> ToolResultEvent:
250+
return ToolResultEvent(parent_id=event.id, name=event.name, result=ToolResult("ok"))
251+
252+
# When "shell_exec" is attempted before the update -- denied
253+
instance_before = factory(initial_event, ctx)
254+
result_before = await instance_before.on_tool_execution(
255+
call_next, ToolCallEvent(id="c1", name="shell_exec"), ctx
256+
)
257+
assert isinstance(result_before, ToolErrorEvent)
258+
259+
# And the policy is replaced at runtime to allow shell_exec
260+
factory.update_policy(ToolPolicyConfig(allowed_tools=["search", "shell_exec"]))
261+
262+
# Then a new instance picks up the new policy and permits the call
263+
instance_after = factory(initial_event, ctx)
264+
result_after = await instance_after.on_tool_execution(call_next, ToolCallEvent(id="c2", name="shell_exec"), ctx)
265+
assert not isinstance(result_after, ToolErrorEvent)
266+
assert factory.config.allowed_tools == ("search", "shell_exec")
267+
268+
@pytest.mark.asyncio()
269+
async def test_update_policy_in_flight_instance_uses_snapshot(self) -> None:
270+
# Given a factory that allows "search" and an instance constructed under that policy
271+
factory = ToolPolicyMiddleware(allowed_tools=["search"])
272+
ctx = _make_context()
273+
instance = factory(_make_event(), ctx) # snapshot taken here
274+
275+
async def call_next(event: ToolCallEvent, context: mock.MagicMock) -> ToolResultEvent:
276+
return ToolResultEvent(parent_id=event.id, name=event.name, result=ToolResult("ok"))
277+
278+
# When the factory policy is swapped to allow everything mid-flight
279+
factory.update_policy(ToolPolicyConfig())
280+
281+
# Then the previously constructed instance still enforces the old snapshot
282+
result = await instance.on_tool_execution(call_next, ToolCallEvent(id="c1", name="shell_exec"), ctx)
283+
assert isinstance(result, ToolErrorEvent)
284+
285+
def test_update_policy_clears_restrictions(self) -> None:
286+
# Given a middleware with both blocked and allowed lists
287+
factory = ToolPolicyMiddleware(blocked_tools=["x"], allowed_tools=["y"])
288+
289+
# When the policy is replaced with an empty config
290+
factory.update_policy(ToolPolicyConfig())
291+
292+
# Then check() permits any tool name
293+
allowed, reason = factory._policy.check("anything")
294+
assert allowed
295+
assert reason == ""
296+
assert factory.config.blocked_tools == ()
297+
assert factory.config.allowed_tools is None
298+
299+
def test_config_property_returns_active_config(self) -> None:
300+
# Given a middleware
301+
factory = ToolPolicyMiddleware(blocked_tools=["delete_all"])
302+
303+
# Then config property exposes the active ToolPolicyConfig
304+
assert isinstance(factory.config, ToolPolicyConfig)
305+
assert factory.config.blocked_tools == ("delete_all",)
306+
242307
@pytest.mark.asyncio()
243308
async def test_no_callback_means_no_error(self) -> None:
244309
# Given a middleware without an on_blocked callback

0 commit comments

Comments
 (0)