agent-wait 0.4.0__tar.gz → 0.5.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.0 → agent_wait-0.5.0}/PKG-INFO +85 -45
- {agent_wait-0.4.0 → agent_wait-0.5.0}/README.md +75 -42
- agent_wait-0.5.0/pyproject.toml +109 -0
- agent_wait-0.5.0/reference/wait-sim/README.md +31 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/__init__.py +85 -78
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/base.py +1 -1
- agent_wait-0.5.0/src/agent_wait/aws/__init__.py +19 -0
- agent_wait-0.5.0/src/agent_wait/aws/dynamodb.py +75 -0
- agent_wait-0.5.0/src/agent_wait/aws/eventbridge.py +57 -0
- agent_wait-0.5.0/src/agent_wait/aws/sns.py +51 -0
- agent_wait-0.5.0/src/agent_wait/aws/sqs.py +50 -0
- agent_wait-0.5.0/src/agent_wait/framework.py +55 -0
- agent_wait-0.5.0/src/agent_wait/langgraph/__init__.py +69 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/publish.py +1 -1
- agent_wait-0.5.0/src/agent_wait/wait.py +203 -0
- agent_wait-0.4.0/pyproject.toml +0 -38
- {agent_wait-0.4.0 → agent_wait-0.5.0}/.gitignore +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/LICENSE +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/composite.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/log.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/webhook.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/errors.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/model.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/policy.py +0 -0
- {agent_wait-0.4.0 → agent_wait-0.5.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.5.0
|
|
4
|
+
Summary: Human-in-the-loop for LangGraph 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,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,13 @@ 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
|
+
Provides-Extra: aws
|
|
27
|
+
Requires-Dist: boto3>=1.35; extra == 'aws'
|
|
28
|
+
Provides-Extra: langgraph
|
|
29
|
+
Requires-Dist: langgraph<2,>=1.2; extra == 'langgraph'
|
|
23
30
|
Description-Content-Type: text/markdown
|
|
24
31
|
|
|
25
32
|
# agent-wait
|
|
@@ -27,32 +34,42 @@ Description-Content-Type: text/markdown
|
|
|
27
34
|
[](https://pypi.org/project/agent-wait/)
|
|
28
35
|
[](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
|
|
29
36
|
[](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
|
|
37
|
+
[](https://skamalj.github.io/agent-wait/)
|
|
30
38
|
|
|
31
|
-
**
|
|
39
|
+
**Make an agent wait for something outside the process — and get the answer back in.**
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
ticket queue or another service, you are writing that code yourself — whether your agent
|
|
38
|
-
is a server that runs for a year or a Lambda that is gone in seconds.
|
|
41
|
+
Human-in-the-loop is the common case: a refund over the limit, a contract clause, a
|
|
42
|
+
deploy. But the same shape covers a vendor callback, a payment processor, a KYC check, or
|
|
43
|
+
another agent — anything the run cannot continue without, and nothing inside the process
|
|
44
|
+
can supply.
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
LangGraph has `interrupt()` for this. When a node calls it the graph pauses and the
|
|
47
|
+
interrupt is handed to whatever called `invoke()` — and that is where LangGraph stops.
|
|
48
|
+
There is no built-in way to tell anyone *else* that a question was asked, and no built-in
|
|
49
|
+
way for anyone else to answer it. The moment the question has to reach Slack, an approvals
|
|
50
|
+
dashboard, a ticket queue or another service, you are writing that code yourself — whether
|
|
51
|
+
your agent is a server that runs for a year or a Lambda that is gone in seconds.
|
|
52
|
+
|
|
53
|
+
agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
|
|
54
|
+
can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
|
|
55
|
+
it in one envelope, and documents the shape of the answer so the return leg is one `if`.
|
|
43
56
|
|
|
44
57
|
```bash
|
|
45
|
-
pip install agent-wait
|
|
46
|
-
pip install agent-wait
|
|
58
|
+
pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
|
|
59
|
+
pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
|
|
47
60
|
```
|
|
48
61
|
|
|
62
|
+
The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
|
|
63
|
+
announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
|
|
64
|
+
|
|
49
65
|
## The whole thing
|
|
50
66
|
|
|
51
67
|
**In the graph** — one decorator, on the function where the decision belongs:
|
|
52
68
|
|
|
53
69
|
```python
|
|
54
70
|
from agent_wait import WaitPolicy
|
|
55
|
-
from
|
|
71
|
+
from agent_wait.langgraph import wait
|
|
72
|
+
from langchain_core.tools import tool
|
|
56
73
|
|
|
57
74
|
FINANCE = WaitPolicy(
|
|
58
75
|
timeout="P3D",
|
|
@@ -63,7 +80,7 @@ FINANCE = WaitPolicy(
|
|
|
63
80
|
|
|
64
81
|
|
|
65
82
|
@tool
|
|
66
|
-
@
|
|
83
|
+
@wait(FINANCE)
|
|
67
84
|
def issue_refund(order_id: str, amount: int) -> str:
|
|
68
85
|
payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
|
|
69
86
|
return "refunded"
|
|
@@ -73,19 +90,34 @@ Calling `issue_refund(...)` inside a run parks the graph on `{"function": "issue
|
|
|
73
90
|
"args": {...}}`. On resume, `{"action": "approve"}` runs the body — optionally with edited
|
|
74
91
|
`args` — and anything else is returned in its place, so a model sees why it did not run.
|
|
75
92
|
|
|
76
|
-
|
|
93
|
+
The policy is what the asker declares about the question. Every field is published and
|
|
94
|
+
none is enforced — the consumer acts on them:
|
|
95
|
+
|
|
96
|
+
| field | meaning |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `timeout` | ISO 8601 duration. Published as an absolute `expires_at`. |
|
|
99
|
+
| `default` | What to send as the answer if nobody answers by then. |
|
|
100
|
+
| `answer_ttl` | How long an answer stays usable after it is given. |
|
|
101
|
+
| `allowed_actions` | Which answers are meaningful — the buttons to draw. |
|
|
102
|
+
| `tags` | Routing hints; become SNS message attributes. |
|
|
103
|
+
| `correlation` | Your reference for the thing being waited on — a vendor job id, a ticket. |
|
|
104
|
+
|
|
105
|
+
A node that needs the answer itself declares a `decision` parameter and always runs,
|
|
106
|
+
approve or not, with the answer in it:
|
|
77
107
|
|
|
78
108
|
```python
|
|
79
|
-
@
|
|
109
|
+
@wait(FINANCE)
|
|
80
110
|
def review(state, decision=None):
|
|
81
111
|
return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
|
|
82
112
|
```
|
|
83
113
|
|
|
114
|
+
The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
|
|
115
|
+
|
|
84
116
|
**After the run** — one call:
|
|
85
117
|
|
|
86
118
|
```python
|
|
87
|
-
from
|
|
88
|
-
from
|
|
119
|
+
from agent_wait.aws import SnsAnnounce
|
|
120
|
+
from agent_wait.langgraph import publish_interrupts
|
|
89
121
|
|
|
90
122
|
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
|
|
91
123
|
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
@@ -116,16 +148,21 @@ resume for a question the thread has moved past — so there is nothing to check
|
|
|
116
148
|
## Async mode — the thread does not park
|
|
117
149
|
|
|
118
150
|
```python
|
|
151
|
+
from agent_wait.aws import DynamoDbAnnounce, SnsAnnounce
|
|
152
|
+
|
|
153
|
+
|
|
119
154
|
@tool
|
|
120
|
-
@
|
|
155
|
+
@wait(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
|
|
121
156
|
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
122
157
|
```
|
|
123
158
|
|
|
124
159
|
The decorator *is* the publisher: the call announces the question and returns
|
|
125
|
-
`{"status": "pending_approval", "question_id": ...}`
|
|
126
|
-
on; nothing is parked; nothing to call after
|
|
127
|
-
message and your graph acts on it. This is
|
|
128
|
-
WhatsApp, where the approver is not the person
|
|
160
|
+
`{"status": "pending_approval", "question_id": "...", "function": "issue_refund"}`
|
|
161
|
+
without running the body. The graph carries on; nothing is parked; nothing to call after
|
|
162
|
+
the run. The decision arrives later as a new message and your graph acts on it. This is
|
|
163
|
+
the mode for a single-thread channel like WhatsApp, where the approver is not the person
|
|
164
|
+
on the thread — and since nothing is parked, LangGraph remembers nothing about the
|
|
165
|
+
question; the `DynamoDbAnnounce` row (or whatever you keep) is the only record.
|
|
129
166
|
|
|
130
167
|
## What goes out
|
|
131
168
|
|
|
@@ -144,7 +181,7 @@ WhatsApp, where the approver is not the person on the thread.
|
|
|
144
181
|
```
|
|
145
182
|
|
|
146
183
|
`reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
|
|
147
|
-
`answer` is what the `@
|
|
184
|
+
`answer` is what the `@wait` function receives — verbatim.
|
|
148
185
|
|
|
149
186
|
Full schema, the recommended answer shape, and how to deduplicate:
|
|
150
187
|
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
@@ -168,19 +205,13 @@ class RedisAnnounce(BaseAnnounce):
|
|
|
168
205
|
self.client.set(envelope.dedupe_key, envelope.to_json())
|
|
169
206
|
```
|
|
170
207
|
|
|
171
|
-
A raise inside `deliver()` becomes a log line; the run completes.
|
|
172
|
-
|
|
173
|
-
| Adapter | Package | Where the question lands |
|
|
174
|
-
|---|---|---|
|
|
175
|
-
| `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
|
|
176
|
-
| `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
|
|
177
|
-
| `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
|
|
178
|
-
| `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
|
|
179
|
-
| `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
|
|
180
|
-
| `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
|
|
181
|
-
| `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
|
|
208
|
+
A raise inside `deliver()` becomes a log line; the run completes.
|
|
182
209
|
|
|
183
|
-
|
|
210
|
+
Shipped in the core: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
|
|
211
|
+
`InMemoryAnnounce`. With `[aws]`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
|
|
212
|
+
`DynamoDbAnnounce` (the question as a row an approvals UI can query). Each one, its
|
|
213
|
+
constructor and where the question lands: [Announcers](https://skamalj.github.io/agent-wait/announcers/).
|
|
214
|
+
Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
|
|
184
215
|
|
|
185
216
|
## What the library does *not* do
|
|
186
217
|
|
|
@@ -193,7 +224,7 @@ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
|
|
|
193
224
|
## Two things to know
|
|
194
225
|
|
|
195
226
|
**Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
|
|
196
|
-
tool is called, in its own shape; `@
|
|
227
|
+
tool is called, in its own shape; `@wait` interrupts inside the call. Both on one tool
|
|
197
228
|
means two interrupts for one approval. Use one or the other.
|
|
198
229
|
|
|
199
230
|
**One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
|
|
@@ -204,12 +235,21 @@ interrupting tool its own node. Pinned by a test that fails if LangGraph changes
|
|
|
204
235
|
|
|
205
236
|
## Layout
|
|
206
237
|
|
|
238
|
+
One distribution, one import root. Frameworks and providers are subpackages behind extras:
|
|
239
|
+
|
|
207
240
|
```
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
241
|
+
agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
|
|
242
|
+
Webhook/Log/InMemory announcers, and the Framework interface. No deps.
|
|
243
|
+
agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
|
|
244
|
+
agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
|
|
245
|
+
examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
|
|
246
|
+
docs/ message contract, architecture, announcer guide, consumer guide.
|
|
213
247
|
```
|
|
214
248
|
|
|
249
|
+
`@wait` and `publish_interrupts` are written once against a three-method `Framework`
|
|
250
|
+
interface; `agent_wait.langgraph` is one implementor of it. Announcers are the same
|
|
251
|
+
split: `BaseAnnounce` is the interface, each provider subpackage the implementors. A
|
|
252
|
+
`strands` extra follows the same pattern — see
|
|
253
|
+
[Architecture](https://skamalj.github.io/agent-wait/architecture/).
|
|
254
|
+
|
|
215
255
|
MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
|
|
@@ -3,32 +3,42 @@
|
|
|
3
3
|
[](https://pypi.org/project/agent-wait/)
|
|
4
4
|
[](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
|
|
5
5
|
[](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
|
|
6
|
+
[](https://skamalj.github.io/agent-wait/)
|
|
6
7
|
|
|
7
|
-
**
|
|
8
|
+
**Make an agent wait for something outside the process — and get the answer back in.**
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
ticket queue or another service, you are writing that code yourself — whether your agent
|
|
14
|
-
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.
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
LangGraph has `interrupt()` for this. When a node calls it the graph pauses and the
|
|
16
|
+
interrupt is handed to whatever called `invoke()` — and that is where LangGraph stops.
|
|
17
|
+
There is no built-in way to tell anyone *else* that a question was asked, and no built-in
|
|
18
|
+
way for anyone else to answer it. The moment the question has to reach Slack, an approvals
|
|
19
|
+
dashboard, a ticket queue or another service, you are writing that code yourself — whether
|
|
20
|
+
your agent is a server that runs for a year or a Lambda that is gone in seconds.
|
|
21
|
+
|
|
22
|
+
agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
|
|
23
|
+
can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
|
|
24
|
+
it in one envelope, and documents the shape of the answer so the return leg is one `if`.
|
|
19
25
|
|
|
20
26
|
```bash
|
|
21
|
-
pip install agent-wait
|
|
22
|
-
pip install agent-wait
|
|
27
|
+
pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
|
|
28
|
+
pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
|
|
23
29
|
```
|
|
24
30
|
|
|
31
|
+
The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
|
|
32
|
+
announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
|
|
33
|
+
|
|
25
34
|
## The whole thing
|
|
26
35
|
|
|
27
36
|
**In the graph** — one decorator, on the function where the decision belongs:
|
|
28
37
|
|
|
29
38
|
```python
|
|
30
39
|
from agent_wait import WaitPolicy
|
|
31
|
-
from
|
|
40
|
+
from agent_wait.langgraph import wait
|
|
41
|
+
from langchain_core.tools import tool
|
|
32
42
|
|
|
33
43
|
FINANCE = WaitPolicy(
|
|
34
44
|
timeout="P3D",
|
|
@@ -39,7 +49,7 @@ FINANCE = WaitPolicy(
|
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
@tool
|
|
42
|
-
@
|
|
52
|
+
@wait(FINANCE)
|
|
43
53
|
def issue_refund(order_id: str, amount: int) -> str:
|
|
44
54
|
payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
|
|
45
55
|
return "refunded"
|
|
@@ -49,19 +59,34 @@ Calling `issue_refund(...)` inside a run parks the graph on `{"function": "issue
|
|
|
49
59
|
"args": {...}}`. On resume, `{"action": "approve"}` runs the body — optionally with edited
|
|
50
60
|
`args` — and anything else is returned in its place, so a model sees why it did not run.
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
The policy is what the asker declares about the question. Every field is published and
|
|
63
|
+
none is enforced — the consumer acts on them:
|
|
64
|
+
|
|
65
|
+
| field | meaning |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `timeout` | ISO 8601 duration. Published as an absolute `expires_at`. |
|
|
68
|
+
| `default` | What to send as the answer if nobody answers by then. |
|
|
69
|
+
| `answer_ttl` | How long an answer stays usable after it is given. |
|
|
70
|
+
| `allowed_actions` | Which answers are meaningful — the buttons to draw. |
|
|
71
|
+
| `tags` | Routing hints; become SNS message attributes. |
|
|
72
|
+
| `correlation` | Your reference for the thing being waited on — a vendor job id, a ticket. |
|
|
73
|
+
|
|
74
|
+
A node that needs the answer itself declares a `decision` parameter and always runs,
|
|
75
|
+
approve or not, with the answer in it:
|
|
53
76
|
|
|
54
77
|
```python
|
|
55
|
-
@
|
|
78
|
+
@wait(FINANCE)
|
|
56
79
|
def review(state, decision=None):
|
|
57
80
|
return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
|
|
58
81
|
```
|
|
59
82
|
|
|
83
|
+
The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
|
|
84
|
+
|
|
60
85
|
**After the run** — one call:
|
|
61
86
|
|
|
62
87
|
```python
|
|
63
|
-
from
|
|
64
|
-
from
|
|
88
|
+
from agent_wait.aws import SnsAnnounce
|
|
89
|
+
from agent_wait.langgraph import publish_interrupts
|
|
65
90
|
|
|
66
91
|
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
|
|
67
92
|
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
@@ -92,16 +117,21 @@ resume for a question the thread has moved past — so there is nothing to check
|
|
|
92
117
|
## Async mode — the thread does not park
|
|
93
118
|
|
|
94
119
|
```python
|
|
120
|
+
from agent_wait.aws import DynamoDbAnnounce, SnsAnnounce
|
|
121
|
+
|
|
122
|
+
|
|
95
123
|
@tool
|
|
96
|
-
@
|
|
124
|
+
@wait(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
|
|
97
125
|
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
98
126
|
```
|
|
99
127
|
|
|
100
128
|
The decorator *is* the publisher: the call announces the question and returns
|
|
101
|
-
`{"status": "pending_approval", "question_id": ...}`
|
|
102
|
-
on; nothing is parked; nothing to call after
|
|
103
|
-
message and your graph acts on it. This is
|
|
104
|
-
WhatsApp, where the approver is not the person
|
|
129
|
+
`{"status": "pending_approval", "question_id": "...", "function": "issue_refund"}`
|
|
130
|
+
without running the body. The graph carries on; nothing is parked; nothing to call after
|
|
131
|
+
the run. The decision arrives later as a new message and your graph acts on it. This is
|
|
132
|
+
the mode for a single-thread channel like WhatsApp, where the approver is not the person
|
|
133
|
+
on the thread — and since nothing is parked, LangGraph remembers nothing about the
|
|
134
|
+
question; the `DynamoDbAnnounce` row (or whatever you keep) is the only record.
|
|
105
135
|
|
|
106
136
|
## What goes out
|
|
107
137
|
|
|
@@ -120,7 +150,7 @@ WhatsApp, where the approver is not the person on the thread.
|
|
|
120
150
|
```
|
|
121
151
|
|
|
122
152
|
`reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
|
|
123
|
-
`answer` is what the `@
|
|
153
|
+
`answer` is what the `@wait` function receives — verbatim.
|
|
124
154
|
|
|
125
155
|
Full schema, the recommended answer shape, and how to deduplicate:
|
|
126
156
|
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
@@ -144,19 +174,13 @@ class RedisAnnounce(BaseAnnounce):
|
|
|
144
174
|
self.client.set(envelope.dedupe_key, envelope.to_json())
|
|
145
175
|
```
|
|
146
176
|
|
|
147
|
-
A raise inside `deliver()` becomes a log line; the run completes.
|
|
148
|
-
|
|
149
|
-
| Adapter | Package | Where the question lands |
|
|
150
|
-
|---|---|---|
|
|
151
|
-
| `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
|
|
152
|
-
| `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
|
|
153
|
-
| `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
|
|
154
|
-
| `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
|
|
155
|
-
| `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
|
|
156
|
-
| `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
|
|
157
|
-
| `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
|
|
177
|
+
A raise inside `deliver()` becomes a log line; the run completes.
|
|
158
178
|
|
|
159
|
-
|
|
179
|
+
Shipped in the core: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
|
|
180
|
+
`InMemoryAnnounce`. With `[aws]`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
|
|
181
|
+
`DynamoDbAnnounce` (the question as a row an approvals UI can query). Each one, its
|
|
182
|
+
constructor and where the question lands: [Announcers](https://skamalj.github.io/agent-wait/announcers/).
|
|
183
|
+
Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
|
|
160
184
|
|
|
161
185
|
## What the library does *not* do
|
|
162
186
|
|
|
@@ -169,7 +193,7 @@ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
|
|
|
169
193
|
## Two things to know
|
|
170
194
|
|
|
171
195
|
**Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
|
|
172
|
-
tool is called, in its own shape; `@
|
|
196
|
+
tool is called, in its own shape; `@wait` interrupts inside the call. Both on one tool
|
|
173
197
|
means two interrupts for one approval. Use one or the other.
|
|
174
198
|
|
|
175
199
|
**One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
|
|
@@ -180,12 +204,21 @@ interrupting tool its own node. Pinned by a test that fails if LangGraph changes
|
|
|
180
204
|
|
|
181
205
|
## Layout
|
|
182
206
|
|
|
207
|
+
One distribution, one import root. Frameworks and providers are subpackages behind extras:
|
|
208
|
+
|
|
183
209
|
```
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
210
|
+
agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
|
|
211
|
+
Webhook/Log/InMemory announcers, and the Framework interface. No deps.
|
|
212
|
+
agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
|
|
213
|
+
agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
|
|
214
|
+
examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
|
|
215
|
+
docs/ message contract, architecture, announcer guide, consumer guide.
|
|
189
216
|
```
|
|
190
217
|
|
|
218
|
+
`@wait` and `publish_interrupts` are written once against a three-method `Framework`
|
|
219
|
+
interface; `agent_wait.langgraph` is one implementor of it. Announcers are the same
|
|
220
|
+
split: `BaseAnnounce` is the interface, each provider subpackage the implementors. A
|
|
221
|
+
`strands` extra follows the same pattern — see
|
|
222
|
+
[Architecture](https://skamalj.github.io/agent-wait/architecture/).
|
|
223
|
+
|
|
191
224
|
MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-wait"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "Human-in-the-loop for LangGraph 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
|
+
"agents",
|
|
17
|
+
"interrupt",
|
|
18
|
+
"durable",
|
|
19
|
+
"serverless",
|
|
20
|
+
"lambda",
|
|
21
|
+
"sqs",
|
|
22
|
+
"sns",
|
|
23
|
+
"eventbridge",
|
|
24
|
+
"dynamodb",
|
|
25
|
+
"webhook",
|
|
26
|
+
"callback",
|
|
27
|
+
]
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 4 - Beta",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"License :: OSI Approved :: MIT License",
|
|
32
|
+
"Programming Language :: Python :: 3",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Software Development :: Libraries",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
dependencies = []
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
langgraph = ["langgraph>=1.2,<2"]
|
|
42
|
+
aws = ["boto3>=1.35"]
|
|
43
|
+
all = ["agent-wait[langgraph,aws]"]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://skamalj.github.io/agent-wait/"
|
|
47
|
+
Documentation = "https://skamalj.github.io/agent-wait/"
|
|
48
|
+
Source = "https://github.com/skamalj/agent-wait"
|
|
49
|
+
Issues = "https://github.com/skamalj/agent-wait/issues"
|
|
50
|
+
Changelog = "https://github.com/skamalj/agent-wait/blob/main/CHANGELOG.md"
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["hatchling"]
|
|
54
|
+
build-backend = "hatchling.build"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["src/agent_wait"]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
include = ["src/agent_wait", "README.md", "LICENSE"]
|
|
61
|
+
|
|
62
|
+
[dependency-groups]
|
|
63
|
+
dev = [
|
|
64
|
+
"agent-wait[all]",
|
|
65
|
+
"pytest>=8.3",
|
|
66
|
+
"pytest-cov>=5.0",
|
|
67
|
+
"ruff>=0.6",
|
|
68
|
+
"pyright>=1.1.380",
|
|
69
|
+
"moto[dynamodb,sqs,sns,events]>=5.0",
|
|
70
|
+
"boto3-stubs[dynamodb,sqs,sns,events]>=1.35",
|
|
71
|
+
"langgraph-checkpoint-sqlite>=2.0",
|
|
72
|
+
"aws-cdk-lib>=2.160",
|
|
73
|
+
"constructs>=10.3",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.uv.sources]
|
|
77
|
+
agent-wait = { workspace = true }
|
|
78
|
+
|
|
79
|
+
[tool.uv.workspace]
|
|
80
|
+
members = ["."]
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
testpaths = ["tests", "examples"]
|
|
84
|
+
addopts = "-q --strict-markers"
|
|
85
|
+
norecursedirs = ["cdk.out", "build", ".venv", "reference"]
|
|
86
|
+
markers = [
|
|
87
|
+
"e2e: requires real AWS; opt in with AGENT_WAIT_E2E=1",
|
|
88
|
+
"spike: empirical probes of LangGraph behaviour",
|
|
89
|
+
]
|
|
90
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
91
|
+
|
|
92
|
+
[tool.ruff]
|
|
93
|
+
line-length = 110
|
|
94
|
+
target-version = "py312"
|
|
95
|
+
extend-exclude = ["reference", "cdk.out"]
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint]
|
|
98
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
99
|
+
ignore = ["B008", "RUF012"]
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint.per-file-ignores]
|
|
102
|
+
"tests/**" = ["E501"]
|
|
103
|
+
|
|
104
|
+
[tool.pyright]
|
|
105
|
+
include = ["src"]
|
|
106
|
+
exclude = ["reference", "**/node_modules", "**/__pycache__"]
|
|
107
|
+
pythonVersion = "3.12"
|
|
108
|
+
typeCheckingMode = "strict"
|
|
109
|
+
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
|