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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+ pytest-asyncio>=0.23
5
+ ruff>=0.4
@@ -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)