Package
policy-engine
Description
SnapshotBuilder.record_tool_call (policy-engine/sdk/python/agent_control_specification/_host.py:146) is the only writer of envelope.budgets.tool_call_count, and the counter moves only when the host calls it. The tool adapters in _adapters/ (openai, anthropic, langchain, mcp, litellm, foundry, semantic_kernel, agents) never do. They take a caller-supplied snapshot mapping (_shared.py, _merge_snapshot) and hold no builder:
$ grep -rn 'tool_call_count\|record_tool_call\|SnapshotBuilder' policy-engine/sdk/python/agent_control_specification/_adapters/
(no output, main @ e7f5d2b)
So on the adapter path the snapshot a policy sees carries whatever tool_call_count the host passed at wrap time, on every call, unless the host writes its own per-call bookkeeping loop around the adapter. A manifest bound to the stock budget gate (policy/lib/budgets.rego max_tool_calls_exceeded, or budget_tool_calls_exceeded in the Cedar mirror) then never trips. There is no error, warning, or evidence entry. For a budget control that is fail-open.
Proposed fix
Have the adapters advance the counter on the governed path: after a permitted pre_tool_call, increment tool_call_count, so the policy deciding call N reads the count as of N-1 and a denied call never inflates the budget it just breached. That means the adapters hold (or are handed) the SnapshotBuilder rather than a frozen mapping.
If the intended contract is that the host must call record_tool_call, the adapter docstrings and the budgets library README should say so, and the adapter could refuse a snapshot that carries budgets when no writer is wired.
I can send a PR for the adapter-side increment if that direction works for you.
How does this impact your work?
I wrap a client with an adapter, bind the stock budgets library with a per-session tool cap, and the cap never fires. I had to move the increment into my own wrapper around the adapter to get the control to work at all.
Timeline
Not blocking; I have a downstream workaround.
Steps to Reproduce
- Manifest binding
pre_tool_call to the stock budgets library, with max_tool_calls: 2 for the tool.
snapshot = SnapshotBuilder(...).build() (or the equivalent mapping) with tool_call_count at 0, passed as snapshot= to guard_openai_client(...).
- Drive five tool calls through the guarded client.
- Expected: calls 3, 4 and 5 denied with
budget_tool_calls_exceeded. Actual: all five allowed; the snapshot's tool_call_count is still 0 after the run.
Environment
agent-control-specification at main e7f5d2b (also 0.3.1b1 from PyPI), Python 3.12, macOS.
Logs / Error Output
None: the failure is silent, which is the problem.
Code of Conduct
Package
policy-engine
Description
SnapshotBuilder.record_tool_call(policy-engine/sdk/python/agent_control_specification/_host.py:146) is the only writer ofenvelope.budgets.tool_call_count, and the counter moves only when the host calls it. The tool adapters in_adapters/(openai, anthropic, langchain, mcp, litellm, foundry, semantic_kernel, agents) never do. They take a caller-suppliedsnapshotmapping (_shared.py,_merge_snapshot) and hold no builder:So on the adapter path the snapshot a policy sees carries whatever
tool_call_countthe host passed at wrap time, on every call, unless the host writes its own per-call bookkeeping loop around the adapter. A manifest bound to the stock budget gate (policy/lib/budgets.regomax_tool_calls_exceeded, orbudget_tool_calls_exceededin the Cedar mirror) then never trips. There is no error, warning, or evidence entry. For a budget control that is fail-open.Proposed fix
Have the adapters advance the counter on the governed path: after a permitted
pre_tool_call, incrementtool_call_count, so the policy deciding call N reads the count as of N-1 and a denied call never inflates the budget it just breached. That means the adapters hold (or are handed) theSnapshotBuilderrather than a frozen mapping.If the intended contract is that the host must call
record_tool_call, the adapter docstrings and the budgets library README should say so, and the adapter could refuse a snapshot that carriesbudgetswhen no writer is wired.I can send a PR for the adapter-side increment if that direction works for you.
How does this impact your work?
I wrap a client with an adapter, bind the stock budgets library with a per-session tool cap, and the cap never fires. I had to move the increment into my own wrapper around the adapter to get the control to work at all.
Timeline
Not blocking; I have a downstream workaround.
Steps to Reproduce
pre_tool_callto the stock budgets library, withmax_tool_calls: 2for the tool.snapshot = SnapshotBuilder(...).build()(or the equivalent mapping) withtool_call_countat 0, passed assnapshot=toguard_openai_client(...).budget_tool_calls_exceeded. Actual: all five allowed; the snapshot'stool_call_countis still 0 after the run.Environment
agent-control-specification at main e7f5d2b (also 0.3.1b1 from PyPI), Python 3.12, macOS.
Logs / Error Output
Code of Conduct