opshield 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opshield/__init__.py +53 -0
- opshield/__main__.py +3 -0
- opshield/action.py +50 -0
- opshield/approval.py +275 -0
- opshield/async_shield.py +195 -0
- opshield/budget.py +76 -0
- opshield/capabilities.py +211 -0
- opshield/chaos.py +193 -0
- opshield/circuitbreaker.py +150 -0
- opshield/cli.py +117 -0
- opshield/compliance.py +306 -0
- opshield/config.py +91 -0
- opshield/dashboard.py +212 -0
- opshield/forecast.py +174 -0
- opshield/integrations/__init__.py +1 -0
- opshield/integrations/anthropic_sdk.py +119 -0
- opshield/integrations/autogen.py +116 -0
- opshield/integrations/crewai.py +115 -0
- opshield/integrations/langchain.py +127 -0
- opshield/integrations/openai_agents.py +138 -0
- opshield/logger.py +112 -0
- opshield/masking.py +185 -0
- opshield/metrics.py +159 -0
- opshield/multiagent.py +160 -0
- opshield/policy.py +126 -0
- opshield/ratelimit.py +109 -0
- opshield/replay.py +187 -0
- opshield/retry.py +106 -0
- opshield/rules.py +116 -0
- opshield/scoring.py +229 -0
- opshield/shield.py +474 -0
- opshield/snapshot.py +67 -0
- opshield/tracing.py +256 -0
- opshield/webhooks.py +113 -0
- opshield-0.2.0.dist-info/METADATA +469 -0
- opshield-0.2.0.dist-info/RECORD +39 -0
- opshield-0.2.0.dist-info/WHEEL +4 -0
- opshield-0.2.0.dist-info/entry_points.txt +2 -0
- opshield-0.2.0.dist-info/licenses/LICENSE +21 -0
opshield/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""OpShield — rollback, cost control, and testing for AI agents."""
|
|
2
|
+
|
|
3
|
+
from opshield.shield import OpShield
|
|
4
|
+
from opshield.action import Action, ActionResult, RiskLevel
|
|
5
|
+
from opshield.approval import ApprovalManager
|
|
6
|
+
from opshield.async_shield import AsyncOpShield
|
|
7
|
+
from opshield.capabilities import CapabilityManager
|
|
8
|
+
from opshield.chaos import ChaosEngine, FaultType
|
|
9
|
+
from opshield.circuitbreaker import CircuitBreaker, CircuitState
|
|
10
|
+
from opshield.compliance import ComplianceReporter
|
|
11
|
+
from opshield.forecast import CostForecaster
|
|
12
|
+
from opshield.logger import ActionLogger
|
|
13
|
+
from opshield.masking import DataMasker
|
|
14
|
+
from opshield.multiagent import AgentPool
|
|
15
|
+
from opshield.policy import load_policy, validate_policy
|
|
16
|
+
from opshield.ratelimit import RateLimiter, ToolLimit
|
|
17
|
+
from opshield.replay import ActionReplayer
|
|
18
|
+
from opshield.retry import RetryPolicy
|
|
19
|
+
from opshield.scoring import AgentScorer, PoolScorer
|
|
20
|
+
from opshield.tracing import Tracer
|
|
21
|
+
from opshield.webhooks import EventNotifier
|
|
22
|
+
from opshield.config import rules_from_config
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"OpShield",
|
|
26
|
+
"AsyncOpShield",
|
|
27
|
+
"AgentPool",
|
|
28
|
+
"AgentScorer",
|
|
29
|
+
"ApprovalManager",
|
|
30
|
+
"Action",
|
|
31
|
+
"ActionResult",
|
|
32
|
+
"CapabilityManager",
|
|
33
|
+
"ChaosEngine",
|
|
34
|
+
"CircuitBreaker",
|
|
35
|
+
"CircuitState",
|
|
36
|
+
"ComplianceReporter",
|
|
37
|
+
"CostForecaster",
|
|
38
|
+
"FaultType",
|
|
39
|
+
"PoolScorer",
|
|
40
|
+
"RiskLevel",
|
|
41
|
+
"ActionLogger",
|
|
42
|
+
"ActionReplayer",
|
|
43
|
+
"DataMasker",
|
|
44
|
+
"RateLimiter",
|
|
45
|
+
"ToolLimit",
|
|
46
|
+
"RetryPolicy",
|
|
47
|
+
"Tracer",
|
|
48
|
+
"EventNotifier",
|
|
49
|
+
"load_policy",
|
|
50
|
+
"validate_policy",
|
|
51
|
+
"rules_from_config",
|
|
52
|
+
]
|
|
53
|
+
__version__ = "0.2.0"
|
opshield/__main__.py
ADDED
opshield/action.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Action model — represents a single agent action with metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RiskLevel(Enum):
|
|
13
|
+
LOW = "low"
|
|
14
|
+
MEDIUM = "medium"
|
|
15
|
+
HIGH = "high"
|
|
16
|
+
CRITICAL = "critical"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Action:
|
|
21
|
+
"""A single action an agent wants to perform."""
|
|
22
|
+
|
|
23
|
+
tool_name: str
|
|
24
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
26
|
+
timestamp: float = field(default_factory=time.time)
|
|
27
|
+
risk: RiskLevel = RiskLevel.LOW
|
|
28
|
+
cost: float = 0.0
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def description(self) -> str:
|
|
32
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in self.args.items())
|
|
33
|
+
return f"{self.tool_name}({args_str})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ActionResult:
|
|
38
|
+
"""The result of executing an action."""
|
|
39
|
+
|
|
40
|
+
action: Action
|
|
41
|
+
output: Any = None
|
|
42
|
+
error: str | None = None
|
|
43
|
+
blocked: bool = False
|
|
44
|
+
block_reason: str | None = None
|
|
45
|
+
snapshot_id: str | None = None
|
|
46
|
+
cost: float = 0.0
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def success(self) -> bool:
|
|
50
|
+
return not self.blocked and self.error is None
|
opshield/approval.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Human-in-the-loop approval workflows for AI agent actions.
|
|
2
|
+
|
|
3
|
+
Provides configurable approval gates that pause execution until a human
|
|
4
|
+
approves or denies the action. Supports timeout with auto-deny, callback-
|
|
5
|
+
based approval, and escalation chains.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
approver = ApprovalManager(
|
|
9
|
+
default_timeout=30.0,
|
|
10
|
+
on_approval_request=my_callback,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Register tool-specific policies
|
|
14
|
+
approver.require_approval("send_email", timeout=60.0)
|
|
15
|
+
approver.require_approval("delete_record", auto_deny=True)
|
|
16
|
+
|
|
17
|
+
# Check in shield flow
|
|
18
|
+
decision = approver.request_approval(action)
|
|
19
|
+
if decision.approved:
|
|
20
|
+
execute(action)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import Enum
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApprovalDecision(Enum):
|
|
32
|
+
APPROVED = "approved"
|
|
33
|
+
DENIED = "denied"
|
|
34
|
+
TIMEOUT = "timeout"
|
|
35
|
+
ESCALATED = "escalated"
|
|
36
|
+
NOT_REQUIRED = "not_required"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ApprovalResult:
|
|
41
|
+
decision: ApprovalDecision
|
|
42
|
+
tool_name: str
|
|
43
|
+
approver: str | None = None
|
|
44
|
+
reason: str | None = None
|
|
45
|
+
elapsed: float = 0.0
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def approved(self) -> bool:
|
|
49
|
+
return self.decision in (
|
|
50
|
+
ApprovalDecision.APPROVED,
|
|
51
|
+
ApprovalDecision.NOT_REQUIRED,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ApprovalPolicy:
|
|
57
|
+
timeout: float = 30.0
|
|
58
|
+
auto_deny_on_timeout: bool = True
|
|
59
|
+
escalate_to: callable | None = None
|
|
60
|
+
required_for_risk: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ApprovalManager:
|
|
64
|
+
"""Manages human-in-the-loop approval for agent actions."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
default_timeout: float = 30.0,
|
|
69
|
+
on_approval_request: callable | None = None,
|
|
70
|
+
auto_approve_low_risk: bool = True,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._default_timeout = default_timeout
|
|
73
|
+
self._on_request = on_approval_request
|
|
74
|
+
self._auto_approve_low = auto_approve_low_risk
|
|
75
|
+
self._policies: dict[str, ApprovalPolicy] = {}
|
|
76
|
+
self._pending: dict[str, threading.Event] = {}
|
|
77
|
+
self._decisions: dict[str, ApprovalResult] = {}
|
|
78
|
+
self._history: list[ApprovalResult] = []
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
|
|
81
|
+
def require_approval(
|
|
82
|
+
self,
|
|
83
|
+
tool_name: str,
|
|
84
|
+
timeout: float | None = None,
|
|
85
|
+
auto_deny: bool = True,
|
|
86
|
+
escalate_to: callable | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self._policies[tool_name] = ApprovalPolicy(
|
|
89
|
+
timeout=timeout or self._default_timeout,
|
|
90
|
+
auto_deny_on_timeout=auto_deny,
|
|
91
|
+
escalate_to=escalate_to,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def require_approval_for_risk(
|
|
95
|
+
self,
|
|
96
|
+
risk_level: str,
|
|
97
|
+
timeout: float | None = None,
|
|
98
|
+
auto_deny: bool = True,
|
|
99
|
+
) -> None:
|
|
100
|
+
policy = ApprovalPolicy(
|
|
101
|
+
timeout=timeout or self._default_timeout,
|
|
102
|
+
auto_deny_on_timeout=auto_deny,
|
|
103
|
+
required_for_risk=risk_level.upper(),
|
|
104
|
+
)
|
|
105
|
+
self._policies[f"__risk__{risk_level.upper()}"] = policy
|
|
106
|
+
|
|
107
|
+
def needs_approval(self, tool_name: str, risk_level: str = "LOW") -> bool:
|
|
108
|
+
if tool_name in self._policies:
|
|
109
|
+
return True
|
|
110
|
+
risk_key = f"__risk__{risk_level.upper()}"
|
|
111
|
+
if risk_key in self._policies:
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def request_approval(
|
|
116
|
+
self,
|
|
117
|
+
tool_name: str,
|
|
118
|
+
args: dict | None = None,
|
|
119
|
+
risk_level: str = "LOW",
|
|
120
|
+
action_id: str | None = None,
|
|
121
|
+
) -> ApprovalResult:
|
|
122
|
+
if not self.needs_approval(tool_name, risk_level):
|
|
123
|
+
result = ApprovalResult(
|
|
124
|
+
decision=ApprovalDecision.NOT_REQUIRED,
|
|
125
|
+
tool_name=tool_name,
|
|
126
|
+
)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
policy = self._policies.get(tool_name)
|
|
130
|
+
if not policy:
|
|
131
|
+
risk_key = f"__risk__{risk_level.upper()}"
|
|
132
|
+
policy = self._policies.get(risk_key, ApprovalPolicy())
|
|
133
|
+
|
|
134
|
+
request_id = action_id or f"{tool_name}_{time.time()}"
|
|
135
|
+
|
|
136
|
+
event = threading.Event()
|
|
137
|
+
with self._lock:
|
|
138
|
+
self._pending[request_id] = event
|
|
139
|
+
|
|
140
|
+
if self._on_request:
|
|
141
|
+
self._on_request(
|
|
142
|
+
request_id=request_id,
|
|
143
|
+
tool_name=tool_name,
|
|
144
|
+
args=args or {},
|
|
145
|
+
risk_level=risk_level,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
start = time.time()
|
|
149
|
+
approved = event.wait(timeout=policy.timeout)
|
|
150
|
+
elapsed = time.time() - start
|
|
151
|
+
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._pending.pop(request_id, None)
|
|
154
|
+
|
|
155
|
+
if request_id in self._decisions:
|
|
156
|
+
result = self._decisions.pop(request_id)
|
|
157
|
+
result.elapsed = elapsed
|
|
158
|
+
elif approved:
|
|
159
|
+
result = ApprovalResult(
|
|
160
|
+
decision=ApprovalDecision.APPROVED,
|
|
161
|
+
tool_name=tool_name,
|
|
162
|
+
elapsed=elapsed,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
if policy.escalate_to:
|
|
166
|
+
try:
|
|
167
|
+
escalation = policy.escalate_to(
|
|
168
|
+
tool_name=tool_name,
|
|
169
|
+
args=args,
|
|
170
|
+
risk_level=risk_level,
|
|
171
|
+
)
|
|
172
|
+
if escalation:
|
|
173
|
+
result = ApprovalResult(
|
|
174
|
+
decision=ApprovalDecision.APPROVED,
|
|
175
|
+
tool_name=tool_name,
|
|
176
|
+
approver="escalation",
|
|
177
|
+
elapsed=elapsed,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
result = ApprovalResult(
|
|
181
|
+
decision=ApprovalDecision.ESCALATED,
|
|
182
|
+
tool_name=tool_name,
|
|
183
|
+
reason="Escalation denied",
|
|
184
|
+
elapsed=elapsed,
|
|
185
|
+
)
|
|
186
|
+
except Exception:
|
|
187
|
+
result = ApprovalResult(
|
|
188
|
+
decision=ApprovalDecision.DENIED,
|
|
189
|
+
tool_name=tool_name,
|
|
190
|
+
reason="Escalation failed",
|
|
191
|
+
elapsed=elapsed,
|
|
192
|
+
)
|
|
193
|
+
elif policy.auto_deny_on_timeout:
|
|
194
|
+
result = ApprovalResult(
|
|
195
|
+
decision=ApprovalDecision.TIMEOUT,
|
|
196
|
+
tool_name=tool_name,
|
|
197
|
+
reason=f"Approval timed out after {policy.timeout}s",
|
|
198
|
+
elapsed=elapsed,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
result = ApprovalResult(
|
|
202
|
+
decision=ApprovalDecision.DENIED,
|
|
203
|
+
tool_name=tool_name,
|
|
204
|
+
reason="No approval received",
|
|
205
|
+
elapsed=elapsed,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
self._history.append(result)
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
def approve(
|
|
212
|
+
self,
|
|
213
|
+
request_id: str,
|
|
214
|
+
approver: str | None = None,
|
|
215
|
+
reason: str | None = None,
|
|
216
|
+
) -> bool:
|
|
217
|
+
with self._lock:
|
|
218
|
+
event = self._pending.get(request_id)
|
|
219
|
+
if not event:
|
|
220
|
+
return False
|
|
221
|
+
self._decisions[request_id] = ApprovalResult(
|
|
222
|
+
decision=ApprovalDecision.APPROVED,
|
|
223
|
+
tool_name="",
|
|
224
|
+
approver=approver,
|
|
225
|
+
reason=reason,
|
|
226
|
+
)
|
|
227
|
+
event.set()
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
def deny(
|
|
231
|
+
self,
|
|
232
|
+
request_id: str,
|
|
233
|
+
approver: str | None = None,
|
|
234
|
+
reason: str | None = None,
|
|
235
|
+
) -> bool:
|
|
236
|
+
with self._lock:
|
|
237
|
+
event = self._pending.get(request_id)
|
|
238
|
+
if not event:
|
|
239
|
+
return False
|
|
240
|
+
self._decisions[request_id] = ApprovalResult(
|
|
241
|
+
decision=ApprovalDecision.DENIED,
|
|
242
|
+
tool_name="",
|
|
243
|
+
approver=approver,
|
|
244
|
+
reason=reason,
|
|
245
|
+
)
|
|
246
|
+
event.set()
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def pending_count(self) -> int:
|
|
251
|
+
with self._lock:
|
|
252
|
+
return len(self._pending)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def history(self) -> list[ApprovalResult]:
|
|
256
|
+
return list(self._history)
|
|
257
|
+
|
|
258
|
+
def stats(self) -> dict:
|
|
259
|
+
total = len(self._history)
|
|
260
|
+
approved = sum(1 for r in self._history if r.approved)
|
|
261
|
+
denied = sum(
|
|
262
|
+
1 for r in self._history
|
|
263
|
+
if r.decision == ApprovalDecision.DENIED
|
|
264
|
+
)
|
|
265
|
+
timed_out = sum(
|
|
266
|
+
1 for r in self._history
|
|
267
|
+
if r.decision == ApprovalDecision.TIMEOUT
|
|
268
|
+
)
|
|
269
|
+
return {
|
|
270
|
+
"total_requests": total,
|
|
271
|
+
"approved": approved,
|
|
272
|
+
"denied": denied,
|
|
273
|
+
"timed_out": timed_out,
|
|
274
|
+
"pending": self.pending_count,
|
|
275
|
+
}
|
opshield/async_shield.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Async version of OpShield for use with asyncio-based agent frameworks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from opshield.action import Action, ActionResult, RiskLevel
|
|
11
|
+
from opshield.shield import OpShield
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncOpShield(OpShield):
|
|
15
|
+
"""OpShield with async tool support.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
shield = AsyncOpShield(budget=5.00)
|
|
19
|
+
|
|
20
|
+
@shield.async_tool(cost=0.03)
|
|
21
|
+
async def query_db(sql: str) -> str:
|
|
22
|
+
return await db.execute(sql)
|
|
23
|
+
|
|
24
|
+
result = await query_db(sql="SELECT * FROM users")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def async_tool(
|
|
28
|
+
self,
|
|
29
|
+
fn: Callable | None = None,
|
|
30
|
+
*,
|
|
31
|
+
cost: float = 0.0,
|
|
32
|
+
risk: RiskLevel | None = None,
|
|
33
|
+
snapshot_state: Callable[[], Any] | None = None,
|
|
34
|
+
restore_state: Callable[[Any], None] | None = None,
|
|
35
|
+
) -> Callable:
|
|
36
|
+
|
|
37
|
+
def decorator(func: Callable) -> Callable:
|
|
38
|
+
tool_name = func.__name__
|
|
39
|
+
|
|
40
|
+
if snapshot_state and restore_state:
|
|
41
|
+
self._tool_states[tool_name] = {
|
|
42
|
+
"capture": snapshot_state,
|
|
43
|
+
"restore": restore_state,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@functools.wraps(func)
|
|
47
|
+
async def wrapper(*args: Any, **kwargs: Any) -> ActionResult:
|
|
48
|
+
action = Action(
|
|
49
|
+
tool_name=tool_name,
|
|
50
|
+
args=kwargs if kwargs else {"args": list(args)},
|
|
51
|
+
cost=cost,
|
|
52
|
+
)
|
|
53
|
+
return await self._async_execute(action, func, args, kwargs, cost, risk)
|
|
54
|
+
|
|
55
|
+
wrapper._shield_tool = True
|
|
56
|
+
wrapper._tool_name = tool_name
|
|
57
|
+
return wrapper
|
|
58
|
+
|
|
59
|
+
if fn is not None:
|
|
60
|
+
return decorator(fn)
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
async def _async_execute(
|
|
64
|
+
self,
|
|
65
|
+
action: Action,
|
|
66
|
+
func: Callable,
|
|
67
|
+
args: tuple,
|
|
68
|
+
kwargs: dict,
|
|
69
|
+
cost: float,
|
|
70
|
+
risk_override: RiskLevel | None,
|
|
71
|
+
) -> ActionResult:
|
|
72
|
+
detected_risk, reasons = self._rules.evaluate(action)
|
|
73
|
+
action.risk = risk_override or detected_risk
|
|
74
|
+
|
|
75
|
+
if action.risk == RiskLevel.CRITICAL:
|
|
76
|
+
result = ActionResult(
|
|
77
|
+
action=action,
|
|
78
|
+
blocked=True,
|
|
79
|
+
block_reason=f"CRITICAL risk blocked: {'; '.join(reasons)}",
|
|
80
|
+
)
|
|
81
|
+
self._history.append(result)
|
|
82
|
+
if self._logger:
|
|
83
|
+
self._logger.log(result)
|
|
84
|
+
self._notifier.emit("action_blocked", {
|
|
85
|
+
"tool": action.tool_name, "reason": result.block_reason,
|
|
86
|
+
})
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
if action.risk == RiskLevel.HIGH and self._on_block:
|
|
90
|
+
allowed = self._on_block(action, reasons)
|
|
91
|
+
if not allowed:
|
|
92
|
+
result = ActionResult(
|
|
93
|
+
action=action,
|
|
94
|
+
blocked=True,
|
|
95
|
+
block_reason=f"Blocked by user: {'; '.join(reasons)}",
|
|
96
|
+
)
|
|
97
|
+
self._history.append(result)
|
|
98
|
+
if self._logger:
|
|
99
|
+
self._logger.log(result)
|
|
100
|
+
self._notifier.emit("action_blocked", {
|
|
101
|
+
"tool": action.tool_name, "reason": result.block_reason,
|
|
102
|
+
})
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
if self._budget.would_exceed(cost):
|
|
106
|
+
result = ActionResult(
|
|
107
|
+
action=action,
|
|
108
|
+
blocked=True,
|
|
109
|
+
block_reason=f"Budget exceeded (spent: ${self._budget.spent:.2f}, "
|
|
110
|
+
f"budget: ${self._budget.budget:.2f}, action cost: ${cost:.2f})",
|
|
111
|
+
)
|
|
112
|
+
self._history.append(result)
|
|
113
|
+
if self._logger:
|
|
114
|
+
self._logger.log(result)
|
|
115
|
+
self._notifier.emit("budget_exceeded", {
|
|
116
|
+
"tool": action.tool_name, "spent": self._budget.spent,
|
|
117
|
+
"budget": self._budget.budget,
|
|
118
|
+
})
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
exceeded, rate_reason = self._rate_limiter.would_exceed(action.tool_name)
|
|
122
|
+
if exceeded:
|
|
123
|
+
result = ActionResult(
|
|
124
|
+
action=action,
|
|
125
|
+
blocked=True,
|
|
126
|
+
block_reason=rate_reason,
|
|
127
|
+
)
|
|
128
|
+
self._history.append(result)
|
|
129
|
+
if self._logger:
|
|
130
|
+
self._logger.log(result)
|
|
131
|
+
self._notifier.emit("rate_limited", {
|
|
132
|
+
"tool": action.tool_name, "reason": rate_reason,
|
|
133
|
+
})
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
snapshot_id = None
|
|
137
|
+
if self._auto_snapshot and action.tool_name in self._tool_states:
|
|
138
|
+
state = self._tool_states[action.tool_name]["capture"]()
|
|
139
|
+
snap = self._snapshots.capture(action.id, action.tool_name, state)
|
|
140
|
+
snapshot_id = snap.id
|
|
141
|
+
|
|
142
|
+
if self.is_test_mode:
|
|
143
|
+
result = ActionResult(
|
|
144
|
+
action=action,
|
|
145
|
+
output=f"[TEST MODE] Would execute: {action.description}",
|
|
146
|
+
snapshot_id=snapshot_id,
|
|
147
|
+
cost=cost,
|
|
148
|
+
)
|
|
149
|
+
self._budget.record(action.id, action.tool_name, cost)
|
|
150
|
+
self._rate_limiter.record(action.tool_name)
|
|
151
|
+
self._history.append(result)
|
|
152
|
+
if self._logger:
|
|
153
|
+
self._logger.log(result)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
if inspect.iscoroutinefunction(func):
|
|
158
|
+
output = await func(*args, **kwargs)
|
|
159
|
+
else:
|
|
160
|
+
loop = asyncio.get_event_loop()
|
|
161
|
+
output = await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
|
162
|
+
result = ActionResult(
|
|
163
|
+
action=action,
|
|
164
|
+
output=output,
|
|
165
|
+
snapshot_id=snapshot_id,
|
|
166
|
+
cost=cost,
|
|
167
|
+
)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
result = ActionResult(
|
|
170
|
+
action=action,
|
|
171
|
+
error=str(e),
|
|
172
|
+
snapshot_id=snapshot_id,
|
|
173
|
+
cost=cost,
|
|
174
|
+
)
|
|
175
|
+
self._notifier.emit("action_error", {
|
|
176
|
+
"tool": action.tool_name, "error": str(e),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
self._budget.record(action.id, action.tool_name, cost)
|
|
180
|
+
self._rate_limiter.record(action.tool_name)
|
|
181
|
+
self._history.append(result)
|
|
182
|
+
if self._logger:
|
|
183
|
+
self._logger.log(result)
|
|
184
|
+
|
|
185
|
+
if result.success:
|
|
186
|
+
self._notifier.emit("action_completed", {
|
|
187
|
+
"tool": action.tool_name, "cost": cost,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if self._budget.warning:
|
|
191
|
+
self._notifier.emit("budget_warning", {
|
|
192
|
+
"spent": self._budget.spent, "budget": self._budget.budget,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return result
|
opshield/budget.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Budget tracker for agent cost control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CostEntry:
|
|
11
|
+
"""A single cost record."""
|
|
12
|
+
|
|
13
|
+
action_id: str
|
|
14
|
+
tool_name: str
|
|
15
|
+
cost: float
|
|
16
|
+
timestamp: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BudgetTracker:
|
|
20
|
+
"""Tracks cumulative cost and enforces a budget limit."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, budget: float | None = None, warn_at: float = 0.8) -> None:
|
|
23
|
+
self._budget = budget
|
|
24
|
+
self._warn_at = warn_at
|
|
25
|
+
self._entries: list[CostEntry] = []
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def budget(self) -> float | None:
|
|
29
|
+
return self._budget
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def spent(self) -> float:
|
|
33
|
+
return sum(e.cost for e in self._entries)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def remaining(self) -> float | None:
|
|
37
|
+
if self._budget is None:
|
|
38
|
+
return None
|
|
39
|
+
return max(0.0, self._budget - self.spent)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def over_budget(self) -> bool:
|
|
43
|
+
if self._budget is None:
|
|
44
|
+
return False
|
|
45
|
+
return self.spent >= self._budget
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def warning(self) -> bool:
|
|
49
|
+
if self._budget is None:
|
|
50
|
+
return False
|
|
51
|
+
return self.spent >= self._budget * self._warn_at
|
|
52
|
+
|
|
53
|
+
def would_exceed(self, cost: float) -> bool:
|
|
54
|
+
if self._budget is None:
|
|
55
|
+
return False
|
|
56
|
+
return self.spent + cost > self._budget
|
|
57
|
+
|
|
58
|
+
def record(self, action_id: str, tool_name: str, cost: float) -> CostEntry:
|
|
59
|
+
entry = CostEntry(
|
|
60
|
+
action_id=action_id,
|
|
61
|
+
tool_name=tool_name,
|
|
62
|
+
cost=cost,
|
|
63
|
+
timestamp=time.time(),
|
|
64
|
+
)
|
|
65
|
+
self._entries.append(entry)
|
|
66
|
+
return entry
|
|
67
|
+
|
|
68
|
+
def summary(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"budget": self._budget,
|
|
71
|
+
"spent": round(self.spent, 4),
|
|
72
|
+
"remaining": round(self.remaining, 4) if self.remaining is not None else None,
|
|
73
|
+
"entries": len(self._entries),
|
|
74
|
+
"over_budget": self.over_budget,
|
|
75
|
+
"warning": self.warning,
|
|
76
|
+
}
|