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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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