agentfences 0.1.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.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentfences
3
+ Version: 0.1.0
4
+ Summary: Runtime governance for AI agents — budget limits, loop protection, token limits, audit trail
5
+ Home-page: https://github.com/bhaviksardar/fences
6
+ Author: Bhavik Sardar
7
+ Keywords: ai,agents,governance,llm,budget,safety,token
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: requests>=2.25.0
15
+ Dynamic: author
16
+ Dynamic: classifier
17
+ Dynamic: description
18
+ Dynamic: description-content-type
19
+ Dynamic: home-page
20
+ Dynamic: keywords
21
+ Dynamic: requires-dist
22
+ Dynamic: requires-python
23
+ Dynamic: summary
24
+
25
+ # Fences
26
+
27
+ Runtime governance for AI agents. Give any agent budget limits, loop protection, and a decision audit trail in three lines of code.
28
+
29
+ ```python
30
+ import fences
31
+ fences.init(local_only=True)
32
+
33
+ @fences.governed(budget_usd=0.50, max_iterations=20, max_duration_ms=60_000)
34
+ async def run_agent(query: str):
35
+ response = call_llm(query)
36
+ fences.log_decision(reasoning="searching for sources", action="web_search")
37
+ await fences.checkpoint(cost_delta_usd=compute_cost(response))
38
+ return response
39
+ ```
40
+
41
+ If the agent exceeds its budget, loops past the iteration limit, or runs too long, `checkpoint()` raises and execution stops immediately.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install fences
47
+ ```
48
+
49
+ ## Quickstart — no backend needed
50
+
51
+ ```python
52
+ import fences
53
+ from fences import governed, checkpoint, log_decision
54
+ from fences import BudgetExceeded, IterationLimitReached
55
+
56
+ fences.init(local_only=True)
57
+
58
+ @governed(budget_usd=0.10, max_iterations=10)
59
+ async def my_agent(query: str):
60
+ for step in range(100):
61
+ log_decision(reasoning=f"step {step}: searching", action="search")
62
+ await checkpoint(cost_delta_usd=0.01)
63
+ return "done"
64
+ ```
65
+
66
+ ## What gets enforced
67
+
68
+ | Limit | Parameter | Raises |
69
+ |---|---|---|
70
+ | Spend | `budget_usd` | `BudgetExceeded` |
71
+ | Iterations | `max_iterations` | `IterationLimitReached` |
72
+ | Duration | `max_duration_ms` | `TimeLimitReached` |
73
+
74
+ ## Handling breaches
75
+
76
+ ```python
77
+ from fences import BudgetExceeded, IterationLimitReached, TimeLimitReached
78
+
79
+ try:
80
+ result = await my_agent("research this topic")
81
+ except BudgetExceeded as e:
82
+ print(f"Stopped: spent ${e.spent_usd:.4f} of ${e.budget_usd:.4f}")
83
+ except IterationLimitReached as e:
84
+ print(f"Stopped: {e.iterations} iterations reached")
85
+ except TimeLimitReached as e:
86
+ print(f"Stopped: ran for {e.duration_ms}ms")
87
+ ```
88
+
89
+ ## Cloud mode
90
+
91
+ Connect to a Fences backend for persistent audit trails, a dashboard, and server-authoritative enforcement across distributed agents.
92
+
93
+ ```python
94
+ fences.init(api_key="fc_...", endpoint="https://your-fences-instance.com")
95
+ ```
96
+
97
+ Everything else stays the same — same decorator, same `checkpoint()` calls.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,8 @@
1
+ fences/__init__.py,sha256=qQg57Xvqjr3Kni_MIpHrXj74gUR-cq6KoxbvfAuOJok,376
2
+ fences/client.py,sha256=eR9xjN42xeOPlJwp1x6XYLy4dV_SIGKQyuCVv38Ewbg,2242
3
+ fences/core.py,sha256=Jk09NrxLLPr_LfXkK2_aOMLtrliBNyjgcGcK5gJd5eI,8077
4
+ fences/exceptions.py,sha256=5nn8VgqYso7hxODr9Eebrp8HGbw_aUb1JfKBZdpz7Qo,1249
5
+ agentfences-0.1.0.dist-info/METADATA,sha256=jag8Y93jLhsZaBj4kg68xEf_Vm_Bb0lrW5D32rC_iFw,2941
6
+ agentfences-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ agentfences-0.1.0.dist-info/top_level.txt,sha256=uwq9QgEQVJftVRH4_d3gzxYkOSWL0Vb_D4CImXVrPCI,7
8
+ agentfences-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fences
fences/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .core import init, governed, checkpoint, log_decision, get_active_run
2
+ from .exceptions import FencesError, BudgetExceeded, IterationLimitReached, TimeLimitReached, TokenLimitReached
3
+
4
+ __all__ = [
5
+ "init", "governed", "checkpoint", "log_decision", "get_active_run",
6
+ "FencesError", "BudgetExceeded", "IterationLimitReached", "TimeLimitReached", "TokenLimitReached",
7
+ ]
fences/client.py ADDED
@@ -0,0 +1,60 @@
1
+ import requests
2
+ from typing import Optional
3
+
4
+
5
+ class GovClient:
6
+ def __init__(self, api_key: str, endpoint: str, timeout: float = 3.0):
7
+ self.api_key = api_key
8
+ self.endpoint = endpoint.rstrip("/")
9
+ self.timeout = timeout
10
+
11
+ def start_run(self, run_id: str, agent_name: str, budget_usd: float, max_iterations: int, max_duration_ms: int, max_tokens: int) -> dict:
12
+ return self._post("/api/runs/start", {
13
+ "run_id": run_id,
14
+ "agent_name": agent_name,
15
+ "budget_usd": budget_usd,
16
+ "max_iterations": max_iterations,
17
+ "max_duration_ms": max_duration_ms,
18
+ "max_tokens": max_tokens,
19
+ })
20
+
21
+ def checkpoint(self, run_id: str, cost_delta_usd: float, iterations: int, duration_ms: int, tokens_used: int) -> dict:
22
+ return self._post(f"/api/runs/{run_id}/checkpoint", {
23
+ "cost_delta_usd": cost_delta_usd,
24
+ "iterations": iterations,
25
+ "duration_ms": duration_ms,
26
+ "tokens_used": tokens_used,
27
+ })
28
+
29
+ def log_decision(self, run_id: str, iteration: int, reasoning: str, action: Optional[str]) -> dict:
30
+ try:
31
+ return self._post(f"/api/runs/{run_id}/decisions", {
32
+ "iteration": iteration,
33
+ "reasoning": reasoning,
34
+ "action": action,
35
+ })
36
+ except Exception:
37
+ return {}
38
+
39
+ def end_run(self, run_id: str, status: str, error: Optional[str] = None) -> dict:
40
+ return self._post(f"/api/runs/{run_id}/end", {
41
+ "status": status,
42
+ "error": error,
43
+ })
44
+
45
+ def _post(self, path: str, payload: dict) -> dict:
46
+ try:
47
+ resp = requests.post(
48
+ f"{self.endpoint}{path}",
49
+ json=payload,
50
+ headers={"X-API-Key": self.api_key},
51
+ timeout=self.timeout,
52
+ )
53
+ if resp.status_code in (401, 403):
54
+ raise PermissionError(f"Fences API key rejected: {resp.text}")
55
+ resp.raise_for_status()
56
+ return resp.json()
57
+ except PermissionError:
58
+ raise
59
+ except requests.RequestException as e:
60
+ return {"ok": True, "network_error": str(e)}
fences/core.py ADDED
@@ -0,0 +1,267 @@
1
+ import time
2
+ import uuid
3
+ import asyncio
4
+ import functools
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, Callable
8
+
9
+ from .exceptions import BudgetExceeded, IterationLimitReached, TimeLimitReached, TokenLimitReached
10
+ from .client import GovClient
11
+
12
+ _local = threading.local()
13
+
14
+
15
+ @dataclass
16
+ class RunState:
17
+ run_id: str
18
+ agent_name: str
19
+ budget_usd: float
20
+ max_iterations: int
21
+ max_duration_ms: int
22
+ max_tokens: int
23
+ cost_usd: float = 0.0
24
+ iterations: int = 0
25
+ tokens_used: int = 0
26
+ started_at: float = field(default_factory=time.time)
27
+ decisions: list = field(default_factory=list)
28
+
29
+ @property
30
+ def duration_ms(self) -> int:
31
+ return int((time.time() - self.started_at) * 1000)
32
+
33
+
34
+ def get_active_run() -> Optional[RunState]:
35
+ return getattr(_local, "run", None)
36
+
37
+
38
+ def _set_active_run(run: Optional[RunState]):
39
+ _local.run = run
40
+
41
+
42
+ _client: Optional[GovClient] = None
43
+ _local_only: bool = False
44
+
45
+
46
+ def init(
47
+ api_key: Optional[str] = None,
48
+ endpoint: str = "http://localhost:8000",
49
+ local_only: bool = False,
50
+ ):
51
+ """
52
+ Initialize Fences. Call once at startup before using @governed.
53
+
54
+ Local mode — no backend required:
55
+ fences.init(local_only=True)
56
+
57
+ Cloud mode — connects to a Fences backend:
58
+ fences.init(api_key="fc_...", endpoint="https://...")
59
+ """
60
+ global _client, _local_only
61
+ _local_only = local_only
62
+
63
+ if local_only:
64
+ _client = None
65
+ return
66
+
67
+ if not api_key:
68
+ raise ValueError(
69
+ "api_key is required unless local_only=True. "
70
+ "Use fences.init(local_only=True) for local usage."
71
+ )
72
+ _client = GovClient(api_key=api_key, endpoint=endpoint)
73
+
74
+
75
+ def _get_client() -> Optional[GovClient]:
76
+ return _client
77
+
78
+
79
+ def _require_init():
80
+ if not _local_only and _client is None:
81
+ raise RuntimeError(
82
+ "Call fences.init() before using Fences. "
83
+ "For local usage: fences.init(local_only=True)"
84
+ )
85
+
86
+
87
+ def governed(
88
+ budget_usd: float,
89
+ max_iterations: int = 100,
90
+ max_duration_ms: int = 300_000,
91
+ max_tokens: int = 0,
92
+ ):
93
+ """
94
+ Decorator that applies governance policy to an agent function.
95
+
96
+ Args:
97
+ budget_usd: Maximum spend allowed for this run in USD.
98
+ max_iterations: Maximum number of checkpoint() calls allowed.
99
+ max_duration_ms: Maximum wall-clock duration in milliseconds.
100
+ max_tokens: Maximum total tokens (input + output) allowed. 0 = no limit.
101
+
102
+ Raises BudgetExceeded, IterationLimitReached, TimeLimitReached, or
103
+ TokenLimitReached when limits are crossed.
104
+
105
+ Usage:
106
+ @fences.governed(budget_usd=0.50, max_iterations=20, max_tokens=50000)
107
+ async def run_agent(query: str):
108
+ response = call_llm(query)
109
+ await fences.checkpoint(
110
+ cost_delta_usd=0.02,
111
+ tokens_used=response.usage.total_tokens,
112
+ )
113
+ return response
114
+ """
115
+ def decorator(func: Callable) -> Callable:
116
+ is_async = asyncio.iscoroutinefunction(func)
117
+
118
+ @functools.wraps(func)
119
+ async def async_wrapper(*args, **kwargs):
120
+ _require_init()
121
+ run = _start_run(func.__name__, budget_usd, max_iterations, max_duration_ms, max_tokens)
122
+ _set_active_run(run)
123
+ try:
124
+ result = await func(*args, **kwargs)
125
+ _end_run(run, status="success")
126
+ return result
127
+ except (BudgetExceeded, IterationLimitReached, TimeLimitReached, TokenLimitReached):
128
+ _end_run(run, status="breached")
129
+ raise
130
+ except Exception as e:
131
+ _end_run(run, status="error", error=str(e))
132
+ raise
133
+ finally:
134
+ _set_active_run(None)
135
+
136
+ @functools.wraps(func)
137
+ def sync_wrapper(*args, **kwargs):
138
+ _require_init()
139
+ run = _start_run(func.__name__, budget_usd, max_iterations, max_duration_ms, max_tokens)
140
+ _set_active_run(run)
141
+ try:
142
+ result = func(*args, **kwargs)
143
+ _end_run(run, status="success")
144
+ return result
145
+ except (BudgetExceeded, IterationLimitReached, TimeLimitReached, TokenLimitReached):
146
+ _end_run(run, status="breached")
147
+ raise
148
+ except Exception as e:
149
+ _end_run(run, status="error", error=str(e))
150
+ raise
151
+ finally:
152
+ _set_active_run(None)
153
+
154
+ return async_wrapper if is_async else sync_wrapper
155
+
156
+ return decorator
157
+
158
+
159
+ def _start_run(agent_name, budget_usd, max_iterations, max_duration_ms, max_tokens) -> RunState:
160
+ run_id = str(uuid.uuid4())
161
+ run = RunState(
162
+ run_id=run_id,
163
+ agent_name=agent_name,
164
+ budget_usd=budget_usd,
165
+ max_iterations=max_iterations,
166
+ max_duration_ms=max_duration_ms,
167
+ max_tokens=max_tokens,
168
+ )
169
+ client = _get_client()
170
+ if client:
171
+ client.start_run(run_id, agent_name, budget_usd, max_iterations, max_duration_ms, max_tokens)
172
+ return run
173
+
174
+
175
+ def _end_run(run: RunState, status: str, error: Optional[str] = None):
176
+ client = _get_client()
177
+ if client:
178
+ client.end_run(run.run_id, status=status, error=error)
179
+
180
+
181
+ async def checkpoint(cost_delta_usd: float = 0.0, tokens_used: int = 0):
182
+ """
183
+ Report spend and token usage, then check all governance limits.
184
+
185
+ Call this after each LLM call in your agent loop.
186
+ Raises BudgetExceeded, IterationLimitReached, TimeLimitReached,
187
+ or TokenLimitReached if any limit is crossed.
188
+
189
+ Args:
190
+ cost_delta_usd: Amount spent since the last checkpoint call.
191
+ tokens_used: Total tokens (input + output) consumed by this step.
192
+ """
193
+ run = get_active_run()
194
+ if run is None:
195
+ return
196
+
197
+ run.cost_usd += cost_delta_usd
198
+ run.tokens_used += tokens_used
199
+ run.iterations += 1
200
+
201
+ # Local checks — instant, no network
202
+ if run.cost_usd >= run.budget_usd:
203
+ raise BudgetExceeded(run.cost_usd, run.budget_usd)
204
+
205
+ if run.iterations >= run.max_iterations:
206
+ raise IterationLimitReached(run.iterations, run.max_iterations)
207
+
208
+ if run.duration_ms >= run.max_duration_ms:
209
+ raise TimeLimitReached(run.duration_ms, run.max_duration_ms)
210
+
211
+ if run.max_tokens > 0 and run.tokens_used >= run.max_tokens:
212
+ raise TokenLimitReached(run.tokens_used, run.max_tokens)
213
+
214
+ # Server check — cloud mode only
215
+ client = _get_client()
216
+ if client is None:
217
+ return
218
+
219
+ result = client.checkpoint(
220
+ run.run_id, cost_delta_usd, run.iterations, run.duration_ms, run.tokens_used
221
+ )
222
+
223
+ if result.get("ok", True):
224
+ return
225
+
226
+ breach = result.get("breach")
227
+ if breach == "budget_exceeded":
228
+ raise BudgetExceeded(result.get("spent_usd", run.cost_usd), result.get("budget_usd", run.budget_usd))
229
+ elif breach == "iteration_limit":
230
+ raise IterationLimitReached(run.iterations, run.max_iterations)
231
+ elif breach == "time_limit":
232
+ raise TimeLimitReached(run.duration_ms, run.max_duration_ms)
233
+ elif breach == "token_limit":
234
+ raise TokenLimitReached(run.tokens_used, run.max_tokens)
235
+
236
+
237
+ def log_decision(reasoning: str, action: Optional[str] = None):
238
+ """
239
+ Record the agent's reasoning at this step.
240
+
241
+ Call this before any significant action to build an audit trail
242
+ of why the agent did what it did.
243
+
244
+ Args:
245
+ reasoning: Why the agent is taking this action.
246
+ action: Short label for the action being taken (e.g. "web_search").
247
+ """
248
+ run = get_active_run()
249
+ if run is None:
250
+ return
251
+
252
+ entry = {
253
+ "timestamp": time.time(),
254
+ "iteration": run.iterations,
255
+ "reasoning": reasoning,
256
+ "action": action,
257
+ }
258
+ run.decisions.append(entry)
259
+
260
+ client = _get_client()
261
+ if client:
262
+ client.log_decision(
263
+ run_id=run.run_id,
264
+ iteration=run.iterations,
265
+ reasoning=reasoning,
266
+ action=action,
267
+ )
fences/exceptions.py ADDED
@@ -0,0 +1,38 @@
1
+ class FencesError(Exception):
2
+ pass
3
+
4
+
5
+ class BudgetExceeded(FencesError):
6
+ def __init__(self, spent_usd: float, budget_usd: float):
7
+ self.spent_usd = spent_usd
8
+ self.budget_usd = budget_usd
9
+ super().__init__(
10
+ f"Run exceeded budget: spent ${spent_usd:.4f} of ${budget_usd:.4f} limit"
11
+ )
12
+
13
+
14
+ class IterationLimitReached(FencesError):
15
+ def __init__(self, iterations: int, max_iterations: int):
16
+ self.iterations = iterations
17
+ self.max_iterations = max_iterations
18
+ super().__init__(
19
+ f"Run exceeded iteration limit: {iterations} of {max_iterations} allowed"
20
+ )
21
+
22
+
23
+ class TimeLimitReached(FencesError):
24
+ def __init__(self, duration_ms: int, max_duration_ms: int):
25
+ self.duration_ms = duration_ms
26
+ self.max_duration_ms = max_duration_ms
27
+ super().__init__(
28
+ f"Run exceeded time limit: {duration_ms}ms of {max_duration_ms}ms allowed"
29
+ )
30
+
31
+
32
+ class TokenLimitReached(FencesError):
33
+ def __init__(self, tokens_used: int, max_tokens: int):
34
+ self.tokens_used = tokens_used
35
+ self.max_tokens = max_tokens
36
+ super().__init__(
37
+ f"Run exceeded token limit: {tokens_used} of {max_tokens} tokens allowed"
38
+ )