Skip to content

feat(beta): add governance middleware (budget, circuit breaker, tool policy) - #2501

Closed
amabito wants to merge 18 commits into
ag2ai:mainfrom
amabito:feat/governance-middleware-v2
Closed

feat(beta): add governance middleware (budget, circuit breaker, tool policy)#2501
amabito wants to merge 18 commits into
ag2ai:mainfrom
amabito:feat/governance-middleware-v2

Conversation

@amabito

@amabito amabito commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Governance middleware for AG2 Beta: budget tracking, circuit breaker, tool policy.
Uses the on_llm_call / on_tool_execution hooks from #2439.

Context

With #2498 adding Arcade.dev (8,000+ tools), there's no built-in way to cap
LLM spend or restrict which tools an agent can call. This fills that gap.

What's in here

autogen/beta/middleware/builtin/governance.py -- 448 lines, single file.

  • Budget: tracks USD cost from ModelResponse.usage (OpenAI + Gemini key formats). Blocks calls at limit. Soft cap -- concurrent calls can overshoot slightly.
  • Circuit breaker: OPEN after N failures, HALF_OPEN probe, handles CancelledError without false-tripping.
  • Tool policy: blocklist + optional allowlist. Blocklist wins.
  • Degradation: logs at threshold, can disable expensive tools. Does not switch models.

61 tests, 893 lines. Budget, CB, policy, concurrency, adversarial, config validation.

agent = ConversableAgent(
    ...,
    middleware=[GovernanceMiddleware(GovernanceConfig(
        max_cost_usd=1.0,
        failure_threshold=3,
        blocked_tools=["rm", "eval"],
    ))],
)

CC: @Lancetnik @marklysze

@github-actions github-actions Bot added the beta label Mar 20, 2026
@amabito
amabito force-pushed the feat/governance-middleware-v2 branch from 474c174 to df043a4 Compare March 20, 2026 05:45
amabito added 16 commits March 21, 2026 15:02
…policy)

Adds GovernanceMiddleware to autogen/beta/middleware/builtin/ with:
- Budget enforcement via on_llm_call (tracks ModelResponse.usage)
- Circuit breaker (CLOSED/OPEN/HALF_OPEN with atomic probe claim)
- Tool policy (block/allowlist) via on_tool_execution
- Degradation (log + disable expensive tools near budget limit)
- Thread-safe (Lock on budget, circuit breaker, stats)

33 tests, all passing.
- BudgetState: NaN/Inf/negative validation, __setattr__ write guard,
  record_tokens() with lock, string sentinel key
- Budget middleware: .get() + isinstance check (no KeyError crash),
  getattr fallback for event.name
- Helpers: generic error content instead of repr(error)
- Policy: fail-closed on non-dict JSON args, set copy on init,
  generic PolicyViolationError message, 64KB args size guard
- Redaction: max_content_bytes ReDoS guard, tool argument redaction,
  field-preserving event copy, tool-arg redaction in on_tool_call
- Tests: refactored to pytest-asyncio, 12 new adversarial tests
- Redaction: fail-closed on copy failure (raise instead of forward unredacted)
- Policy: fail-closed on JSON parse error (DENY instead of empty args)
- BudgetState: try_consume_tool_call returns (bool, str) matching llm API
- BudgetState: _COUNTER_FIELDS as class-level frozenset (avoid per-call rebuild)
- Policy: document intentional size cap difference (64KB args vs 100KB content)
- Tests: update for try_consume_tool_call return type and fail-closed policy
- BudgetState: guard limit fields (max_tokens etc.) against post-init mutation
- BudgetState: reject non-int max_llm_calls/max_tool_calls (float boundary bug)
- Redaction: validate max_content_bytes >= 0 at init
- Policy: fail-closed on non-string arguments (bytes/bytearray)
- policy.py: use getattr(event, 'name', None) instead of event.name to
  prevent AttributeError when event has no name attribute (fail-closed
  path already handles non-str names including None)
- redaction.py: add isinstance(int)/bool guard on max_content_bytes to
  prevent float('nan') from bypassing the < 0 validation (IEEE 754:
  nan < 0 is always False)
