runfence 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.
- agentleash/__init__.py +9 -0
- agentleash/budget.py +42 -0
- agentleash/errors.py +23 -0
- agentleash/scope.py +135 -0
- runfence-0.1.0.dist-info/METADATA +153 -0
- runfence-0.1.0.dist-info/RECORD +9 -0
- runfence-0.1.0.dist-info/WHEEL +5 -0
- runfence-0.1.0.dist-info/licenses/LICENSE +21 -0
- runfence-0.1.0.dist-info/top_level.txt +1 -0
agentleash/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .budget import Budget, Usage
|
|
4
|
+
from .errors import BudgetExceeded, Cancelled, DeadlineExceeded, RunStopped
|
|
5
|
+
from .scope import RunScope, run_scope
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = ["Budget", "Usage", "RunScope", "run_scope", "RunStopped",
|
|
9
|
+
"Cancelled", "DeadlineExceeded", "BudgetExceeded"]
|
agentleash/budget.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Usage:
|
|
8
|
+
"""What a run has spent so far."""
|
|
9
|
+
|
|
10
|
+
tokens: int = 0
|
|
11
|
+
usd: float = 0.0
|
|
12
|
+
|
|
13
|
+
def add(self, tokens: int = 0, usd: float = 0.0) -> None:
|
|
14
|
+
if tokens < 0 or usd < 0:
|
|
15
|
+
raise ValueError("usage cannot decrease")
|
|
16
|
+
self.tokens += tokens
|
|
17
|
+
self.usd += usd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Budget:
|
|
22
|
+
"""A ceiling for one run. None means no limit for that dimension."""
|
|
23
|
+
|
|
24
|
+
tokens: int | None = None
|
|
25
|
+
usd: float | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if self.tokens is not None and self.tokens <= 0:
|
|
29
|
+
raise ValueError("token budget must be positive")
|
|
30
|
+
if self.usd is not None and self.usd <= 0:
|
|
31
|
+
raise ValueError("cost budget must be positive")
|
|
32
|
+
|
|
33
|
+
def exceeded_by(self, usage: Usage) -> str | None:
|
|
34
|
+
if self.tokens is not None and usage.tokens > self.tokens:
|
|
35
|
+
return f"token budget spent: {usage.tokens} > {self.tokens}"
|
|
36
|
+
if self.usd is not None and usage.usd > self.usd:
|
|
37
|
+
return f"cost budget spent: ${usage.usd:.4f} > ${self.usd:.2f}"
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def unlimited(self) -> bool:
|
|
42
|
+
return self.tokens is None and self.usd is None
|
agentleash/errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RunStopped(Exception):
|
|
5
|
+
"""Base for every reason a run was stopped by the scope rather than by finishing."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, *, elapsed: float, tokens: int, usd: float) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.elapsed = elapsed
|
|
10
|
+
self.tokens = tokens
|
|
11
|
+
self.usd = usd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Cancelled(RunStopped):
|
|
15
|
+
"""cancel() was called."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeadlineExceeded(RunStopped):
|
|
19
|
+
"""The wall-clock deadline passed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BudgetExceeded(RunStopped):
|
|
23
|
+
"""The token or cost budget was spent."""
|
agentleash/scope.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncIterator, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .budget import Budget, Usage
|
|
9
|
+
from .errors import BudgetExceeded, Cancelled, DeadlineExceeded
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunScope:
|
|
13
|
+
"""A boundary around one agent run: it can be stopped, and it stops for real.
|
|
14
|
+
|
|
15
|
+
Abandoning an async generator does not stop work the generator started; those tasks
|
|
16
|
+
keep running, and for an agent that means live model calls that still cost money.
|
|
17
|
+
Work spawned through this scope is cancelled with it, and anything left behind is
|
|
18
|
+
reported rather than ignored.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, deadline: float | None = None, budget: Budget | None = None) -> None:
|
|
22
|
+
if deadline is not None and deadline <= 0:
|
|
23
|
+
raise ValueError("deadline must be positive")
|
|
24
|
+
self.deadline = deadline
|
|
25
|
+
self.budget = budget or Budget()
|
|
26
|
+
self.usage = Usage()
|
|
27
|
+
self.leaked: list[str] = []
|
|
28
|
+
self._started: float | None = None
|
|
29
|
+
self._stop = asyncio.Event()
|
|
30
|
+
self._reason = ""
|
|
31
|
+
self._children: set[asyncio.Task[Any]] = set()
|
|
32
|
+
self._known: set[asyncio.Task[Any]] = set()
|
|
33
|
+
|
|
34
|
+
# ---- lifecycle -------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
async def __aenter__(self) -> RunScope:
|
|
37
|
+
self._started = time.monotonic()
|
|
38
|
+
self._known = set(asyncio.all_tasks())
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
async def __aexit__(self, *exc: object) -> bool:
|
|
42
|
+
for task in list(self._children):
|
|
43
|
+
if not task.done():
|
|
44
|
+
task.cancel()
|
|
45
|
+
if self._children:
|
|
46
|
+
await asyncio.gather(*self._children, return_exceptions=True)
|
|
47
|
+
strays = [t for t in asyncio.all_tasks()
|
|
48
|
+
if t not in self._known and t not in self._children
|
|
49
|
+
and not t.done() and t is not asyncio.current_task()]
|
|
50
|
+
self.leaked = [t.get_name() for t in strays]
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
# ---- controls --------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def spawn(self, coro: Any) -> asyncio.Task[Any]:
|
|
56
|
+
"""Run child work inside the scope so cancelling the scope cancels it too."""
|
|
57
|
+
task = asyncio.ensure_future(coro)
|
|
58
|
+
self._children.add(task)
|
|
59
|
+
task.add_done_callback(self._children.discard)
|
|
60
|
+
return task
|
|
61
|
+
|
|
62
|
+
def cancel(self, reason: str = "cancelled by caller") -> None:
|
|
63
|
+
"""Stop the run. Safe to call from another task."""
|
|
64
|
+
self._reason = reason
|
|
65
|
+
self._stop.set()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def elapsed(self) -> float:
|
|
69
|
+
return 0.0 if self._started is None else time.monotonic() - self._started
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def remaining(self) -> float | None:
|
|
73
|
+
return None if self.deadline is None else self.deadline - self.elapsed
|
|
74
|
+
|
|
75
|
+
def record(self, tokens: int = 0, usd: float = 0.0) -> None:
|
|
76
|
+
"""Add usage and stop the run if that puts it over budget."""
|
|
77
|
+
self.usage.add(tokens=tokens, usd=usd)
|
|
78
|
+
breach = self.budget.exceeded_by(self.usage)
|
|
79
|
+
if breach:
|
|
80
|
+
raise self._stopped(BudgetExceeded, breach)
|
|
81
|
+
|
|
82
|
+
# ---- consumption -----------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def stream(
|
|
85
|
+
self,
|
|
86
|
+
source: AsyncIterator[Any],
|
|
87
|
+
usage: Callable[[Any], dict[str, Any]] | None = None,
|
|
88
|
+
) -> AsyncIterator[Any]:
|
|
89
|
+
"""Yield from `source` while enforcing cancellation, deadline and budget.
|
|
90
|
+
|
|
91
|
+
The deadline is applied to each wait for the next item rather than wrapped
|
|
92
|
+
around the yields, because a timeout that fires while a generator is suspended
|
|
93
|
+
is delivered to whoever happens to be running it.
|
|
94
|
+
"""
|
|
95
|
+
iterator = source.__aiter__()
|
|
96
|
+
waiter = asyncio.ensure_future(self._stop.wait())
|
|
97
|
+
try:
|
|
98
|
+
while True:
|
|
99
|
+
left = self.remaining
|
|
100
|
+
if left is not None and left <= 0:
|
|
101
|
+
raise self._stopped(DeadlineExceeded, f"deadline of {self.deadline}s passed")
|
|
102
|
+
step = asyncio.ensure_future(iterator.__anext__())
|
|
103
|
+
done, _ = await asyncio.wait({step, waiter}, timeout=left,
|
|
104
|
+
return_when=asyncio.FIRST_COMPLETED)
|
|
105
|
+
if waiter in done:
|
|
106
|
+
step.cancel()
|
|
107
|
+
await asyncio.gather(step, return_exceptions=True)
|
|
108
|
+
raise self._stopped(Cancelled, self._reason)
|
|
109
|
+
if step not in done:
|
|
110
|
+
step.cancel()
|
|
111
|
+
await asyncio.gather(step, return_exceptions=True)
|
|
112
|
+
raise self._stopped(DeadlineExceeded, f"deadline of {self.deadline}s passed")
|
|
113
|
+
try:
|
|
114
|
+
item = step.result()
|
|
115
|
+
except StopAsyncIteration:
|
|
116
|
+
return
|
|
117
|
+
if usage is not None:
|
|
118
|
+
self.record(**usage(item))
|
|
119
|
+
yield item
|
|
120
|
+
finally:
|
|
121
|
+
waiter.cancel()
|
|
122
|
+
closer = getattr(source, "aclose", None)
|
|
123
|
+
if closer is not None:
|
|
124
|
+
await closer()
|
|
125
|
+
|
|
126
|
+
# ---- internals -------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _stopped(self, kind: type, message: str):
|
|
129
|
+
return kind(message, elapsed=self.elapsed, tokens=self.usage.tokens, usd=self.usage.usd)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_scope(deadline: float | None = None, tokens: int | None = None,
|
|
133
|
+
usd: float | None = None) -> RunScope:
|
|
134
|
+
"""A scope with a deadline in seconds and optional token and cost ceilings."""
|
|
135
|
+
return RunScope(deadline=deadline, budget=Budget(tokens=tokens, usd=usd))
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runfence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cancel, deadline and budget limits for agent runs that actually stop the work
|
|
5
|
+
Author: Naveen
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Chenjigaram/agentleash
|
|
8
|
+
Project-URL: Source, https://github.com/Chenjigaram/agentleash
|
|
9
|
+
Keywords: agents,llm,cancellation,timeout,budget,asyncio,adk,langgraph
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# agentleash
|
|
25
|
+
|
|
26
|
+
Cancel, deadline and budget limits for agent runs — that actually stop the work.
|
|
27
|
+
|
|
28
|
+
Every agent framework hands you an async generator of events. When you stop consuming
|
|
29
|
+
it, the work it started **keeps running**. Model calls keep streaming, tools keep
|
|
30
|
+
executing, and you keep paying. `break` does not mean stop.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from agentleash import run_scope, Cancelled
|
|
34
|
+
|
|
35
|
+
scope = run_scope(deadline=30, tokens=50_000, usd=0.25)
|
|
36
|
+
|
|
37
|
+
async with scope:
|
|
38
|
+
try:
|
|
39
|
+
async for event in scope.stream(runner.run_async(...)):
|
|
40
|
+
handle(event)
|
|
41
|
+
except Cancelled:
|
|
42
|
+
... # scope.cancel() was called, from anywhere
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`scope.stream()` takes any async iterator, so it works with whatever framework produced
|
|
46
|
+
it. There is no adapter to install and nothing to register.
|
|
47
|
+
|
|
48
|
+
## What it costs to get this wrong
|
|
49
|
+
|
|
50
|
+
Cancelling a real streaming run against `llama-3.3-70b`, two runs on different days:
|
|
51
|
+
|
|
52
|
+
| | Wall clock | Events |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Run to completion | 262.3s / 42.6s | 1369 / 1366 |
|
|
55
|
+
| `scope.cancel()` at 1.5s | **1.8s / 1.5s** | 1 |
|
|
56
|
+
|
|
57
|
+
The completion time swings with provider load, which is the point: you cannot predict
|
|
58
|
+
how long a run will take, so the ceiling has to be enforced rather than assumed. No
|
|
59
|
+
framework tasks were left alive after cancelling in either run.
|
|
60
|
+
|
|
61
|
+
Reproduce it with `examples/live_cancel.py`, or see the mechanism with no API key at all:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python examples/stop_means_stop.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
break + aclose() -> tools that still finished: ['search', 'summarise', 'draft']
|
|
69
|
+
inside a run_scope -> tools that still finished: none
|
|
70
|
+
anything left behind? no
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Limits
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
scope = run_scope(
|
|
77
|
+
deadline=30, # seconds of wall clock for the whole run
|
|
78
|
+
tokens=50_000, # stop once this many tokens are spent
|
|
79
|
+
usd=0.25, # stop once this much money is spent
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Usage has to come from somewhere, so tell the scope how to read it off an event:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
async for event in scope.stream(source, usage=lambda e: {"tokens": e.usage.total_tokens}):
|
|
87
|
+
...
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Stopping raises, and the exception carries what was spent:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
except BudgetExceeded as stopped:
|
|
94
|
+
log.warning("stopped after %.1fs and %d tokens", stopped.elapsed, stopped.tokens)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`Cancelled`, `DeadlineExceeded` and `BudgetExceeded` all derive from `RunStopped`.
|
|
98
|
+
|
|
99
|
+
## Work started inside the scope
|
|
100
|
+
|
|
101
|
+
Anything spawned through the scope is cancelled with it:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
async with run_scope(deadline=10) as scope:
|
|
105
|
+
scope.spawn(background_tool())
|
|
106
|
+
async for event in scope.stream(source):
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Anything spawned *outside* it cannot be cancelled by it — but it is reported rather
|
|
111
|
+
than ignored:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
print(scope.leaked) # names of tasks still running when the scope closed
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
That list is the honest answer to "did my framework clean up?", and it is usually the
|
|
118
|
+
first thing you want to know when a run refuses to die.
|
|
119
|
+
|
|
120
|
+
## Why this exists
|
|
121
|
+
|
|
122
|
+
Stopping an agent is unsolved across the ecosystem, not in one framework:
|
|
123
|
+
|
|
124
|
+
- google/adk-python — 52 reactions across its three top cancellation issues, the oldest
|
|
125
|
+
open since August 2025, with three community PRs unmerged
|
|
126
|
+
- langchain-ai/langgraph — 25 open issues mentioning cancel, interrupt or abort; the
|
|
127
|
+
most discussed is about cancellation losing state that was not yet checkpointed
|
|
128
|
+
- strands-agents — 21 open issues on the same theme
|
|
129
|
+
|
|
130
|
+
## What it does not do
|
|
131
|
+
|
|
132
|
+
- It cannot cancel work a framework spawned as an orphan task. Nothing outside that
|
|
133
|
+
framework can. It detects and reports those instead, in `scope.leaked`.
|
|
134
|
+
- It does not price tokens. Pass `usd` yourself, from your provider's numbers or a
|
|
135
|
+
library like `tokencost`.
|
|
136
|
+
- Verified against the OpenAI Agents SDK on real streaming traffic. Google ADK exposes
|
|
137
|
+
the same async-generator shape and is expected to work, but is **not yet tested**.
|
|
138
|
+
|
|
139
|
+
## Install
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install agentleash
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
No dependencies. Python 3.11+ (it uses `asyncio.timeout` semantics and modern task APIs).
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install -e ".[dev]"
|
|
151
|
+
pytest
|
|
152
|
+
ruff check .
|
|
153
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
agentleash/__init__.py,sha256=kC80U4ue00VX9NKyP7qZnZErDSFz7q1MbvRCEYX_HRY,339
|
|
2
|
+
agentleash/budget.py,sha256=OVRCLrt2f45DeOMXLllQGY0CZd07DdTWzsIbd9oBmXY,1278
|
|
3
|
+
agentleash/errors.py,sha256=akXPSAEHUg9kSWCYKGYI2IFKbYOm14L4SM1vOHKb4t8,587
|
|
4
|
+
agentleash/scope.py,sha256=jU8Iw_5MmNVPaeUAu935kyEzgQwJM-lIfkVTBjDIkys,5553
|
|
5
|
+
runfence-0.1.0.dist-info/licenses/LICENSE,sha256=oUiz9FNjnv2M-ecIoc0YgMMunLGtJnC4s2lvuAtItL8,1063
|
|
6
|
+
runfence-0.1.0.dist-info/METADATA,sha256=6Z7WvRGvZiG4UxGTh3eU1bouGIcPcK3PQRMRp_dNCnQ,4902
|
|
7
|
+
runfence-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
runfence-0.1.0.dist-info/top_level.txt,sha256=bipzVBWbdpNk567_g8c_qRQW0xYyjWLa_08fPU7ZuGg,11
|
|
9
|
+
runfence-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naveen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentleash
|