agent-wait 0.4.1__tar.gz → 0.6.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.
- {agent_wait-0.4.1 → agent_wait-0.6.0}/PKG-INFO +107 -32
- {agent_wait-0.4.1 → agent_wait-0.6.0}/README.md +94 -29
- agent_wait-0.6.0/pyproject.toml +111 -0
- agent_wait-0.6.0/reference/wait-sim/README.md +31 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/__init__.py +86 -78
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/base.py +1 -1
- agent_wait-0.6.0/src/agent_wait/aws/__init__.py +19 -0
- agent_wait-0.6.0/src/agent_wait/aws/dynamodb.py +75 -0
- agent_wait-0.6.0/src/agent_wait/aws/eventbridge.py +57 -0
- agent_wait-0.6.0/src/agent_wait/aws/sns.py +51 -0
- agent_wait-0.6.0/src/agent_wait/aws/sqs.py +50 -0
- agent_wait-0.6.0/src/agent_wait/framework.py +55 -0
- agent_wait-0.6.0/src/agent_wait/langgraph/__init__.py +69 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/publish.py +1 -1
- agent_wait-0.6.0/src/agent_wait/pydantic_ai/__init__.py +122 -0
- agent_wait-0.6.0/src/agent_wait/wait.py +203 -0
- agent_wait-0.4.1/pyproject.toml +0 -38
- {agent_wait-0.4.1 → agent_wait-0.6.0}/.gitignore +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/LICENSE +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/composite.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/log.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/webhook.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/errors.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/model.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/policy.py +0 -0
- {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/py.typed +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-wait
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Human-in-the-loop for LangGraph and Pydantic AI agents -- and anything else a run has to wait for. Get the interrupt out of the process (SNS, SQS, EventBridge, DynamoDB, webhook) with everything needed to answer it; get the answer back in.
|
|
5
5
|
Project-URL: Homepage, https://skamalj.github.io/agent-wait/
|
|
6
6
|
Project-URL: Documentation, https://skamalj.github.io/agent-wait/
|
|
7
7
|
Project-URL: Source, https://github.com/skamalj/agent-wait
|
|
@@ -10,7 +10,7 @@ Project-URL: Changelog, https://github.com/skamalj/agent-wait/blob/main/CHANGELO
|
|
|
10
10
|
Author-email: Kamaljeet Singh <skamalj@gmail.com>
|
|
11
11
|
License-Expression: MIT
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Keywords: agents,approval,durable,human-in-the-loop,interrupt,langgraph,serverless
|
|
13
|
+
Keywords: agents,approval,callback,durable,dynamodb,eventbridge,hitl,human-in-the-loop,interrupt,lambda,langchain,langgraph,pydantic-ai,serverless,sns,sqs,webhook
|
|
14
14
|
Classifier: Development Status :: 4 - Beta
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -20,6 +20,16 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
21
21
|
Classifier: Typing :: Typed
|
|
22
22
|
Requires-Python: >=3.12
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: boto3>=1.35; extra == 'all'
|
|
25
|
+
Requires-Dist: langgraph<2,>=1.2; extra == 'all'
|
|
26
|
+
Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'all'
|
|
27
|
+
Provides-Extra: aws
|
|
28
|
+
Requires-Dist: boto3>=1.35; extra == 'aws'
|
|
29
|
+
Provides-Extra: langgraph
|
|
30
|
+
Requires-Dist: langgraph<2,>=1.2; extra == 'langgraph'
|
|
31
|
+
Provides-Extra: pydantic-ai
|
|
32
|
+
Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'pydantic-ai'
|
|
23
33
|
Description-Content-Type: text/markdown
|
|
24
34
|
|
|
25
35
|
# agent-wait
|
|
@@ -29,32 +39,42 @@ Description-Content-Type: text/markdown
|
|
|
29
39
|
[](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
|
|
30
40
|
[](https://skamalj.github.io/agent-wait/)
|
|
31
41
|
|
|
32
|
-
**
|
|
42
|
+
**Make an agent wait for something outside the process — and get the answer back in.**
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
ticket queue or another service, you are writing that code yourself — whether your agent
|
|
39
|
-
is a server that runs for a year or a Lambda that is gone in seconds.
|
|
44
|
+
Human-in-the-loop is the common case: a refund over the limit, a contract clause, a
|
|
45
|
+
deploy. But the same shape covers a vendor callback, a payment processor, a KYC check, or
|
|
46
|
+
another agent — anything the run cannot continue without, and nothing inside the process
|
|
47
|
+
can supply.
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
LangGraph has `interrupt()` for this; Pydantic AI has `ApprovalRequired`. In both, the
|
|
50
|
+
run pauses and the question is handed to whatever called it — and that is where the
|
|
51
|
+
framework stops. There is no built-in way to tell anyone *else* that a question was asked,
|
|
52
|
+
and no built-in way for anyone else to answer it. The moment the question has to reach
|
|
53
|
+
Slack, an approvals dashboard, a ticket queue or another service, you are writing that
|
|
54
|
+
code yourself — whether your agent is a server that runs for a year or a Lambda that is
|
|
55
|
+
gone in seconds.
|
|
56
|
+
|
|
57
|
+
agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
|
|
58
|
+
can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
|
|
59
|
+
it in one envelope, and documents the shape of the answer so the return leg is one `if`.
|
|
44
60
|
|
|
45
61
|
```bash
|
|
46
|
-
pip install agent-wait
|
|
47
|
-
pip install agent-wait-
|
|
62
|
+
pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
|
|
63
|
+
pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
|
|
64
|
+
pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
|
|
48
65
|
```
|
|
49
66
|
|
|
67
|
+
The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
|
|
68
|
+
announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
|
|
69
|
+
|
|
50
70
|
## The whole thing
|
|
51
71
|
|
|
52
72
|
**In the graph** — one decorator, on the function where the decision belongs:
|
|
53
73
|
|
|
54
74
|
```python
|
|
55
75
|
from agent_wait import WaitPolicy
|
|
76
|
+
from agent_wait.langgraph import wait
|
|
56
77
|
from langchain_core.tools import tool
|
|
57
|
-
from langgraph_wait import hitl
|
|
58
78
|
|
|
59
79
|
FINANCE = WaitPolicy(
|
|
60
80
|
timeout="P3D",
|
|
@@ -65,7 +85,7 @@ FINANCE = WaitPolicy(
|
|
|
65
85
|
|
|
66
86
|
|
|
67
87
|
@tool
|
|
68
|
-
@
|
|
88
|
+
@wait(FINANCE)
|
|
69
89
|
def issue_refund(order_id: str, amount: int) -> str:
|
|
70
90
|
payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
|
|
71
91
|
return "refunded"
|
|
@@ -85,23 +105,24 @@ none is enforced — the consumer acts on them:
|
|
|
85
105
|
| `answer_ttl` | How long an answer stays usable after it is given. |
|
|
86
106
|
| `allowed_actions` | Which answers are meaningful — the buttons to draw. |
|
|
87
107
|
| `tags` | Routing hints; become SNS message attributes. |
|
|
108
|
+
| `correlation` | Your reference for the thing being waited on — a vendor job id, a ticket. |
|
|
88
109
|
|
|
89
110
|
A node that needs the answer itself declares a `decision` parameter and always runs,
|
|
90
111
|
approve or not, with the answer in it:
|
|
91
112
|
|
|
92
113
|
```python
|
|
93
|
-
@
|
|
114
|
+
@wait(FINANCE)
|
|
94
115
|
def review(state, decision=None):
|
|
95
116
|
return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
|
|
96
117
|
```
|
|
97
118
|
|
|
98
|
-
The parameter name is yours — `@
|
|
119
|
+
The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
|
|
99
120
|
|
|
100
121
|
**After the run** — one call:
|
|
101
122
|
|
|
102
123
|
```python
|
|
103
|
-
from
|
|
104
|
-
from
|
|
124
|
+
from agent_wait.aws import SnsAnnounce
|
|
125
|
+
from agent_wait.langgraph import publish_interrupts
|
|
105
126
|
|
|
106
127
|
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
|
|
107
128
|
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
@@ -129,14 +150,58 @@ publish_interrupts(result, message["thread_id"], announce)
|
|
|
129
150
|
That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
|
|
130
151
|
resume for a question the thread has moved past — so there is nothing to check.
|
|
131
152
|
|
|
153
|
+
## The same two names on Pydantic AI
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from agent_wait.pydantic_ai import publish_interrupts, wait
|
|
157
|
+
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext, ToolApproved, ToolDenied
|
|
158
|
+
|
|
159
|
+
agent = Agent("openai:gpt-5.4", output_type=[str, DeferredToolRequests])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@agent.tool
|
|
163
|
+
@wait(FINANCE)
|
|
164
|
+
def issue_refund(ctx: RunContext[None], order_id: str, amount: int) -> str:
|
|
165
|
+
payments.refund(order_id, amount)
|
|
166
|
+
return "refunded"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
result = agent.run_sync("refund order 4471", message_history=history)
|
|
170
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
171
|
+
store(thread_id, result.all_messages()) # Pydantic AI has no checkpointer; the history is yours
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The run ends with `DeferredToolRequests` in `result.output`; `question_id` is the
|
|
175
|
+
`tool_call_id`. The answer goes back through the framework's own resume, with the answer
|
|
176
|
+
itself riding in `metadata` so the `@wait` rules apply unchanged:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
approved = answer.get("action") == "approve"
|
|
180
|
+
result = agent.run_sync(
|
|
181
|
+
None,
|
|
182
|
+
message_history=load(thread_id),
|
|
183
|
+
deferred_tool_results=DeferredToolResults(
|
|
184
|
+
approvals={question_id: ToolApproved() if approved else ToolDenied(str(answer))},
|
|
185
|
+
metadata={question_id: answer},
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
store(thread_id, result.all_messages()) # before acknowledging the answer message
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Two rules the framework leaves to you: name the `RunContext` parameter `ctx`, and store
|
|
192
|
+
the post-run history *before* you acknowledge an answer — a duplicate answer is refused
|
|
193
|
+
only if the history you pass already holds the tool's return. `requires_approval=True`
|
|
194
|
+
tools and `CallDeferred` calls (a job handed to an external system) are published too,
|
|
195
|
+
with the default policy.
|
|
196
|
+
|
|
132
197
|
## Async mode — the thread does not park
|
|
133
198
|
|
|
134
199
|
```python
|
|
135
|
-
from
|
|
200
|
+
from agent_wait.aws import DynamoDbAnnounce, SnsAnnounce
|
|
136
201
|
|
|
137
202
|
|
|
138
203
|
@tool
|
|
139
|
-
@
|
|
204
|
+
@wait(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
|
|
140
205
|
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
141
206
|
```
|
|
142
207
|
|
|
@@ -165,7 +230,7 @@ question; the `DynamoDbAnnounce` row (or whatever you keep) is the only record.
|
|
|
165
230
|
```
|
|
166
231
|
|
|
167
232
|
`reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
|
|
168
|
-
`answer` is what the `@
|
|
233
|
+
`answer` is what the `@wait` function receives — verbatim.
|
|
169
234
|
|
|
170
235
|
Full schema, the recommended answer shape, and how to deduplicate:
|
|
171
236
|
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
@@ -191,8 +256,8 @@ class RedisAnnounce(BaseAnnounce):
|
|
|
191
256
|
|
|
192
257
|
A raise inside `deliver()` becomes a log line; the run completes.
|
|
193
258
|
|
|
194
|
-
Shipped: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
|
|
195
|
-
|
|
259
|
+
Shipped in the core: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
|
|
260
|
+
`InMemoryAnnounce`. With `[aws]`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
|
|
196
261
|
`DynamoDbAnnounce` (the question as a row an approvals UI can query). Each one, its
|
|
197
262
|
constructor and where the question lands: [Announcers](https://skamalj.github.io/agent-wait/announcers/).
|
|
198
263
|
Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
|
|
@@ -208,7 +273,7 @@ Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an
|
|
|
208
273
|
## Two things to know
|
|
209
274
|
|
|
210
275
|
**Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
|
|
211
|
-
tool is called, in its own shape; `@
|
|
276
|
+
tool is called, in its own shape; `@wait` interrupts inside the call. Both on one tool
|
|
212
277
|
means two interrupts for one approval. Use one or the other.
|
|
213
278
|
|
|
214
279
|
**One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
|
|
@@ -219,12 +284,22 @@ interrupting tool its own node. Pinned by a test that fails if LangGraph changes
|
|
|
219
284
|
|
|
220
285
|
## Layout
|
|
221
286
|
|
|
287
|
+
One distribution, one import root. Frameworks and providers are subpackages behind extras:
|
|
288
|
+
|
|
222
289
|
```
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
290
|
+
agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
|
|
291
|
+
Webhook/Log/InMemory announcers, and the Framework interface. No deps.
|
|
292
|
+
agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
|
|
293
|
+
agent_wait.pydantic_ai [pydantic-ai] the same two names bound to Pydantic AI's deferred tools.
|
|
294
|
+
agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
|
|
295
|
+
examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
|
|
296
|
+
docs/ message contract, architecture, announcer guide, consumer guide.
|
|
228
297
|
```
|
|
229
298
|
|
|
299
|
+
`@wait` and `publish_interrupts` are written once against a three-method `Framework`
|
|
300
|
+
interface; `agent_wait.langgraph` and `agent_wait.pydantic_ai` are implementors of it,
|
|
301
|
+
about fifty lines each. Announcers are the same split: `BaseAnnounce` is the interface,
|
|
302
|
+
each provider subpackage the implementors. See
|
|
303
|
+
[Architecture](https://skamalj.github.io/agent-wait/architecture/).
|
|
304
|
+
|
|
230
305
|
MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
|
|
@@ -5,32 +5,42 @@
|
|
|
5
5
|
[](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
|
|
6
6
|
[](https://skamalj.github.io/agent-wait/)
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**Make an agent wait for something outside the process — and get the answer back in.**
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
ticket queue or another service, you are writing that code yourself — whether your agent
|
|
15
|
-
is a server that runs for a year or a Lambda that is gone in seconds.
|
|
10
|
+
Human-in-the-loop is the common case: a refund over the limit, a contract clause, a
|
|
11
|
+
deploy. But the same shape covers a vendor callback, a payment processor, a KYC check, or
|
|
12
|
+
another agent — anything the run cannot continue without, and nothing inside the process
|
|
13
|
+
can supply.
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
LangGraph has `interrupt()` for this; Pydantic AI has `ApprovalRequired`. In both, the
|
|
16
|
+
run pauses and the question is handed to whatever called it — and that is where the
|
|
17
|
+
framework stops. There is no built-in way to tell anyone *else* that a question was asked,
|
|
18
|
+
and no built-in way for anyone else to answer it. The moment the question has to reach
|
|
19
|
+
Slack, an approvals dashboard, a ticket queue or another service, you are writing that
|
|
20
|
+
code yourself — whether your agent is a server that runs for a year or a Lambda that is
|
|
21
|
+
gone in seconds.
|
|
22
|
+
|
|
23
|
+
agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
|
|
24
|
+
can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
|
|
25
|
+
it in one envelope, and documents the shape of the answer so the return leg is one `if`.
|
|
20
26
|
|
|
21
27
|
```bash
|
|
22
|
-
pip install agent-wait
|
|
23
|
-
pip install agent-wait-
|
|
28
|
+
pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
|
|
29
|
+
pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
|
|
30
|
+
pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
|
|
24
31
|
```
|
|
25
32
|
|
|
33
|
+
The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
|
|
34
|
+
announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
|
|
35
|
+
|
|
26
36
|
## The whole thing
|
|
27
37
|
|
|
28
38
|
**In the graph** — one decorator, on the function where the decision belongs:
|
|
29
39
|
|
|
30
40
|
```python
|
|
31
41
|
from agent_wait import WaitPolicy
|
|
42
|
+
from agent_wait.langgraph import wait
|
|
32
43
|
from langchain_core.tools import tool
|
|
33
|
-
from langgraph_wait import hitl
|
|
34
44
|
|
|
35
45
|
FINANCE = WaitPolicy(
|
|
36
46
|
timeout="P3D",
|
|
@@ -41,7 +51,7 @@ FINANCE = WaitPolicy(
|
|
|
41
51
|
|
|
42
52
|
|
|
43
53
|
@tool
|
|
44
|
-
@
|
|
54
|
+
@wait(FINANCE)
|
|
45
55
|
def issue_refund(order_id: str, amount: int) -> str:
|
|
46
56
|
payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
|
|
47
57
|
return "refunded"
|
|
@@ -61,23 +71,24 @@ none is enforced — the consumer acts on them:
|
|
|
61
71
|
| `answer_ttl` | How long an answer stays usable after it is given. |
|
|
62
72
|
| `allowed_actions` | Which answers are meaningful — the buttons to draw. |
|
|
63
73
|
| `tags` | Routing hints; become SNS message attributes. |
|
|
74
|
+
| `correlation` | Your reference for the thing being waited on — a vendor job id, a ticket. |
|
|
64
75
|
|
|
65
76
|
A node that needs the answer itself declares a `decision` parameter and always runs,
|
|
66
77
|
approve or not, with the answer in it:
|
|
67
78
|
|
|
68
79
|
```python
|
|
69
|
-
@
|
|
80
|
+
@wait(FINANCE)
|
|
70
81
|
def review(state, decision=None):
|
|
71
82
|
return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
|
|
72
83
|
```
|
|
73
84
|
|
|
74
|
-
The parameter name is yours — `@
|
|
85
|
+
The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
|
|
75
86
|
|
|
76
87
|
**After the run** — one call:
|
|
77
88
|
|
|
78
89
|
```python
|
|
79
|
-
from
|
|
80
|
-
from
|
|
90
|
+
from agent_wait.aws import SnsAnnounce
|
|
91
|
+
from agent_wait.langgraph import publish_interrupts
|
|
81
92
|
|
|
82
93
|
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
|
|
83
94
|
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
@@ -105,14 +116,58 @@ publish_interrupts(result, message["thread_id"], announce)
|
|
|
105
116
|
That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
|
|
106
117
|
resume for a question the thread has moved past — so there is nothing to check.
|
|
107
118
|
|
|
119
|
+
## The same two names on Pydantic AI
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from agent_wait.pydantic_ai import publish_interrupts, wait
|
|
123
|
+
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext, ToolApproved, ToolDenied
|
|
124
|
+
|
|
125
|
+
agent = Agent("openai:gpt-5.4", output_type=[str, DeferredToolRequests])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@agent.tool
|
|
129
|
+
@wait(FINANCE)
|
|
130
|
+
def issue_refund(ctx: RunContext[None], order_id: str, amount: int) -> str:
|
|
131
|
+
payments.refund(order_id, amount)
|
|
132
|
+
return "refunded"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
result = agent.run_sync("refund order 4471", message_history=history)
|
|
136
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
137
|
+
store(thread_id, result.all_messages()) # Pydantic AI has no checkpointer; the history is yours
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The run ends with `DeferredToolRequests` in `result.output`; `question_id` is the
|
|
141
|
+
`tool_call_id`. The answer goes back through the framework's own resume, with the answer
|
|
142
|
+
itself riding in `metadata` so the `@wait` rules apply unchanged:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
approved = answer.get("action") == "approve"
|
|
146
|
+
result = agent.run_sync(
|
|
147
|
+
None,
|
|
148
|
+
message_history=load(thread_id),
|
|
149
|
+
deferred_tool_results=DeferredToolResults(
|
|
150
|
+
approvals={question_id: ToolApproved() if approved else ToolDenied(str(answer))},
|
|
151
|
+
metadata={question_id: answer},
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
store(thread_id, result.all_messages()) # before acknowledging the answer message
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Two rules the framework leaves to you: name the `RunContext` parameter `ctx`, and store
|
|
158
|
+
the post-run history *before* you acknowledge an answer — a duplicate answer is refused
|
|
159
|
+
only if the history you pass already holds the tool's return. `requires_approval=True`
|
|
160
|
+
tools and `CallDeferred` calls (a job handed to an external system) are published too,
|
|
161
|
+
with the default policy.
|
|
162
|
+
|
|
108
163
|
## Async mode — the thread does not park
|
|
109
164
|
|
|
110
165
|
```python
|
|
111
|
-
from
|
|
166
|
+
from agent_wait.aws import DynamoDbAnnounce, SnsAnnounce
|
|
112
167
|
|
|
113
168
|
|
|
114
169
|
@tool
|
|
115
|
-
@
|
|
170
|
+
@wait(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
|
|
116
171
|
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
117
172
|
```
|
|
118
173
|
|
|
@@ -141,7 +196,7 @@ question; the `DynamoDbAnnounce` row (or whatever you keep) is the only record.
|
|
|
141
196
|
```
|
|
142
197
|
|
|
143
198
|
`reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
|
|
144
|
-
`answer` is what the `@
|
|
199
|
+
`answer` is what the `@wait` function receives — verbatim.
|
|
145
200
|
|
|
146
201
|
Full schema, the recommended answer shape, and how to deduplicate:
|
|
147
202
|
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
@@ -167,8 +222,8 @@ class RedisAnnounce(BaseAnnounce):
|
|
|
167
222
|
|
|
168
223
|
A raise inside `deliver()` becomes a log line; the run completes.
|
|
169
224
|
|
|
170
|
-
Shipped: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
|
|
171
|
-
|
|
225
|
+
Shipped in the core: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
|
|
226
|
+
`InMemoryAnnounce`. With `[aws]`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
|
|
172
227
|
`DynamoDbAnnounce` (the question as a row an approvals UI can query). Each one, its
|
|
173
228
|
constructor and where the question lands: [Announcers](https://skamalj.github.io/agent-wait/announcers/).
|
|
174
229
|
Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
|
|
@@ -184,7 +239,7 @@ Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an
|
|
|
184
239
|
## Two things to know
|
|
185
240
|
|
|
186
241
|
**Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
|
|
187
|
-
tool is called, in its own shape; `@
|
|
242
|
+
tool is called, in its own shape; `@wait` interrupts inside the call. Both on one tool
|
|
188
243
|
means two interrupts for one approval. Use one or the other.
|
|
189
244
|
|
|
190
245
|
**One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
|
|
@@ -195,12 +250,22 @@ interrupting tool its own node. Pinned by a test that fails if LangGraph changes
|
|
|
195
250
|
|
|
196
251
|
## Layout
|
|
197
252
|
|
|
253
|
+
One distribution, one import root. Frameworks and providers are subpackages behind extras:
|
|
254
|
+
|
|
198
255
|
```
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
256
|
+
agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
|
|
257
|
+
Webhook/Log/InMemory announcers, and the Framework interface. No deps.
|
|
258
|
+
agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
|
|
259
|
+
agent_wait.pydantic_ai [pydantic-ai] the same two names bound to Pydantic AI's deferred tools.
|
|
260
|
+
agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
|
|
261
|
+
examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
|
|
262
|
+
docs/ message contract, architecture, announcer guide, consumer guide.
|
|
204
263
|
```
|
|
205
264
|
|
|
265
|
+
`@wait` and `publish_interrupts` are written once against a three-method `Framework`
|
|
266
|
+
interface; `agent_wait.langgraph` and `agent_wait.pydantic_ai` are implementors of it,
|
|
267
|
+
about fifty lines each. Announcers are the same split: `BaseAnnounce` is the interface,
|
|
268
|
+
each provider subpackage the implementors. See
|
|
269
|
+
[Architecture](https://skamalj.github.io/agent-wait/architecture/).
|
|
270
|
+
|
|
206
271
|
MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-wait"
|
|
3
|
+
version = "0.6.0"
|
|
4
|
+
description = "Human-in-the-loop for LangGraph and Pydantic AI agents -- and anything else a run has to wait for. Get the interrupt out of the process (SNS, SQS, EventBridge, DynamoDB, webhook) with everything needed to answer it; get the answer back in."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Kamaljeet Singh", email = "skamalj@gmail.com" }]
|
|
10
|
+
keywords = [
|
|
11
|
+
"human-in-the-loop",
|
|
12
|
+
"hitl",
|
|
13
|
+
"approval",
|
|
14
|
+
"langgraph",
|
|
15
|
+
"langchain",
|
|
16
|
+
"pydantic-ai",
|
|
17
|
+
"agents",
|
|
18
|
+
"interrupt",
|
|
19
|
+
"durable",
|
|
20
|
+
"serverless",
|
|
21
|
+
"lambda",
|
|
22
|
+
"sqs",
|
|
23
|
+
"sns",
|
|
24
|
+
"eventbridge",
|
|
25
|
+
"dynamodb",
|
|
26
|
+
"webhook",
|
|
27
|
+
"callback",
|
|
28
|
+
]
|
|
29
|
+
classifiers = [
|
|
30
|
+
"Development Status :: 4 - Beta",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"License :: OSI Approved :: MIT License",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Programming Language :: Python :: 3.13",
|
|
36
|
+
"Topic :: Software Development :: Libraries",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
]
|
|
39
|
+
dependencies = []
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
langgraph = ["langgraph>=1.2,<2"]
|
|
43
|
+
pydantic-ai = ["pydantic-ai-slim>=2.43,<3"]
|
|
44
|
+
aws = ["boto3>=1.35"]
|
|
45
|
+
all = ["agent-wait[langgraph,pydantic-ai,aws]"]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://skamalj.github.io/agent-wait/"
|
|
49
|
+
Documentation = "https://skamalj.github.io/agent-wait/"
|
|
50
|
+
Source = "https://github.com/skamalj/agent-wait"
|
|
51
|
+
Issues = "https://github.com/skamalj/agent-wait/issues"
|
|
52
|
+
Changelog = "https://github.com/skamalj/agent-wait/blob/main/CHANGELOG.md"
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["hatchling"]
|
|
56
|
+
build-backend = "hatchling.build"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["src/agent_wait"]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.sdist]
|
|
62
|
+
include = ["src/agent_wait", "README.md", "LICENSE"]
|
|
63
|
+
|
|
64
|
+
[dependency-groups]
|
|
65
|
+
dev = [
|
|
66
|
+
"agent-wait[all]",
|
|
67
|
+
"pytest>=8.3",
|
|
68
|
+
"pytest-cov>=5.0",
|
|
69
|
+
"ruff>=0.6",
|
|
70
|
+
"pyright>=1.1.380",
|
|
71
|
+
"moto[dynamodb,sqs,sns,events]>=5.0",
|
|
72
|
+
"boto3-stubs[dynamodb,sqs,sns,events]>=1.35",
|
|
73
|
+
"langgraph-checkpoint-sqlite>=2.0",
|
|
74
|
+
"aws-cdk-lib>=2.160",
|
|
75
|
+
"constructs>=10.3",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.uv.sources]
|
|
79
|
+
agent-wait = { workspace = true }
|
|
80
|
+
|
|
81
|
+
[tool.uv.workspace]
|
|
82
|
+
members = ["."]
|
|
83
|
+
|
|
84
|
+
[tool.pytest.ini_options]
|
|
85
|
+
testpaths = ["tests", "examples"]
|
|
86
|
+
addopts = "-q --strict-markers"
|
|
87
|
+
norecursedirs = ["cdk.out", "build", ".venv", "reference"]
|
|
88
|
+
markers = [
|
|
89
|
+
"e2e: requires real AWS; opt in with AGENT_WAIT_E2E=1",
|
|
90
|
+
"spike: empirical probes of LangGraph behaviour",
|
|
91
|
+
]
|
|
92
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
93
|
+
|
|
94
|
+
[tool.ruff]
|
|
95
|
+
line-length = 110
|
|
96
|
+
target-version = "py312"
|
|
97
|
+
extend-exclude = ["reference", "cdk.out"]
|
|
98
|
+
|
|
99
|
+
[tool.ruff.lint]
|
|
100
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
101
|
+
ignore = ["B008", "RUF012"]
|
|
102
|
+
|
|
103
|
+
[tool.ruff.lint.per-file-ignores]
|
|
104
|
+
"tests/**" = ["E501"]
|
|
105
|
+
|
|
106
|
+
[tool.pyright]
|
|
107
|
+
include = ["src"]
|
|
108
|
+
exclude = ["reference", "**/node_modules", "**/__pycache__"]
|
|
109
|
+
pythonVersion = "3.12"
|
|
110
|
+
typeCheckingMode = "strict"
|
|
111
|
+
reportMissingTypeStubs = false
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# agent-wait · end-to-end simulation (LangGraph agent on Lambda)
|
|
2
|
+
|
|
3
|
+
Runs a real LangGraph graph (1.2.x) through the durable-wait design, with in-memory stand-ins
|
|
4
|
+
for the AWS services. Every stand-in mimics the one property the design relies on:
|
|
5
|
+
|
|
6
|
+
| Stand-in | Real service | Property relied on |
|
|
7
|
+
|---------------------|-----------------------|------------------------------------------------------|
|
|
8
|
+
| `DynamoWaitStore` | DynamoDB | conditional writes (status CAS), unique idempotency key |
|
|
9
|
+
| `SqsFifo` | SQS FIFO | one in-flight message per MessageGroupId, visibility timeout, redelivery |
|
|
10
|
+
| `EventBridgeScheduler` | EventBridge Scheduler | one-time schedule named by wait_id, deleted on cancel |
|
|
11
|
+
| `EventBridgeBus` | EventBridge | fan-out to subscribers (SNS/Slack/webhooks) |
|
|
12
|
+
| `run_handler` etc. | Lambda | stateless invocations that can die mid-way |
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
pip install langgraph
|
|
16
|
+
python simulate.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Files:
|
|
20
|
+
- `agent_wait/core.py` — the framework-agnostic core: Wait model, 5 ports, HMAC tokens, Waiter, sweeper
|
|
21
|
+
- `agent_wait/langgraph_adapter.py` — `ask()` + the LangGraph adapter (extract interrupts, build resume, still_pending)
|
|
22
|
+
- `agent_wait/aws_sim.py` — the AWS stand-ins
|
|
23
|
+
- `app/graph.py` — the customer's refund graph (knows nothing about AWS)
|
|
24
|
+
- `app/lambdas.py` — three Lambda handlers: run (SQS), answer (API Gateway), on_timer (Scheduler)
|
|
25
|
+
- `simulate.py` — four scenarios; `RUN_OUTPUT.txt` is a captured run
|
|
26
|
+
|
|
27
|
+
Scenarios:
|
|
28
|
+
- A crash after checkpoint but before register → redelivery → idempotent; double-click; stale reject; tampered token; resume on another Lambda; refund issued once
|
|
29
|
+
- B no answer for 3 days → Scheduler fires → default reject → late click gets 409
|
|
30
|
+
- C crash after resume applied but before SQS ack → redelivery → still_pending() guard → no second refund
|
|
31
|
+
- D crash after DynamoDB create but before timer/notify → sweeper completes setup
|