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.
Files changed (36) hide show
  1. durable_agents-0.1.0/.gitignore +11 -0
  2. durable_agents-0.1.0/LICENSE +21 -0
  3. durable_agents-0.1.0/PKG-INFO +273 -0
  4. durable_agents-0.1.0/README.md +247 -0
  5. durable_agents-0.1.0/pyproject.toml +96 -0
  6. durable_agents-0.1.0/src/durable_agents/__init__.py +126 -0
  7. durable_agents-0.1.0/src/durable_agents/api/__init__.py +0 -0
  8. durable_agents-0.1.0/src/durable_agents/api/app.py +226 -0
  9. durable_agents-0.1.0/src/durable_agents/cli.py +187 -0
  10. durable_agents-0.1.0/src/durable_agents/events.py +206 -0
  11. durable_agents-0.1.0/src/durable_agents/guardrails/__init__.py +0 -0
  12. durable_agents-0.1.0/src/durable_agents/guardrails/decisions.py +223 -0
  13. durable_agents-0.1.0/src/durable_agents/guardrails/input_scan.py +24 -0
  14. durable_agents-0.1.0/src/durable_agents/guardrails/output_validate.py +75 -0
  15. durable_agents-0.1.0/src/durable_agents/guardrails/patterns.py +199 -0
  16. durable_agents-0.1.0/src/durable_agents/guardrails/run_level.py +62 -0
  17. durable_agents-0.1.0/src/durable_agents/guardrails/tool_result_scan.py +37 -0
  18. durable_agents-0.1.0/src/durable_agents/guardrails/types.py +26 -0
  19. durable_agents-0.1.0/src/durable_agents/llm/__init__.py +0 -0
  20. durable_agents-0.1.0/src/durable_agents/llm/openai_compatible.py +193 -0
  21. durable_agents-0.1.0/src/durable_agents/llm/protocol.py +44 -0
  22. durable_agents-0.1.0/src/durable_agents/llm/scripted.py +41 -0
  23. durable_agents-0.1.0/src/durable_agents/orchestrator.py +894 -0
  24. durable_agents-0.1.0/src/durable_agents/py.typed +0 -0
  25. durable_agents-0.1.0/src/durable_agents/replay_view.py +309 -0
  26. durable_agents-0.1.0/src/durable_agents/runtime.py +209 -0
  27. durable_agents-0.1.0/src/durable_agents/state.py +341 -0
  28. durable_agents-0.1.0/src/durable_agents/storage/__init__.py +0 -0
  29. durable_agents-0.1.0/src/durable_agents/storage/memory.py +101 -0
  30. durable_agents-0.1.0/src/durable_agents/storage/postgres.py +143 -0
  31. durable_agents-0.1.0/src/durable_agents/storage/protocol.py +91 -0
  32. durable_agents-0.1.0/src/durable_agents/storage/schema.py +31 -0
  33. durable_agents-0.1.0/src/durable_agents/storage/schema.sql +27 -0
  34. durable_agents-0.1.0/src/durable_agents/tools/__init__.py +0 -0
  35. durable_agents-0.1.0/src/durable_agents/tools/registry.py +151 -0
  36. 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
+ [![CI](https://github.com/therajtiwari/durable-agents/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/therajtiwari/durable-agents/actions/workflows/ci.yml/badge.svg)](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'"