|
| 1 | +# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +"""TealTiger deterministic governance middleware. |
| 5 | +
|
| 6 | +Implements MiddlewareFactory pattern: TealTigerMiddleware is the factory (holds |
| 7 | +long-lived state like frozen agents, decisions, cumulative cost) and creates |
| 8 | +per-turn _TealTigerPerTurn instances that share a reference to the factory state. |
| 9 | +
|
| 10 | +This follows the same pattern as MetricsMiddleware/TelemetryMiddleware in |
| 11 | +ag2/middleware/builtin/. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import hashlib |
| 17 | +import time |
| 18 | +from collections.abc import Callable |
| 19 | +from typing import TYPE_CHECKING, Any |
| 20 | + |
| 21 | +if TYPE_CHECKING: |
| 22 | + from ag2 import Agent |
| 23 | + from ag2.annotations import Context |
| 24 | + from ag2.events import BaseEvent, ToolCallEvent |
| 25 | + from ag2.middleware.base import ToolExecution, ToolResultType |
| 26 | + |
| 27 | +# Import tealtiger for governance evaluation |
| 28 | +import tealtiger |
| 29 | + |
| 30 | +from ag2.events import ToolErrorEvent |
| 31 | +from ag2.extensions.tealtiger.types import ( |
| 32 | + GovernanceDecision, |
| 33 | + GovernanceMode, |
| 34 | + GovernancePolicy, |
| 35 | + TEECReceipt, |
| 36 | +) |
| 37 | +from ag2.middleware import BaseMiddleware |
| 38 | +from ag2.middleware.base import ToolResultType |
| 39 | +from ag2.utils import AGENT_CONTEXT_DEPENDENCY_KEY |
| 40 | + |
| 41 | + |
| 42 | +class TealTigerMiddleware: |
| 43 | + """Deterministic governance middleware factory for AG2. |
| 44 | +
|
| 45 | + Holds long-lived governance state (decisions, receipts, frozen agents, cost) |
| 46 | + across turns. Creates per-turn middleware instances that share this state. |
| 47 | +
|
| 48 | + This is a MiddlewareFactory — pass it directly to the agent's middleware list. |
| 49 | +
|
| 50 | + Args: |
| 51 | + policies: List of GovernancePolicy definitions. |
| 52 | + mode: Governance mode (OBSERVE, MONITOR, ENFORCE). |
| 53 | + cost_per_call: Estimated cost per tool call in USD (default: 0.002). |
| 54 | + on_decision: Optional callback invoked with each GovernanceDecision. |
| 55 | + on_receipt: Optional callback invoked with each TEECReceipt. |
| 56 | +
|
| 57 | + Example: |
| 58 | + from ag2 import Agent |
| 59 | + from ag2.extensions.tealtiger import TealTigerMiddleware, GovernancePolicy |
| 60 | +
|
| 61 | + governance = TealTigerMiddleware( |
| 62 | + policies=[ |
| 63 | + GovernancePolicy.tool_allowlist(["search", "read_*"]), |
| 64 | + GovernancePolicy.pii_block(["ssn", "credit_card"]), |
| 65 | + GovernancePolicy.cost_limit(max_per_session=5.0), |
| 66 | + ], |
| 67 | + mode="ENFORCE", |
| 68 | + ) |
| 69 | +
|
| 70 | + agent = Agent( |
| 71 | + "assistant", |
| 72 | + config=OpenAIConfig("gpt-4o-mini"), |
| 73 | + tools=[search, read_file], |
| 74 | + middleware=[governance], |
| 75 | + ) |
| 76 | +
|
| 77 | + # Long-lived state accessible on the factory |
| 78 | + governance.freeze("assistant") |
| 79 | + print(governance.decisions) |
| 80 | + print(f"Total cost: ${governance.total_cost:.4f}") |
| 81 | + """ |
| 82 | + |
| 83 | + def __init__( |
| 84 | + self, |
| 85 | + policies: list[GovernancePolicy] | None = None, |
| 86 | + mode: str | GovernanceMode = GovernanceMode.OBSERVE, |
| 87 | + cost_per_call: float = 0.002, |
| 88 | + on_decision: Callable[[GovernanceDecision], None] | None = None, |
| 89 | + on_receipt: Callable[[TEECReceipt], None] | None = None, |
| 90 | + ) -> None: |
| 91 | + self.policies = policies or [] |
| 92 | + self.mode = GovernanceMode(mode) if isinstance(mode, str) else mode |
| 93 | + self.cost_per_call = cost_per_call |
| 94 | + self.on_decision = on_decision |
| 95 | + self.on_receipt = on_receipt |
| 96 | + |
| 97 | + # Long-lived state (survives across turns) |
| 98 | + self._decisions: list[GovernanceDecision] = [] |
| 99 | + self._receipts: list[TEECReceipt] = [] |
| 100 | + self._frozen_agents: set[str] = set() |
| 101 | + self._cumulative_cost: float = 0.0 |
| 102 | + |
| 103 | + # Initialize TealTiger engine for policy evaluation |
| 104 | + self._engine = tealtiger.TealEngine( |
| 105 | + policies=[{"type": p.type, **p.config} for p in self.policies], |
| 106 | + mode=self.mode.value, |
| 107 | + ) |
| 108 | + |
| 109 | + # Compute policy digest for receipts |
| 110 | + policy_str = str(sorted((p.type, str(p.config)) for p in self.policies)) |
| 111 | + self._policy_digest = hashlib.sha256(policy_str.encode()).hexdigest()[:16] |
| 112 | + |
| 113 | + def __call__(self, event: BaseEvent, context: Context) -> BaseMiddleware: |
| 114 | + """MiddlewareFactory protocol: create per-turn middleware instance.""" |
| 115 | + return _TealTigerPerTurn(event, context, factory=self) |
| 116 | + |
| 117 | + # ─── Public API (accessible on the factory) ────────────────────────── |
| 118 | + |
| 119 | + def freeze(self, agent_name: str) -> None: |
| 120 | + """Freeze an agent — blocks all tool calls for this agent.""" |
| 121 | + self._frozen_agents.add(agent_name) |
| 122 | + |
| 123 | + def unfreeze(self, agent_name: str) -> None: |
| 124 | + """Unfreeze an agent — restores normal governance.""" |
| 125 | + self._frozen_agents.discard(agent_name) |
| 126 | + |
| 127 | + def is_frozen(self, agent_name: str) -> bool: |
| 128 | + """Check if an agent is currently frozen.""" |
| 129 | + return agent_name in self._frozen_agents |
| 130 | + |
| 131 | + @property |
| 132 | + def decisions(self) -> list[GovernanceDecision]: |
| 133 | + """All governance decisions made across all turns.""" |
| 134 | + return list(self._decisions) |
| 135 | + |
| 136 | + @property |
| 137 | + def receipts(self) -> list[TEECReceipt]: |
| 138 | + """All TEEC receipts generated across all turns.""" |
| 139 | + return list(self._receipts) |
| 140 | + |
| 141 | + @property |
| 142 | + def total_cost(self) -> float: |
| 143 | + """Cumulative cost tracked across all tool calls.""" |
| 144 | + return self._cumulative_cost |
| 145 | + |
| 146 | + @property |
| 147 | + def deny_count(self) -> int: |
| 148 | + """Number of denied decisions.""" |
| 149 | + return sum(1 for d in self._decisions if d.action == "DENY") |
| 150 | + |
| 151 | + def reset(self) -> None: |
| 152 | + """Reset all state — decisions, receipts, cost, frozen agents.""" |
| 153 | + self._decisions.clear() |
| 154 | + self._receipts.clear() |
| 155 | + self._frozen_agents.clear() |
| 156 | + self._cumulative_cost = 0.0 |
| 157 | + |
| 158 | + |
| 159 | +class _TealTigerPerTurn(BaseMiddleware): |
| 160 | + """Per-turn middleware instance — delegates governance to the factory's shared state.""" |
| 161 | + |
| 162 | + def __init__( |
| 163 | + self, |
| 164 | + event: BaseEvent, |
| 165 | + context: Context, |
| 166 | + factory: TealTigerMiddleware, |
| 167 | + ) -> None: |
| 168 | + super().__init__(event, context) |
| 169 | + self._factory = factory |
| 170 | + self._agent_name = self._get_agent_name(context) |
| 171 | + |
| 172 | + async def on_tool_execution( |
| 173 | + self, |
| 174 | + call_next: ToolExecution, |
| 175 | + event: ToolCallEvent, |
| 176 | + context: Context, |
| 177 | + ) -> ToolResultType: |
| 178 | + """Evaluate governance policies before tool execution. |
| 179 | +
|
| 180 | + Returns ToolErrorEvent for denied calls, or passes through to call_next. |
| 181 | + """ |
| 182 | + start_time = time.perf_counter() |
| 183 | + tool_name = event.name |
| 184 | + tool_args = event.arguments if hasattr(event, "arguments") else str(event.args) |
| 185 | + |
| 186 | + # Evaluate governance |
| 187 | + decision = self._evaluate(tool_name, tool_args) |
| 188 | + eval_time_ms = (time.perf_counter() - start_time) * 1000 |
| 189 | + decision.evaluation_time_ms = round(eval_time_ms, 3) |
| 190 | + |
| 191 | + # Record decision |
| 192 | + self._factory._decisions.append(decision) |
| 193 | + if self._factory.on_decision: |
| 194 | + self._factory.on_decision(decision) |
| 195 | + |
| 196 | + # Handle DENY in ENFORCE mode |
| 197 | + if self._factory.mode == GovernanceMode.ENFORCE and decision.action == "DENY": |
| 198 | + # Emit receipt for blocked execution |
| 199 | + self._emit_receipt(decision, execution_outcome="blocked") |
| 200 | + |
| 201 | + # Return ToolErrorEvent — the correct return type per the contract |
| 202 | + reason = ", ".join(decision.reason_codes) |
| 203 | + return ToolErrorEvent( |
| 204 | + call_id=event.call_id, |
| 205 | + error=Exception( |
| 206 | + f"[GOVERNANCE DENIED] Tool '{tool_name}' blocked. " |
| 207 | + f"Reason: {reason}. Decision ID: {decision.decision_id}" |
| 208 | + ), |
| 209 | + ) |
| 210 | + |
| 211 | + # Track cost for allowed calls |
| 212 | + self._factory._cumulative_cost += self._factory.cost_per_call |
| 213 | + decision.cost_tracked = self._factory.cost_per_call |
| 214 | + decision.cumulative_cost = self._factory._cumulative_cost |
| 215 | + |
| 216 | + # Execute the tool |
| 217 | + result = await call_next(event, context) |
| 218 | + |
| 219 | + # Emit receipt for executed tool |
| 220 | + outcome = "error" if isinstance(result, ToolErrorEvent) else "executed" |
| 221 | + self._emit_receipt(decision, execution_outcome=outcome) |
| 222 | + |
| 223 | + return result |
| 224 | + |
| 225 | + # ─── Private evaluation logic ──────────────────────────────────────── |
| 226 | + |
| 227 | + def _evaluate(self, tool_name: str, tool_args: Any) -> GovernanceDecision: |
| 228 | + """Evaluate governance policies against a tool call.""" |
| 229 | + action = "ALLOW" |
| 230 | + reason_codes: list[str] = [] |
| 231 | + risk_score = 0 |
| 232 | + |
| 233 | + # Check agent freeze |
| 234 | + if self._agent_name and self._factory.is_frozen(self._agent_name): |
| 235 | + action = "DENY" |
| 236 | + reason_codes.append("AGENT_FROZEN") |
| 237 | + risk_score = 100 |
| 238 | + else: |
| 239 | + # Delegate to TealTiger engine for policy evaluation |
| 240 | + engine_decision = self._factory._engine.evaluate( |
| 241 | + tool_name=tool_name, |
| 242 | + tool_args=str(tool_args), |
| 243 | + agent_id=self._agent_name or "unknown", |
| 244 | + cumulative_cost=self._factory._cumulative_cost, |
| 245 | + ) |
| 246 | + action = engine_decision.get("action", "ALLOW") |
| 247 | + reason_codes = engine_decision.get("reason_codes", []) |
| 248 | + risk_score = engine_decision.get("risk_score", 0) |
| 249 | + |
| 250 | + return GovernanceDecision( |
| 251 | + action=action, |
| 252 | + mode=self._factory.mode.value, |
| 253 | + agent_name=self._agent_name or "unknown", |
| 254 | + tool_name=tool_name, |
| 255 | + reason_codes=reason_codes or (["POLICY_ALLOW"] if action == "ALLOW" else []), |
| 256 | + risk_score=risk_score, |
| 257 | + cumulative_cost=self._factory._cumulative_cost, |
| 258 | + ) |
| 259 | + |
| 260 | + def _emit_receipt(self, decision: GovernanceDecision, execution_outcome: str) -> None: |
| 261 | + """Emit a TEEC receipt for the governance decision.""" |
| 262 | + receipt = TEECReceipt( |
| 263 | + decision_id=decision.decision_id, |
| 264 | + agent_name=decision.agent_name, |
| 265 | + tool_name=decision.tool_name, |
| 266 | + action=decision.action, |
| 267 | + execution_outcome=execution_outcome, |
| 268 | + reason_codes=decision.reason_codes, |
| 269 | + risk_score=decision.risk_score, |
| 270 | + policy_digest=self._factory._policy_digest, |
| 271 | + ) |
| 272 | + self._factory._receipts.append(receipt) |
| 273 | + if self._factory.on_receipt: |
| 274 | + self._factory.on_receipt(receipt) |
| 275 | + |
| 276 | + def _get_agent_name(self, context: Context) -> str | None: |
| 277 | + """Extract agent name from context dependencies.""" |
| 278 | + agent: Agent | None = context.dependencies.get(AGENT_CONTEXT_DEPENDENCY_KEY) |
| 279 | + if agent is not None: |
| 280 | + return agent.name |
| 281 | + return None |
0 commit comments