- test_adversarial.py: add 3 tests covering the above fixes
  - test_redaction_max_content_bytes_rejects_float
  - test_redaction_max_content_bytes_rejects_bool
  - test_policy_event_missing_name_attribute_is_denied (async)

30/30 tests pass. Rounds 6+7 both CLEAN.
- ToolError.name: use generic "<denied>" to prevent tool name enumeration
- Redaction on_tool_call: emit ToolError instead of raising RuntimeError
- Policy: reject empty string tool names (fail-closed)
- BudgetState: reject bool values for limits and record_tokens
- Redaction on_llm_call: catch RuntimeError from _copy_event_with_content,
  emit ModelResponse denial instead of crashing middleware chain
- Policy: catch RecursionError/MemoryError from json.loads (fail-closed)
- Demo: update assertions to use isinstance(ToolError) after name anonymization
Codex CLI (gpt-5.4) 3-body x 6-round review-fix loop findings:

Round 1: event type names (ToolCall/ToolError, not ToolCallEvent),
  CancelledError probe leak, fallback_model documented as observation-only
Round 2: split except Exception/BaseException for probe management,
  failure_threshold NaN/Inf validation (isinstance int check)
Round 5: Gemini token key support (prompt_token_count/candidates_token_count)

Also: GovernanceConfig.__post_init__ validates all numeric fields,
  _safe_float() coerces string/None/NaN token values,
  disable_tools_on_degrade frozen to tuple,
  record() type widened to dict[str, object],
  budget soft-limit race documented in docstring,
  total_llm_calls/total_tool_calls properties exposed.

Tests: 33 -> 47 (+14). All new tests cover review findings.
Pre-commit check fix: ruff formatter adjustments
(line wrapping, blank line removal, parametrize formatting).
The v2 branch events/__init__.py exports ToolCallEvent/ToolErrorEvent
(renamed in ag2ai#2491), not ToolCall/ToolError. Local tests passed due to
stale pyc cache. CI correctly caught the import failure.
The standalone ag2_governance_middleware/ PoC was superseded by
autogen/beta/middleware/builtin/governance.py. Remove to avoid
pre-commit failures on files not part of this PR.
Covers the except BaseException path in on_llm_call -- verifies that
asyncio.CancelledError releases the HALF_OPEN probe slot without
incrementing the failure counter.
Adds test for the probe_claimed=False path in except BaseException
handler (line 409->411). governance.py now at 100% statement + branch
coverage.
Align with TelemetryMiddleware pattern (PR ag2ai#2488):
- Import GovernanceConfig + GovernanceMiddleware from .governance
- Add both to __all__ tuple
- No optional dependency guard needed (stdlib only)
- Freeze blocked_tools in GovernanceConfig.__post_init__ (consistency
  with disable_tools_on_degrade)
- Add test_governance_importable_from_builtin_init (verify __init__.py export)
- Add test_zero_budget_allows_calls (budget=0 full async path)

59 tests passing.
Consistent with disable_tools_on_degrade: all list config fields
are frozen to tuple in __post_init__ to prevent post-construction
mutation. _ToolPolicy already uses frozenset internally, but the
config object itself was mutable.

2 new tests: blocked_tools and allowed_tools are tuple after init.
61 tests passing.
@amabito
amabito force-pushed the feat/governance-middleware-v2 branch from 368b2b2 to bf44ef8 Compare March 21, 2026 06:21
@amabito
amabito marked this pull request as ready for review March 22, 2026 12:13
@amabito
amabito requested a review from Lancetnik as a code owner March 22, 2026 12:13
@amabito

amabito commented Mar 31, 2026

Copy link
Copy Markdown
Contributor Author

Splitting this into three single-responsibility middlewares to match the v2 design pattern. New PRs incoming shortly.

@amabito amabito closed this Mar 31, 2026
@codecov

codecov Bot commented Mar 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.76033% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
autogen/beta/middleware/builtin/governance.py 98.75% 2 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
autogen/beta/middleware/builtin/__init__.py 93.33% <100.00%> (+0.47%) ⬆️
autogen/beta/middleware/builtin/governance.py 98.75% <98.75%> (ø)

... and 13 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@amabito

amabito commented Mar 31, 2026

Copy link
Copy Markdown
Contributor Author

Split into three single-responsibility middlewares:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant