Metadata-Version: 2.4
Name: runfence
Version: 0.1.2
Summary: Cancel, deadline and budget limits for agent runs that actually stop the work
Author: Naveen
License: MIT
Project-URL: Homepage, https://github.com/Chenjigaram/runfence
Project-URL: Source, https://github.com/Chenjigaram/runfence
Keywords: agents,llm,cancellation,timeout,budget,asyncio,adk,langgraph
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Framework :: AsyncIO
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Dynamic: license-file

# runfence

Cancel, deadline and budget limits for agent runs — that actually stop the work.

Every agent framework hands you an async generator of events. When you stop consuming
it, the work it started **keeps running**. Model calls keep streaming, tools keep
executing, and you keep paying. `break` does not mean stop.

```python
from runfence import run_scope, Cancelled

scope = run_scope(deadline=30, tokens=50_000, usd=0.25)

async with scope:
    try:
        async for event in scope.stream(runner.run_async(...)):
            handle(event)
    except Cancelled:
        ...          # scope.cancel() was called, from anywhere
```

`scope.stream()` takes any async iterator, so it works with whatever framework produced
it. There is no adapter to install and nothing to register.

## What it costs to get this wrong

Cancelling a real streaming run against `llama-3.3-70b`, two runs on different days:

| | Wall clock | Events |
|---|---|---|
| Run to completion | 262.3s / 42.6s | 1369 / 1366 |
| `scope.cancel()` at 1.5s | **1.8s / 1.5s** | 1 |

The completion time swings with provider load, which is the point: you cannot predict
how long a run will take, so the ceiling has to be enforced rather than assumed. No
framework tasks were left alive after cancelling in either run.

Reproduce it with `examples/live_cancel.py`, or see the mechanism with no API key at all:

```bash
python examples/stop_means_stop.py
```

```
  break + aclose()      -> tools that still finished: ['search', 'summarise', 'draft']
  inside a run_scope    -> tools that still finished: none
  anything left behind? no
```

## Limits

```python
scope = run_scope(
    deadline=30,        # seconds of wall clock for the whole run
    tokens=50_000,      # stop once this many tokens are spent
    usd=0.25,           # stop once this much money is spent
)
```

Usage has to come from somewhere, so tell the scope how to read it off an event:

```python
async for event in scope.stream(source, usage=lambda e: {"tokens": e.usage.total_tokens}):
    ...
```

Stopping raises, and the exception carries what was spent:

```python
except BudgetExceeded as stopped:
    log.warning("stopped after %.1fs and %d tokens", stopped.elapsed, stopped.tokens)
```

`Cancelled`, `DeadlineExceeded` and `BudgetExceeded` all derive from `RunStopped`.

## Work started inside the scope

Anything spawned through the scope is cancelled with it:

```python
async with run_scope(deadline=10) as scope:
    scope.spawn(background_tool())
    async for event in scope.stream(source):
        ...
```

Anything spawned *outside* it cannot be cancelled by it — but it is reported rather
than ignored:

```python
print(scope.leaked)   # names of tasks still running when the scope closed
```

That list is the honest answer to "did my framework clean up?", and it is usually the
first thing you want to know when a run refuses to die.

## Why this exists

Stopping an agent is unsolved across the ecosystem, not in one framework:

- google/adk-python — 52 reactions across its three top cancellation issues, the oldest
  open since August 2025, with three community PRs unmerged
- langchain-ai/langgraph — 25 open issues mentioning cancel, interrupt or abort; the
  most discussed is about cancellation losing state that was not yet checkpointed
- strands-agents — 21 open issues on the same theme

## What it does not do

- It cannot cancel work a framework spawned as an orphan task. Nothing outside that
  framework can. It detects and reports those instead, in `scope.leaked`.
- It does not price tokens. Pass `usd` yourself, from your provider's numbers or a
  library like `tokencost`.
- Verified against the OpenAI Agents SDK on real streaming traffic. Google ADK exposes
  the same async-generator shape and is expected to work, but is **not yet tested**.

## Install

```bash
pip install runfence
```

No dependencies. Python 3.11+ (it uses `asyncio.timeout` semantics and modern task APIs).

## Development

```bash
pip install -e ".[dev]"
pytest
ruff check .
```
