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.
Files changed (27) hide show
  1. {agent_wait-0.4.0 → agent_wait-0.5.0}/PKG-INFO +85 -45
  2. {agent_wait-0.4.0 → agent_wait-0.5.0}/README.md +75 -42
  3. agent_wait-0.5.0/pyproject.toml +109 -0
  4. agent_wait-0.5.0/reference/wait-sim/README.md +31 -0
  5. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/__init__.py +85 -78
  6. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/base.py +1 -1
  7. agent_wait-0.5.0/src/agent_wait/aws/__init__.py +19 -0
  8. agent_wait-0.5.0/src/agent_wait/aws/dynamodb.py +75 -0
  9. agent_wait-0.5.0/src/agent_wait/aws/eventbridge.py +57 -0
  10. agent_wait-0.5.0/src/agent_wait/aws/sns.py +51 -0
  11. agent_wait-0.5.0/src/agent_wait/aws/sqs.py +50 -0
  12. agent_wait-0.5.0/src/agent_wait/framework.py +55 -0
  13. agent_wait-0.5.0/src/agent_wait/langgraph/__init__.py +69 -0
  14. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/publish.py +1 -1
  15. agent_wait-0.5.0/src/agent_wait/wait.py +203 -0
  16. agent_wait-0.4.0/pyproject.toml +0 -38
  17. {agent_wait-0.4.0 → agent_wait-0.5.0}/.gitignore +0 -0
  18. {agent_wait-0.4.0 → agent_wait-0.5.0}/LICENSE +0 -0
  19. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/__init__.py +0 -0
  20. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/composite.py +0 -0
  21. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/log.py +0 -0
  22. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/memory.py +0 -0
  23. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/announce/webhook.py +0 -0
  24. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/errors.py +0 -0
  25. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/model.py +0 -0
  26. {agent_wait-0.4.0 → agent_wait-0.5.0}/src/agent_wait/policy.py +0 -0
  27. {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.0
4
- Summary: Get a LangGraph interrupt out of the process -- to a topic, a queue, a webhook, a table -- so anyone can answer it, and the answer back in.
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
  [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
28
35
  [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
29
36
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
37
+ [![Docs](https://img.shields.io/badge/docs-skamalj.github.io%2Fagent--wait-black.svg)](https://skamalj.github.io/agent-wait/)
30
38
 
31
- **Get a LangGraph interrupt out of the process, and the answer back in.**
39
+ **Make an agent wait for something outside the process — and get the answer back in.**
32
40
 
33
- When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
34
- called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
35
- anyone *else* that a question was asked, and no built-in way for anyone else to answer
36
- it. The moment the question has to reach a person on Slack, an approvals dashboard, a
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
- agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
41
- a topic, a queue, a webhook, a database row — with everything needed to answer it in one
42
- envelope, and documents the shape of the answer so the return leg is one `if`.
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 langgraph-wait # core + LangGraph
46
- pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
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 langgraph_wait import hitl
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
- @hitl(FINANCE)
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
- A node that needs the answer itself declares a `decision` parameter and always runs:
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
- @hitl(FINANCE)
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 agent_wait_aws import SnsAnnounce
88
- from langgraph_wait import publish_interrupts
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
- @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
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": ...}` without running. The graph carries
126
- on; nothing is parked; nothing to call after the run. The decision arrives later as a new
127
- message and your graph acts on it. This is the mode for a single-thread channel like
128
- WhatsApp, where the approver is not the person on the thread.
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 `@hitl` function receives — verbatim.
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. Shipped:
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
- [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
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; `@hitl` interrupts inside the call. Both on one tool
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
- packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
209
- packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
210
- packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
211
- examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
212
- docs/ message contract, architecture, announcer guide, consumer guide.
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
  [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
4
4
  [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-skamalj.github.io%2Fagent--wait-black.svg)](https://skamalj.github.io/agent-wait/)
6
7
 
7
- **Get a LangGraph interrupt out of the process, and the answer back in.**
8
+ **Make an agent wait for something outside the process — and get the answer back in.**
8
9
 
9
- When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
10
- called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
11
- anyone *else* that a question was asked, and no built-in way for anyone else to answer
12
- it. The moment the question has to reach a person on Slack, an approvals dashboard, a
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
- agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
17
- a topic, a queue, a webhook, a database row — with everything needed to answer it in one
18
- envelope, and documents the shape of the answer so the return leg is one `if`.
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 langgraph-wait # core + LangGraph
22
- pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
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 langgraph_wait import hitl
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
- @hitl(FINANCE)
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
- A node that needs the answer itself declares a `decision` parameter and always runs:
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
- @hitl(FINANCE)
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 agent_wait_aws import SnsAnnounce
64
- from langgraph_wait import publish_interrupts
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
- @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
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": ...}` without running. The graph carries
102
- on; nothing is parked; nothing to call after the run. The decision arrives later as a new
103
- message and your graph acts on it. This is the mode for a single-thread channel like
104
- WhatsApp, where the approver is not the person on the thread.
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 `@hitl` function receives — verbatim.
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. Shipped:
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
- [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
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; `@hitl` interrupts inside the call. Both on one tool
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
- packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
185
- packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
186
- packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
187
- examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
188
- docs/ message contract, architecture, announcer guide, consumer guide.
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