Skip to content

[Bug]: ACS Python tool adapters never advance tool_call_count, so a session tool-call cap fails open #4042

Description

@david-long1

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

  1. Manifest binding pre_tool_call to the stock budgets library, with max_tool_calls: 2 for the tool.
  2. snapshot = SnapshotBuilder(...).build() (or the equivalent mapping) with tool_call_count at 0, passed as snapshot= to guard_openai_client(...).
  3. Drive five tool calls through the guarded client.
  4. 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

  • I agree to follow the Microsoft Open Source Code of Conduct

Activity

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 workingneeds-review:MEDIUMContributor check flagged MEDIUM risktriageNeeds triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions