runfence 0.1.1__tar.gz
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.
- runfence-0.1.1/LICENSE +21 -0
- runfence-0.1.1/PKG-INFO +153 -0
- runfence-0.1.1/README.md +130 -0
- runfence-0.1.1/pyproject.toml +38 -0
- runfence-0.1.1/setup.cfg +4 -0
- runfence-0.1.1/src/runfence/__init__.py +9 -0
- runfence-0.1.1/src/runfence/budget.py +42 -0
- runfence-0.1.1/src/runfence/errors.py +23 -0
- runfence-0.1.1/src/runfence/scope.py +135 -0
- runfence-0.1.1/src/runfence.egg-info/PKG-INFO +153 -0
- runfence-0.1.1/src/runfence.egg-info/SOURCES.txt +13 -0
- runfence-0.1.1/src/runfence.egg-info/dependency_links.txt +1 -0
- runfence-0.1.1/src/runfence.egg-info/requires.txt +5 -0
- runfence-0.1.1/src/runfence.egg-info/top_level.txt +1 -0
- runfence-0.1.1/tests/test_scope.py +146 -0
runfence-0.1.1/LICENSE
ADDED
|
@@ -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.
|
runfence-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runfence
|
|
3
|
+
Version: 0.1.1
|
|
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/runfence
|
|
8
|
+
Project-URL: Source, https://github.com/Chenjigaram/runfence
|
|
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
|
+
# runfence
|
|
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 runfence 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 runfence
|
|
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
|
+
```
|
runfence-0.1.1/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# runfence
|
|
2
|
+
|
|
3
|
+
Cancel, deadline and budget limits for agent runs — that actually stop the work.
|
|
4
|
+
|
|
5
|
+
Every agent framework hands you an async generator of events. When you stop consuming
|
|
6
|
+
it, the work it started **keeps running**. Model calls keep streaming, tools keep
|
|
7
|
+
executing, and you keep paying. `break` does not mean stop.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from runfence import run_scope, Cancelled
|
|
11
|
+
|
|
12
|
+
scope = run_scope(deadline=30, tokens=50_000, usd=0.25)
|
|
13
|
+
|
|
14
|
+
async with scope:
|
|
15
|
+
try:
|
|
16
|
+
async for event in scope.stream(runner.run_async(...)):
|
|
17
|
+
handle(event)
|
|
18
|
+
except Cancelled:
|
|
19
|
+
... # scope.cancel() was called, from anywhere
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`scope.stream()` takes any async iterator, so it works with whatever framework produced
|
|
23
|
+
it. There is no adapter to install and nothing to register.
|
|
24
|
+
|
|
25
|
+
## What it costs to get this wrong
|
|
26
|
+
|
|
27
|
+
Cancelling a real streaming run against `llama-3.3-70b`, two runs on different days:
|
|
28
|
+
|
|
29
|
+
| | Wall clock | Events |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Run to completion | 262.3s / 42.6s | 1369 / 1366 |
|
|
32
|
+
| `scope.cancel()` at 1.5s | **1.8s / 1.5s** | 1 |
|
|
33
|
+
|
|
34
|
+
The completion time swings with provider load, which is the point: you cannot predict
|
|
35
|
+
how long a run will take, so the ceiling has to be enforced rather than assumed. No
|
|
36
|
+
framework tasks were left alive after cancelling in either run.
|
|
37
|
+
|
|
38
|
+
Reproduce it with `examples/live_cancel.py`, or see the mechanism with no API key at all:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python examples/stop_means_stop.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
break + aclose() -> tools that still finished: ['search', 'summarise', 'draft']
|
|
46
|
+
inside a run_scope -> tools that still finished: none
|
|
47
|
+
anything left behind? no
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Limits
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
scope = run_scope(
|
|
54
|
+
deadline=30, # seconds of wall clock for the whole run
|
|
55
|
+
tokens=50_000, # stop once this many tokens are spent
|
|
56
|
+
usd=0.25, # stop once this much money is spent
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Usage has to come from somewhere, so tell the scope how to read it off an event:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
async for event in scope.stream(source, usage=lambda e: {"tokens": e.usage.total_tokens}):
|
|
64
|
+
...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Stopping raises, and the exception carries what was spent:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
except BudgetExceeded as stopped:
|
|
71
|
+
log.warning("stopped after %.1fs and %d tokens", stopped.elapsed, stopped.tokens)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`Cancelled`, `DeadlineExceeded` and `BudgetExceeded` all derive from `RunStopped`.
|
|
75
|
+
|
|
76
|
+
## Work started inside the scope
|
|
77
|
+
|
|
78
|
+
Anything spawned through the scope is cancelled with it:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
async with run_scope(deadline=10) as scope:
|
|
82
|
+
scope.spawn(background_tool())
|
|
83
|
+
async for event in scope.stream(source):
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Anything spawned *outside* it cannot be cancelled by it — but it is reported rather
|
|
88
|
+
than ignored:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
print(scope.leaked) # names of tasks still running when the scope closed
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That list is the honest answer to "did my framework clean up?", and it is usually the
|
|
95
|
+
first thing you want to know when a run refuses to die.
|
|
96
|
+
|
|
97
|
+
## Why this exists
|
|
98
|
+
|
|
99
|
+
Stopping an agent is unsolved across the ecosystem, not in one framework:
|
|
100
|
+
|
|
101
|
+
- google/adk-python — 52 reactions across its three top cancellation issues, the oldest
|
|
102
|
+
open since August 2025, with three community PRs unmerged
|
|
103
|
+
- langchain-ai/langgraph — 25 open issues mentioning cancel, interrupt or abort; the
|
|
104
|
+
most discussed is about cancellation losing state that was not yet checkpointed
|
|
105
|
+
- strands-agents — 21 open issues on the same theme
|
|
106
|
+
|
|
107
|
+
## What it does not do
|
|
108
|
+
|
|
109
|
+
- It cannot cancel work a framework spawned as an orphan task. Nothing outside that
|
|
110
|
+
framework can. It detects and reports those instead, in `scope.leaked`.
|
|
111
|
+
- It does not price tokens. Pass `usd` yourself, from your provider's numbers or a
|
|
112
|
+
library like `tokencost`.
|
|
113
|
+
- Verified against the OpenAI Agents SDK on real streaming traffic. Google ADK exposes
|
|
114
|
+
the same async-generator shape and is expected to work, but is **not yet tested**.
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install runfence
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
No dependencies. Python 3.11+ (it uses `asyncio.timeout` semantics and modern task APIs).
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install -e ".[dev]"
|
|
128
|
+
pytest
|
|
129
|
+
ruff check .
|
|
130
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "runfence"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Cancel, deadline and budget limits for agent runs that actually stop the work"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Naveen" }]
|
|
9
|
+
keywords = ["agents", "llm", "cancellation", "timeout", "budget", "asyncio", "adk", "langgraph"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
]
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.23", "ruff>=0.4"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/Chenjigaram/runfence"
|
|
24
|
+
Source = "https://github.com/Chenjigaram/runfence"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["setuptools>=68"]
|
|
28
|
+
build-backend = "setuptools.build_meta"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 110
|
|
35
|
+
src = ["src", "tests"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["E", "F", "I", "UP", "B"]
|
runfence-0.1.1/setup.cfg
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"]
|
|
@@ -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
|
|
@@ -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."""
|
|
@@ -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.1
|
|
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/runfence
|
|
8
|
+
Project-URL: Source, https://github.com/Chenjigaram/runfence
|
|
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
|
+
# runfence
|
|
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 runfence 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 runfence
|
|
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,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/runfence/__init__.py
|
|
5
|
+
src/runfence/budget.py
|
|
6
|
+
src/runfence/errors.py
|
|
7
|
+
src/runfence/scope.py
|
|
8
|
+
src/runfence.egg-info/PKG-INFO
|
|
9
|
+
src/runfence.egg-info/SOURCES.txt
|
|
10
|
+
src/runfence.egg-info/dependency_links.txt
|
|
11
|
+
src/runfence.egg-info/requires.txt
|
|
12
|
+
src/runfence.egg-info/top_level.txt
|
|
13
|
+
tests/test_scope.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
runfence
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from runfence import (
|
|
6
|
+
Budget,
|
|
7
|
+
BudgetExceeded,
|
|
8
|
+
Cancelled,
|
|
9
|
+
DeadlineExceeded,
|
|
10
|
+
RunScope,
|
|
11
|
+
run_scope,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def ticker(n=10, delay=0.01, spawn=None, work=None):
|
|
16
|
+
"""Stands in for an agent run: yields events and may start background work."""
|
|
17
|
+
if spawn is not None:
|
|
18
|
+
for i in range(3):
|
|
19
|
+
spawn(work(i))
|
|
20
|
+
for i in range(n):
|
|
21
|
+
await asyncio.sleep(delay)
|
|
22
|
+
yield {"step": i, "tokens": 100}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_work_started_in_the_scope_stops_with_it():
|
|
27
|
+
"""The regression case: abandoning a generator normally leaves its work running."""
|
|
28
|
+
finished = []
|
|
29
|
+
|
|
30
|
+
async def slow(i):
|
|
31
|
+
await asyncio.sleep(1)
|
|
32
|
+
finished.append(i)
|
|
33
|
+
|
|
34
|
+
async with run_scope() as scope:
|
|
35
|
+
async for event in scope.stream(ticker(spawn=scope.spawn, work=slow)):
|
|
36
|
+
if event["step"] == 1:
|
|
37
|
+
break
|
|
38
|
+
await asyncio.sleep(1.2)
|
|
39
|
+
assert finished == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_without_the_scope_the_same_work_leaks():
|
|
44
|
+
"""Documents why the scope exists; this is plain asyncio behaviour."""
|
|
45
|
+
finished = []
|
|
46
|
+
|
|
47
|
+
async def slow(i):
|
|
48
|
+
await asyncio.sleep(0.3)
|
|
49
|
+
finished.append(i)
|
|
50
|
+
|
|
51
|
+
gen = ticker(spawn=asyncio.ensure_future, work=slow)
|
|
52
|
+
async for event in gen:
|
|
53
|
+
if event["step"] == 1:
|
|
54
|
+
break
|
|
55
|
+
await gen.aclose()
|
|
56
|
+
await asyncio.sleep(0.5)
|
|
57
|
+
assert finished == [0, 1, 2]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_cancel_stops_the_stream():
|
|
62
|
+
scope = run_scope()
|
|
63
|
+
|
|
64
|
+
async def stopper():
|
|
65
|
+
await asyncio.sleep(0.05)
|
|
66
|
+
scope.cancel("user pressed stop")
|
|
67
|
+
|
|
68
|
+
async with scope:
|
|
69
|
+
asyncio.ensure_future(stopper())
|
|
70
|
+
with pytest.raises(Cancelled, match="user pressed stop"):
|
|
71
|
+
async for _ in scope.stream(ticker(n=100)):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_deadline_stops_the_stream():
|
|
77
|
+
async with run_scope(deadline=0.08) as scope:
|
|
78
|
+
with pytest.raises(DeadlineExceeded):
|
|
79
|
+
async for _ in scope.stream(ticker(n=100, delay=0.02)):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_token_budget_stops_the_stream():
|
|
85
|
+
async with run_scope(tokens=250) as scope:
|
|
86
|
+
with pytest.raises(BudgetExceeded, match="token budget"):
|
|
87
|
+
async for _ in scope.stream(ticker(n=100), usage=lambda e: {"tokens": e["tokens"]}):
|
|
88
|
+
pass
|
|
89
|
+
assert scope.usage.tokens == 300
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_cost_budget_stops_the_stream():
|
|
94
|
+
async with run_scope(usd=0.02) as scope:
|
|
95
|
+
with pytest.raises(BudgetExceeded, match="cost budget"):
|
|
96
|
+
async for _ in scope.stream(ticker(n=100), usage=lambda e: {"usd": 0.01}):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_a_run_inside_its_limits_completes():
|
|
102
|
+
async with run_scope(deadline=5, tokens=10_000) as scope:
|
|
103
|
+
seen = [e async for e in scope.stream(ticker(n=3), usage=lambda e: {"tokens": e["tokens"]})]
|
|
104
|
+
assert len(seen) == 3
|
|
105
|
+
assert scope.usage.tokens == 300
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_stopping_reports_what_was_spent():
|
|
110
|
+
async with run_scope(tokens=150) as scope:
|
|
111
|
+
try:
|
|
112
|
+
async for _ in scope.stream(ticker(n=10), usage=lambda e: {"tokens": e["tokens"]}):
|
|
113
|
+
pass
|
|
114
|
+
except BudgetExceeded as stopped:
|
|
115
|
+
assert stopped.tokens == 200
|
|
116
|
+
assert stopped.elapsed > 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_tasks_left_running_outside_the_scope_are_reported():
|
|
121
|
+
async def stray():
|
|
122
|
+
await asyncio.sleep(5)
|
|
123
|
+
|
|
124
|
+
async with run_scope() as scope:
|
|
125
|
+
task = asyncio.ensure_future(stray()) # deliberately not scope.spawn
|
|
126
|
+
await asyncio.sleep(0.01)
|
|
127
|
+
assert scope.leaked, "a task started outside the scope should be reported"
|
|
128
|
+
task.cancel()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_budget_rejects_nonsense():
|
|
132
|
+
with pytest.raises(ValueError):
|
|
133
|
+
Budget(tokens=0)
|
|
134
|
+
with pytest.raises(ValueError):
|
|
135
|
+
Budget(usd=-1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_deadline_must_be_positive():
|
|
139
|
+
with pytest.raises(ValueError):
|
|
140
|
+
RunScope(deadline=0)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_usage_cannot_decrease():
|
|
144
|
+
scope = RunScope()
|
|
145
|
+
with pytest.raises(ValueError):
|
|
146
|
+
scope.record(tokens=-5)
|