durable-agents 0.1.0__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.
- durable_agents-0.1.0/.gitignore +11 -0
- durable_agents-0.1.0/LICENSE +21 -0
- durable_agents-0.1.0/PKG-INFO +273 -0
- durable_agents-0.1.0/README.md +247 -0
- durable_agents-0.1.0/pyproject.toml +96 -0
- durable_agents-0.1.0/src/durable_agents/__init__.py +126 -0
- durable_agents-0.1.0/src/durable_agents/api/__init__.py +0 -0
- durable_agents-0.1.0/src/durable_agents/api/app.py +226 -0
- durable_agents-0.1.0/src/durable_agents/cli.py +187 -0
- durable_agents-0.1.0/src/durable_agents/events.py +206 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/__init__.py +0 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/decisions.py +223 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/input_scan.py +24 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/output_validate.py +75 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/patterns.py +199 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/run_level.py +62 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/tool_result_scan.py +37 -0
- durable_agents-0.1.0/src/durable_agents/guardrails/types.py +26 -0
- durable_agents-0.1.0/src/durable_agents/llm/__init__.py +0 -0
- durable_agents-0.1.0/src/durable_agents/llm/openai_compatible.py +193 -0
- durable_agents-0.1.0/src/durable_agents/llm/protocol.py +44 -0
- durable_agents-0.1.0/src/durable_agents/llm/scripted.py +41 -0
- durable_agents-0.1.0/src/durable_agents/orchestrator.py +894 -0
- durable_agents-0.1.0/src/durable_agents/py.typed +0 -0
- durable_agents-0.1.0/src/durable_agents/replay_view.py +309 -0
- durable_agents-0.1.0/src/durable_agents/runtime.py +209 -0
- durable_agents-0.1.0/src/durable_agents/state.py +341 -0
- durable_agents-0.1.0/src/durable_agents/storage/__init__.py +0 -0
- durable_agents-0.1.0/src/durable_agents/storage/memory.py +101 -0
- durable_agents-0.1.0/src/durable_agents/storage/postgres.py +143 -0
- durable_agents-0.1.0/src/durable_agents/storage/protocol.py +91 -0
- durable_agents-0.1.0/src/durable_agents/storage/schema.py +31 -0
- durable_agents-0.1.0/src/durable_agents/storage/schema.sql +27 -0
- durable_agents-0.1.0/src/durable_agents/tools/__init__.py +0 -0
- durable_agents-0.1.0/src/durable_agents/tools/registry.py +151 -0
- durable_agents-0.1.0/src/durable_agents/worker.py +105 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.env
|
|
7
|
+
|
|
8
|
+
# A standalone project used to sanity-check durable-agents as a real
|
|
9
|
+
# consumer would see it — installed from the built wheel into its own
|
|
10
|
+
# venv, never touching src/. Not part of this project.
|
|
11
|
+
/consumer-test/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raj Tiwari
|
|
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,273 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: durable-agents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An event-sourced runtime for durable, crash-resumable LLM agents
|
|
5
|
+
Project-URL: Repository, https://github.com/therajtiwari/durable-agents
|
|
6
|
+
Project-URL: Issues, https://github.com/therajtiwari/durable-agents/issues
|
|
7
|
+
Author: Raj Tiwari
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,durable-execution,event-sourcing,llm,postgres
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: asyncpg
|
|
19
|
+
Requires-Dist: pydantic>=2
|
|
20
|
+
Provides-Extra: api
|
|
21
|
+
Requires-Dist: fastapi>=0.115; extra == 'api'
|
|
22
|
+
Requires-Dist: uvicorn>=0.30; extra == 'api'
|
|
23
|
+
Provides-Extra: openai
|
|
24
|
+
Requires-Dist: httpx>=0.27; extra == 'openai'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# durable-agents
|
|
28
|
+
|
|
29
|
+
[](https://github.com/therajtiwari/durable-agents/actions/workflows/ci.yml)
|
|
30
|
+
|
|
31
|
+
An event-sourced runtime for LLM agents, backed by Postgres. Runs survive
|
|
32
|
+
process restarts, resume where they stopped without repeating side effects, and
|
|
33
|
+
can pause indefinitely when a step needs human approval.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
Every model call, tool call, and approval is appended to a log before and after
|
|
38
|
+
it happens. Run state is a fold over that log, so a process that dies mid-run
|
|
39
|
+
leaves enough behind for another one to finish the job:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
seq= 0 RunStarted goal='Refund order A-8891, item arrived damaged.'
|
|
43
|
+
seq= 1 LLMCallRequested step=1
|
|
44
|
+
seq= 2 LLMCallFailed step=1 attempt=1 error='429 Too Many Requests'
|
|
45
|
+
seq= 3 LLMCallCompleted step=1 -> issue_refund({'order_id': 'A-8891', 'amount': 6400})
|
|
46
|
+
seq= 4 ToolCallRequested step=1 issue_refund(...) <- process killed here
|
|
47
|
+
seq= 5 ToolCallCompleted step=1 issue_refund -> {...} [recovered]
|
|
48
|
+
seq= 6 RunCompleted final_answer='Refund RF-55012 processed.'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Seq 5 was written by a different process than seq 4. One refund exists, not two.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install durable-agents
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Optional extras: `[openai]` for the bundled provider client, `[api]` for the
|
|
60
|
+
HTTP endpoints.
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from durable_agents import Runtime, InMemoryEventStore, tool
|
|
66
|
+
|
|
67
|
+
@tool(side_effect=True)
|
|
68
|
+
async def issue_refund(order_id: str, amount: int, idempotency_key: str) -> dict:
|
|
69
|
+
return await payments.refund(order_id, amount, key=idempotency_key)
|
|
70
|
+
|
|
71
|
+
runtime = Runtime(store=InMemoryEventStore(), llm=my_llm_client, tools=[issue_refund])
|
|
72
|
+
run = await runtime.start(goal="Refund order A-8891, item arrived damaged.")
|
|
73
|
+
|
|
74
|
+
print(run.state.status) # 'completed', 'failed', or 'awaiting_approval'
|
|
75
|
+
print(run.state.final_answer)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The in-memory store dies with the process. For runs that outlive it, swap in
|
|
79
|
+
Postgres:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from durable_agents import PostgresEventStore, create_schema
|
|
83
|
+
|
|
84
|
+
await create_schema(DATABASE_URL) # idempotent, or run: durable-agents init-db
|
|
85
|
+
store = await PostgresEventStore.connect(DATABASE_URL)
|
|
86
|
+
|
|
87
|
+
runtime = Runtime(store=store, llm=my_llm_client, tools=[issue_refund])
|
|
88
|
+
run_id = await runtime.create(goal="Refund order A-8891.") # record, don't run
|
|
89
|
+
state = await runtime.resume(run_id) # run it
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`resume()` is safe to call more than once. On a finished run it returns the
|
|
93
|
+
state; on one killed mid-tool-call it reconciles the dangling operation first.
|
|
94
|
+
|
|
95
|
+
## Writing tools
|
|
96
|
+
|
|
97
|
+
`@tool` derives the JSON schema from your type hints, so every parameter needs
|
|
98
|
+
an annotation. `*args` and `**kwargs` are rejected. The docstring becomes the
|
|
99
|
+
description the model sees.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
@tool(requires_approval=lambda args: args["amount"] > 5000, side_effect=True)
|
|
103
|
+
async def issue_refund(order_id: str, amount: int, idempotency_key: str) -> dict:
|
|
104
|
+
"""Refund an order. Needs approval above 5000."""
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- A parameter named `idempotency_key` is filled in by the runtime with
|
|
109
|
+
`sha256(run_id + seq + tool + args)`. It is stable across restarts, and a
|
|
110
|
+
retry gets the same key.
|
|
111
|
+
- Arguments the function doesn't accept are rejected before it runs, and the
|
|
112
|
+
error goes back to the model to correct.
|
|
113
|
+
- Return a `dict` and it is recorded as-is. Anything else is stored as
|
|
114
|
+
`{"result": <value>}`. Values JSON can't represent (`Decimal`, `datetime`,
|
|
115
|
+
`bytes`) are stored as strings.
|
|
116
|
+
|
|
117
|
+
## Human approval
|
|
118
|
+
|
|
119
|
+
A tool marked `requires_approval` parks the run rather than blocking on it. No
|
|
120
|
+
thread is held and no process stays alive, so the gap can be days.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
run = await runtime.start(goal="Refund order A-8891.")
|
|
124
|
+
if run.state.status == "awaiting_approval":
|
|
125
|
+
print(run.state.pending_approval.tool) # 'issue_refund'
|
|
126
|
+
|
|
127
|
+
# Later, in a different process:
|
|
128
|
+
await runtime.approve(run.id, approver="dana@example.com")
|
|
129
|
+
final = await runtime.resume(run.id)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`approve()` and `deny()` record the decision only; `resume()` does the work. On
|
|
133
|
+
denial the reason is passed back to the model, which can then choose another
|
|
134
|
+
action.
|
|
135
|
+
|
|
136
|
+
## Resuming runs automatically
|
|
137
|
+
|
|
138
|
+
`Worker` polls for runs that need work: new ones, ones a human just approved,
|
|
139
|
+
and ones that have been quiet long enough to look abandoned.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from durable_agents import Worker
|
|
143
|
+
|
|
144
|
+
await Worker(runtime, stale_after_seconds=60.0).run_forever()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Set `stale_after_seconds` above your slowest single operation. Too low and two
|
|
148
|
+
workers pick up the same run, which is safe but doubles that run's model spend.
|
|
149
|
+
|
|
150
|
+
## Guardrails
|
|
151
|
+
|
|
152
|
+
Argument validation runs by default: the tool has to be registered, its
|
|
153
|
+
arguments have to match the declared schema, and numbers stay within any caps
|
|
154
|
+
you configure. A failure is returned to the model to correct rather than ending
|
|
155
|
+
the run.
|
|
156
|
+
|
|
157
|
+
Prompt-injection pattern matching is a separate layer, off unless asked for,
|
|
158
|
+
because the regexes have a substantial false-positive rate against ordinary tool
|
|
159
|
+
output. Profiles are `off`, `validation` (the default), `lenient`, `standard`
|
|
160
|
+
and `strict`.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
runtime = Runtime(store=..., llm=..., guardrail_profile="standard")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
[`docs/THREAT_MODEL.md`](https://github.com/therajtiwari/durable-agents/blob/develop/docs/THREAT_MODEL.md) has the measured attack-success
|
|
167
|
+
and false-positive rates for each profile.
|
|
168
|
+
|
|
169
|
+
## HTTP API
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install durable-agents[api]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from durable_agents.api.app import create_app
|
|
177
|
+
|
|
178
|
+
app = create_app(store, default_max_steps=15)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
| Endpoint | Description |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `POST /runs` | Record a new run and return its id. Body: `{"goal": "..."}` |
|
|
184
|
+
| `GET /runs/{id}` | Status, pending approval, final answer, totals |
|
|
185
|
+
| `GET /approvals` | Runs currently waiting on a human |
|
|
186
|
+
| `POST /runs/{id}/approve` | Approve a parked run. Body: `{"approver": "..."}` |
|
|
187
|
+
| `POST /runs/{id}/deny` | Reject it. Body: `{"approver": "...", "reason": "..."}` |
|
|
188
|
+
|
|
189
|
+
No endpoint executes a run. Run a `Worker` alongside the API.
|
|
190
|
+
|
|
191
|
+
## Bringing your own model
|
|
192
|
+
|
|
193
|
+
One method:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from durable_agents import LLMClient, LLMResponse
|
|
197
|
+
|
|
198
|
+
class MyClient(LLMClient):
|
|
199
|
+
async def call(self, messages, tools, system_prompt=""):
|
|
200
|
+
...
|
|
201
|
+
return LLMResponse(
|
|
202
|
+
content=..., tool_calls=[...], stop_reason=...,
|
|
203
|
+
input_tokens=..., output_tokens=..., cost_usd=Decimal("0.002"),
|
|
204
|
+
latency_ms=..., provider_request_id=...,
|
|
205
|
+
)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Two implementations ship. `ScriptedLLM` takes a fixed list of responses, or
|
|
209
|
+
exceptions to simulate a flaky provider. `OpenAICompatibleClient` talks to
|
|
210
|
+
anything speaking the OpenAI chat-completions format: OpenAI, Azure, Groq,
|
|
211
|
+
Together, OpenRouter, Ollama, vLLM.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from durable_agents.llm.openai_compatible import OpenAICompatibleClient
|
|
215
|
+
|
|
216
|
+
llm = OpenAICompatibleClient(
|
|
217
|
+
base_url="https://api.openai.com/v1",
|
|
218
|
+
model="gpt-4o-mini",
|
|
219
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Neither client retries. Retries and their budget belong to the orchestrator.
|
|
224
|
+
|
|
225
|
+
## Limitations
|
|
226
|
+
|
|
227
|
+
- Postgres or in-memory only. No SQLite, MySQL or Redis.
|
|
228
|
+
- Recovery is poll-based. Nothing in the log records that a live process holds a
|
|
229
|
+
run, so `Worker` infers it from silence. No leases, no distributed scheduler.
|
|
230
|
+
|
|
231
|
+
Event fields are only ever added, always with a default, and the meaning of an
|
|
232
|
+
existing field does not change. If that ever has to happen, `schema_version` is
|
|
233
|
+
added in the same release and its absence means version 1.
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
git clone https://github.com/therajtiwari/durable-agents && cd durable-agents
|
|
239
|
+
uv sync
|
|
240
|
+
|
|
241
|
+
uv run pytest tests/unit # no network, no database
|
|
242
|
+
uv run python examples/quickstart.py # offline, in-memory
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The integration and chaos suites need Docker and Postgres, as do the remaining
|
|
246
|
+
examples:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
docker compose up -d
|
|
250
|
+
uv run durable-agents init-db
|
|
251
|
+
|
|
252
|
+
uv run pytest # everything, ~70s
|
|
253
|
+
uv run python examples/offboarding_agent.py # approval, retry, exactly-once
|
|
254
|
+
uv run python examples/crash_resume_demo.py # kill it, run it again
|
|
255
|
+
uv run durable-agents replay <run_id> # full trace of any run
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Tests that hit a real provider are excluded by default. `pytest -m live
|
|
259
|
+
tests/live` opts in and skips if `LLM_API_KEY` is unset.
|
|
260
|
+
|
|
261
|
+
On Windows, consoles default to a legacy codepage and raise
|
|
262
|
+
`UnicodeEncodeError` on non-ASCII output. The CLI handles this; in your own
|
|
263
|
+
scripts use `sys.stdout.reconfigure(encoding="utf-8", errors="replace")`.
|
|
264
|
+
|
|
265
|
+
## Docs
|
|
266
|
+
|
|
267
|
+
- [`docs/SPEC.md`](https://github.com/therajtiwari/durable-agents/blob/develop/docs/SPEC.md) — architecture and component reference
|
|
268
|
+
- [`docs/THREAT_MODEL.md`](https://github.com/therajtiwari/durable-agents/blob/develop/docs/THREAT_MODEL.md) — guardrail threat model and measurements
|
|
269
|
+
- [`DECISIONS.md`](https://github.com/therajtiwari/durable-agents/blob/develop/DECISIONS.md) — design decisions and rejected alternatives
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
MIT
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# durable-agents
|
|
2
|
+
|
|
3
|
+
[](https://github.com/therajtiwari/durable-agents/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
An event-sourced runtime for LLM agents, backed by Postgres. Runs survive
|
|
6
|
+
process restarts, resume where they stopped without repeating side effects, and
|
|
7
|
+
can pause indefinitely when a step needs human approval.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
Every model call, tool call, and approval is appended to a log before and after
|
|
12
|
+
it happens. Run state is a fold over that log, so a process that dies mid-run
|
|
13
|
+
leaves enough behind for another one to finish the job:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
seq= 0 RunStarted goal='Refund order A-8891, item arrived damaged.'
|
|
17
|
+
seq= 1 LLMCallRequested step=1
|
|
18
|
+
seq= 2 LLMCallFailed step=1 attempt=1 error='429 Too Many Requests'
|
|
19
|
+
seq= 3 LLMCallCompleted step=1 -> issue_refund({'order_id': 'A-8891', 'amount': 6400})
|
|
20
|
+
seq= 4 ToolCallRequested step=1 issue_refund(...) <- process killed here
|
|
21
|
+
seq= 5 ToolCallCompleted step=1 issue_refund -> {...} [recovered]
|
|
22
|
+
seq= 6 RunCompleted final_answer='Refund RF-55012 processed.'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Seq 5 was written by a different process than seq 4. One refund exists, not two.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install durable-agents
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Optional extras: `[openai]` for the bundled provider client, `[api]` for the
|
|
34
|
+
HTTP endpoints.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from durable_agents import Runtime, InMemoryEventStore, tool
|
|
40
|
+
|
|
41
|
+
@tool(side_effect=True)
|
|
42
|
+
async def issue_refund(order_id: str, amount: int, idempotency_key: str) -> dict:
|
|
43
|
+
return await payments.refund(order_id, amount, key=idempotency_key)
|
|
44
|
+
|
|
45
|
+
runtime = Runtime(store=InMemoryEventStore(), llm=my_llm_client, tools=[issue_refund])
|
|
46
|
+
run = await runtime.start(goal="Refund order A-8891, item arrived damaged.")
|
|
47
|
+
|
|
48
|
+
print(run.state.status) # 'completed', 'failed', or 'awaiting_approval'
|
|
49
|
+
print(run.state.final_answer)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The in-memory store dies with the process. For runs that outlive it, swap in
|
|
53
|
+
Postgres:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from durable_agents import PostgresEventStore, create_schema
|
|
57
|
+
|
|
58
|
+
await create_schema(DATABASE_URL) # idempotent, or run: durable-agents init-db
|
|
59
|
+
store = await PostgresEventStore.connect(DATABASE_URL)
|
|
60
|
+
|
|
61
|
+
runtime = Runtime(store=store, llm=my_llm_client, tools=[issue_refund])
|
|
62
|
+
run_id = await runtime.create(goal="Refund order A-8891.") # record, don't run
|
|
63
|
+
state = await runtime.resume(run_id) # run it
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`resume()` is safe to call more than once. On a finished run it returns the
|
|
67
|
+
state; on one killed mid-tool-call it reconciles the dangling operation first.
|
|
68
|
+
|
|
69
|
+
## Writing tools
|
|
70
|
+
|
|
71
|
+
`@tool` derives the JSON schema from your type hints, so every parameter needs
|
|
72
|
+
an annotation. `*args` and `**kwargs` are rejected. The docstring becomes the
|
|
73
|
+
description the model sees.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
@tool(requires_approval=lambda args: args["amount"] > 5000, side_effect=True)
|
|
77
|
+
async def issue_refund(order_id: str, amount: int, idempotency_key: str) -> dict:
|
|
78
|
+
"""Refund an order. Needs approval above 5000."""
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- A parameter named `idempotency_key` is filled in by the runtime with
|
|
83
|
+
`sha256(run_id + seq + tool + args)`. It is stable across restarts, and a
|
|
84
|
+
retry gets the same key.
|
|
85
|
+
- Arguments the function doesn't accept are rejected before it runs, and the
|
|
86
|
+
error goes back to the model to correct.
|
|
87
|
+
- Return a `dict` and it is recorded as-is. Anything else is stored as
|
|
88
|
+
`{"result": <value>}`. Values JSON can't represent (`Decimal`, `datetime`,
|
|
89
|
+
`bytes`) are stored as strings.
|
|
90
|
+
|
|
91
|
+
## Human approval
|
|
92
|
+
|
|
93
|
+
A tool marked `requires_approval` parks the run rather than blocking on it. No
|
|
94
|
+
thread is held and no process stays alive, so the gap can be days.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
run = await runtime.start(goal="Refund order A-8891.")
|
|
98
|
+
if run.state.status == "awaiting_approval":
|
|
99
|
+
print(run.state.pending_approval.tool) # 'issue_refund'
|
|
100
|
+
|
|
101
|
+
# Later, in a different process:
|
|
102
|
+
await runtime.approve(run.id, approver="dana@example.com")
|
|
103
|
+
final = await runtime.resume(run.id)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`approve()` and `deny()` record the decision only; `resume()` does the work. On
|
|
107
|
+
denial the reason is passed back to the model, which can then choose another
|
|
108
|
+
action.
|
|
109
|
+
|
|
110
|
+
## Resuming runs automatically
|
|
111
|
+
|
|
112
|
+
`Worker` polls for runs that need work: new ones, ones a human just approved,
|
|
113
|
+
and ones that have been quiet long enough to look abandoned.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from durable_agents import Worker
|
|
117
|
+
|
|
118
|
+
await Worker(runtime, stale_after_seconds=60.0).run_forever()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Set `stale_after_seconds` above your slowest single operation. Too low and two
|
|
122
|
+
workers pick up the same run, which is safe but doubles that run's model spend.
|
|
123
|
+
|
|
124
|
+
## Guardrails
|
|
125
|
+
|
|
126
|
+
Argument validation runs by default: the tool has to be registered, its
|
|
127
|
+
arguments have to match the declared schema, and numbers stay within any caps
|
|
128
|
+
you configure. A failure is returned to the model to correct rather than ending
|
|
129
|
+
the run.
|
|
130
|
+
|
|
131
|
+
Prompt-injection pattern matching is a separate layer, off unless asked for,
|
|
132
|
+
because the regexes have a substantial false-positive rate against ordinary tool
|
|
133
|
+
output. Profiles are `off`, `validation` (the default), `lenient`, `standard`
|
|
134
|
+
and `strict`.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
runtime = Runtime(store=..., llm=..., guardrail_profile="standard")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
[`docs/THREAT_MODEL.md`](https://github.com/therajtiwari/durable-agents/blob/develop/docs/THREAT_MODEL.md) has the measured attack-success
|
|
141
|
+
and false-positive rates for each profile.
|
|
142
|
+
|
|
143
|
+
## HTTP API
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install durable-agents[api]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from durable_agents.api.app import create_app
|
|
151
|
+
|
|
152
|
+
app = create_app(store, default_max_steps=15)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Endpoint | Description |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `POST /runs` | Record a new run and return its id. Body: `{"goal": "..."}` |
|
|
158
|
+
| `GET /runs/{id}` | Status, pending approval, final answer, totals |
|
|
159
|
+
| `GET /approvals` | Runs currently waiting on a human |
|
|
160
|
+
| `POST /runs/{id}/approve` | Approve a parked run. Body: `{"approver": "..."}` |
|
|
161
|
+
| `POST /runs/{id}/deny` | Reject it. Body: `{"approver": "...", "reason": "..."}` |
|
|
162
|
+
|
|
163
|
+
No endpoint executes a run. Run a `Worker` alongside the API.
|
|
164
|
+
|
|
165
|
+
## Bringing your own model
|
|
166
|
+
|
|
167
|
+
One method:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from durable_agents import LLMClient, LLMResponse
|
|
171
|
+
|
|
172
|
+
class MyClient(LLMClient):
|
|
173
|
+
async def call(self, messages, tools, system_prompt=""):
|
|
174
|
+
...
|
|
175
|
+
return LLMResponse(
|
|
176
|
+
content=..., tool_calls=[...], stop_reason=...,
|
|
177
|
+
input_tokens=..., output_tokens=..., cost_usd=Decimal("0.002"),
|
|
178
|
+
latency_ms=..., provider_request_id=...,
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Two implementations ship. `ScriptedLLM` takes a fixed list of responses, or
|
|
183
|
+
exceptions to simulate a flaky provider. `OpenAICompatibleClient` talks to
|
|
184
|
+
anything speaking the OpenAI chat-completions format: OpenAI, Azure, Groq,
|
|
185
|
+
Together, OpenRouter, Ollama, vLLM.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from durable_agents.llm.openai_compatible import OpenAICompatibleClient
|
|
189
|
+
|
|
190
|
+
llm = OpenAICompatibleClient(
|
|
191
|
+
base_url="https://api.openai.com/v1",
|
|
192
|
+
model="gpt-4o-mini",
|
|
193
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Neither client retries. Retries and their budget belong to the orchestrator.
|
|
198
|
+
|
|
199
|
+
## Limitations
|
|
200
|
+
|
|
201
|
+
- Postgres or in-memory only. No SQLite, MySQL or Redis.
|
|
202
|
+
- Recovery is poll-based. Nothing in the log records that a live process holds a
|
|
203
|
+
run, so `Worker` infers it from silence. No leases, no distributed scheduler.
|
|
204
|
+
|
|
205
|
+
Event fields are only ever added, always with a default, and the meaning of an
|
|
206
|
+
existing field does not change. If that ever has to happen, `schema_version` is
|
|
207
|
+
added in the same release and its absence means version 1.
|
|
208
|
+
|
|
209
|
+
## Development
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
git clone https://github.com/therajtiwari/durable-agents && cd durable-agents
|
|
213
|
+
uv sync
|
|
214
|
+
|
|
215
|
+
uv run pytest tests/unit # no network, no database
|
|
216
|
+
uv run python examples/quickstart.py # offline, in-memory
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The integration and chaos suites need Docker and Postgres, as do the remaining
|
|
220
|
+
examples:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
docker compose up -d
|
|
224
|
+
uv run durable-agents init-db
|
|
225
|
+
|
|
226
|
+
uv run pytest # everything, ~70s
|
|
227
|
+
uv run python examples/offboarding_agent.py # approval, retry, exactly-once
|
|
228
|
+
uv run python examples/crash_resume_demo.py # kill it, run it again
|
|
229
|
+
uv run durable-agents replay <run_id> # full trace of any run
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Tests that hit a real provider are excluded by default. `pytest -m live
|
|
233
|
+
tests/live` opts in and skips if `LLM_API_KEY` is unset.
|
|
234
|
+
|
|
235
|
+
On Windows, consoles default to a legacy codepage and raise
|
|
236
|
+
`UnicodeEncodeError` on non-ASCII output. The CLI handles this; in your own
|
|
237
|
+
scripts use `sys.stdout.reconfigure(encoding="utf-8", errors="replace")`.
|
|
238
|
+
|
|
239
|
+
## Docs
|
|
240
|
+
|
|
241
|
+
- [`docs/SPEC.md`](https://github.com/therajtiwari/durable-agents/blob/develop/docs/SPEC.md) — architecture and component reference
|
|
242
|
+
- [`docs/THREAT_MODEL.md`](https://github.com/therajtiwari/durable-agents/blob/develop/docs/THREAT_MODEL.md) — guardrail threat model and measurements
|
|
243
|
+
- [`DECISIONS.md`](https://github.com/therajtiwari/durable-agents/blob/develop/DECISIONS.md) — design decisions and rejected alternatives
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "durable-agents"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An event-sourced runtime for durable, crash-resumable LLM agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Raj Tiwari" }]
|
|
14
|
+
keywords = ["llm", "agents", "durable-execution", "event-sourcing", "postgres"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pydantic>=2",
|
|
25
|
+
"asyncpg",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
# Without these the PyPI page carries no link back to the source at all.
|
|
30
|
+
Repository = "https://github.com/therajtiwari/durable-agents"
|
|
31
|
+
Issues = "https://github.com/therajtiwari/durable-agents/issues"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# The HTTP approve/deny/status endpoints. Optional because the runtime
|
|
35
|
+
# itself never imports FastAPI — only durable_agents.api.app does, and
|
|
36
|
+
# plenty of users will drive runs from their own application instead.
|
|
37
|
+
api = ["fastapi>=0.115", "uvicorn>=0.30"]
|
|
38
|
+
# OpenAICompatibleClient — only needed if you use the built-in reference
|
|
39
|
+
# LLM client. Anyone implementing their own LLMClient (or using
|
|
40
|
+
# ScriptedLLM) never touches httpx.
|
|
41
|
+
openai = ["httpx>=0.27"]
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest",
|
|
46
|
+
"pytest-asyncio",
|
|
47
|
+
"testcontainers[postgres]",
|
|
48
|
+
"mypy",
|
|
49
|
+
"asyncpg-stubs",
|
|
50
|
+
"httpx>=0.28.1",
|
|
51
|
+
"fastapi>=0.115",
|
|
52
|
+
"uvicorn>=0.30",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[project.scripts]
|
|
56
|
+
durable-agents = "durable_agents.cli:main"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["src/durable_agents"]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.sdist]
|
|
62
|
+
# hatchling's default sdist is "everything not gitignored", which put
|
|
63
|
+
# internal notes, examples/, tests/, .env.example and .github/ onto
|
|
64
|
+
# PyPI's own "Download files" page — in a file most tools never inspect.
|
|
65
|
+
# The wheel was already this narrow; the sdist just never had a rule.
|
|
66
|
+
include = ["src", "LICENSE", "README.md", "pyproject.toml"]
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
strict = true
|
|
70
|
+
# explicit_package_bases (+ these extra roots) lets mypy tell apart
|
|
71
|
+
# tests/integration/conftest.py from tests/live/conftest.py — without
|
|
72
|
+
# it, two conftest.py files with no __init__.py collide as the same
|
|
73
|
+
# top-level module name. The extra roots are needed because pytest's
|
|
74
|
+
# own "rootless" import mode inserts each test directory onto sys.path
|
|
75
|
+
# at runtime, which is what lets tests/guardrails/test_corpus_eval.py
|
|
76
|
+
# and tests/live/test_live_llm.py import their sibling "corpus"/
|
|
77
|
+
# "conftest" modules with a bare `from corpus import ...` — mypy needs
|
|
78
|
+
# telling separately since it doesn't replicate that runtime behavior.
|
|
79
|
+
mypy_path = "src:examples:tests/guardrails:tests/live"
|
|
80
|
+
explicit_package_bases = true
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
asyncio_mode = "strict"
|
|
84
|
+
# The refund demo lives in examples/ rather than the package, so it does
|
|
85
|
+
# not ship in the wheel. Tests need it (it is the scenario most of them
|
|
86
|
+
# exercise) and so do the example scripts, which get examples/ on
|
|
87
|
+
# sys.path for free by being run from there.
|
|
88
|
+
pythonpath = ["examples"]
|
|
89
|
+
markers = [
|
|
90
|
+
"live: hits a real LLM provider over the network; costs real API quota. Never runs by default — explicitly with `pytest -m live tests/live`.",
|
|
91
|
+
]
|
|
92
|
+
# Excludes tests/live from every ordinary run, including CI on every
|
|
93
|
+
# push. The addopts default and an explicit `-m live` combine correctly:
|
|
94
|
+
# pytest keeps only the LAST -m it's given, so `pytest -m live ...`
|
|
95
|
+
# overrides this default rather than conflicting with it.
|
|
96
|
+
addopts = "-m 'not live'"
|