agent-wait 0.2.0__tar.gz → 0.2.1__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.
@@ -10,10 +10,10 @@ __pycache__/
10
10
  build/
11
11
  cdk.out/
12
12
  reports/*.log
13
-
14
- # generated by the docs workflow from README.md / CHANGELOG.md
15
- docs/index.md
16
- docs/changelog.md
17
- # build output
18
- dist/
19
- site/
13
+
14
+ # generated by the docs workflow from README.md / CHANGELOG.md
15
+ docs/index.md
16
+ docs/changelog.md
17
+ # build output
18
+ dist/
19
+ site/
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Kamaljeet Singh
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamaljeet Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-wait
3
- Version: 0.2.0
4
- Summary: Publish a LangGraph agent's interrupts to the outside world so a human can answer them -- from a queue, a Lambda, anywhere the process does not stick around.
3
+ Version: 0.2.1
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.
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
@@ -28,15 +28,20 @@ Description-Content-Type: text/markdown
28
28
  [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
29
29
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
30
30
 
31
- **Publish a LangGraph agent's interrupts to the outside world, so a human can answer them.**
31
+ **Get a LangGraph interrupt out of the process, and the answer back in.**
32
32
 
33
- A LangGraph node calls `interrupt()` and the graph stops. If the agent runs in a Lambda,
34
- a container, or anything else that doesn't stick around, the process exits and nobody
35
- knows a question was asked or where to send the answer. agent-wait takes that pause and
36
- puts it somewhere people can see it — a topic, a queue, a webhook, a database row — with
37
- everything needed to answer it in one envelope.
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.
38
39
 
39
- It does not receive the answer. That part is yours, and it is about a dozen lines.
40
+ agent-wait is that code. It takes the pause and puts it somewhere people can see it — a
41
+ topic, a queue, a webhook, a database row — with everything needed to answer it in one
42
+ envelope, and reduces "is this message a new request or an answer?" to a one-line check.
43
+
44
+ It does not receive the answer for you. That part is yours, and it is about a dozen lines.
40
45
 
41
46
  ```bash
42
47
  pip install agent-wait langgraph-wait # core + LangGraph
@@ -170,7 +175,10 @@ Because nothing reads state back through this library, "announce" doesn't have t
170
175
  | `EventBridgeAnnounce` | `agent-wait-aws` | A bus, with the transition as detail-type. Notices partial failures behind a 200. |
171
176
  | `DynamoDbAnnounce` | `agent-wait-aws` | **A row.** `open` on `created`, `closed` on `resumed`. A GSI on `status` gives an approvals UI its query with no broker anywhere. |
172
177
 
173
- Pass as many as you like; failures are contained per adapter.
178
+ Pass as many as you like; failures are contained per adapter. The full guide — what
179
+ `deliver()` receives, why `dedupe_key` is the one field to get right, patterns from the
180
+ shipped adapters, and how to test yours:
181
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
174
182
 
175
183
  ## What the library does *not* do
176
184
 
@@ -212,7 +220,7 @@ packages/agent-wait core. No LangGraph, no AWS, no dependencies. pyright
212
220
  packages/langgraph-wait ask(), the adapter, resume_command(). The only LangGraph import.
213
221
  packages/agent-wait-aws four announce adapters, and a CDK stack.
214
222
  examples/refund_agent a graph, a router, and four scenarios against real AWS.
215
- docs/ message contract, architecture, consumer guide.
223
+ docs/ message contract, architecture, announcer guide, consumer guide.
216
224
  ```
217
225
 
218
226
  ```bash
@@ -1,200 +1,208 @@
1
- # agent-wait
2
-
3
- [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
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
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
-
7
- **Publish a LangGraph agent's interrupts to the outside world, so a human can answer them.**
8
-
9
- A LangGraph node calls `interrupt()` and the graph stops. If the agent runs in a Lambda,
10
- a container, or anything else that doesn't stick around, the process exits and nobody
11
- knows a question was asked or where to send the answer. agent-wait takes that pause and
12
- puts it somewhere people can see it — a topic, a queue, a webhook, a database row — with
13
- everything needed to answer it in one envelope.
14
-
15
- It does not receive the answer. That part is yours, and it is about a dozen lines.
16
-
17
- ```bash
18
- pip install agent-wait langgraph-wait # core + LangGraph
19
- pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
20
- ```
21
-
22
- ## The whole thing
23
-
24
- **In the graph** — one line, where the decision belongs:
25
-
26
- ```python
27
- from agent_wait import WaitPolicy
28
- from langgraph_wait import ask
29
-
30
-
31
- def review(state):
32
- if state["amount"] <= 5_000:
33
- return {"decision": {"action": "approve", "by": "policy:auto"}}
34
-
35
- decision = ask(
36
- {"kind": "refund_approval", "order_id": state["order_id"], "amount": state["amount"]},
37
- policy=WaitPolicy(
38
- timeout="P3D",
39
- default={"action": "reject", "reason": "no response in 3 days"},
40
- allowed_actions=("approve", "reject"),
41
- tags={"approver_group": "finance"},
42
- ),
43
- )
44
- return {"decision": decision}
45
- ```
46
-
47
- `ask()` is a thin wrapper over `interrupt()`. The node pauses exactly as LangGraph pauses;
48
- what `ask()` adds is the policy, which rides along and comes back out in the envelope. A
49
- plain `interrupt(value)` works too, with default policy — a graph that already interrupts
50
- gets published with no edit at all.
51
-
52
- **In the host** — wire it once:
53
-
54
- ```python
55
- from agent_wait import WaitPublisher
56
- from agent_wait_aws import SnsAnnounce
57
- from langgraph_wait import LangGraphAdapter
58
-
59
- agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
60
- ```
61
-
62
- **Then route each message.** Starts and answers arrive at the same place; `interrupt_id`
63
- tells them apart:
64
-
65
- ```python
66
- from langgraph_wait import is_answer, resume_command
67
-
68
-
69
- def route(message):
70
- thread_id = message["thread_id"]
71
- if is_answer(message):
72
- if not is_still_open(thread_id, message["interrupt_id"]):
73
- return # somebody already answered
74
- return agent.invoke(resume_command(message), thread_id)
75
- if agent.pending(thread_id):
76
- return agent.republish(thread_id) # a redelivery; don't re-ask
77
- return agent.invoke(message["input"], thread_id)
78
-
79
-
80
- def is_still_open(thread_id, interrupt_id):
81
- return any(p.interrupt_id == interrupt_id for p in agent.pending(thread_id))
82
- ```
83
-
84
- That is the complete integration. [`examples/refund_agent/`](https://github.com/skamalj/agent-wait/tree/main/examples/refund_agent)
85
- is it, deployed to Lambda behind SQS.
86
-
87
- ## What goes out
88
-
89
- ```json
90
- {
91
- "type": "wait.created",
92
- "thread_id": "order-4471",
93
- "interrupt_id": "a1b2c3d4e5f60718",
94
- "question": { "kind": "refund_approval", "amount": 41000 },
95
- "allowed_actions": ["approve", "reject"],
96
- "expires_at": "2026-09-12T09:00:00Z",
97
- "default": { "action": "reject", "reason": "no response in 3 days" },
98
- "reply_with": { "thread_id": "order-4471", "interrupt_id": "a1b2c3d4e5f60718", "answer": null }
99
- }
100
- ```
101
-
102
- `reply_with` is a filled-in stub: the consumer copies it, sets `answer`, and posts it to
103
- wherever your agent listens. Whatever goes in `answer` is what the `ask()` call returns —
104
- verbatim, with nothing merged into it.
105
-
106
- A second envelope, `wait.resumed`, goes out when the graph moves past the question, so a
107
- UI knows to retract the button.
108
-
109
- Full schema, including how to deduplicate:
110
- [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
111
-
112
- ## Announcers
113
-
114
- An announcer is the only thing you are expected to implement. Subclass `BaseAnnounce`
115
- and write one method:
116
-
117
- ```python
118
- from agent_wait import BaseAnnounce
119
-
120
-
121
- class RedisAnnounce(BaseAnnounce):
122
- name = "redis"
123
-
124
- def __init__(self, client, **kw):
125
- super().__init__(**kw)
126
- self.client = client
127
-
128
- def deliver(self, envelope, transition):
129
- self.client.set(envelope.dedupe_key, envelope.to_json())
130
- ```
131
-
132
- The contract — **an announcer must never raise into the run** — is enforced by the base
133
- class: an exception from `deliver()` becomes a log line, and the graph that just parked
134
- stays parked.
135
-
136
- Because nothing reads state back through this library, "announce" doesn't have to mean
137
- "publish an event". It means *put the question where whoever answers it will find it*:
138
-
139
- | Adapter | Package | Where the question lands |
140
- |---|---|---|
141
- | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature in the GitHub/Stripe shape. Stdlib only. |
142
- | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
143
- | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
144
- | `SnsAnnounce` | `agent-wait-aws` | A topic; policy `tags` become message attributes for subscription filters. |
145
- | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread and deduplicated on the stable key. |
146
- | `EventBridgeAnnounce` | `agent-wait-aws` | A bus, with the transition as detail-type. Notices partial failures behind a 200. |
147
- | `DynamoDbAnnounce` | `agent-wait-aws` | **A row.** `open` on `created`, `closed` on `resumed`. A GSI on `status` gives an approvals UI its query with no broker anywhere. |
148
-
149
- Pass as many as you like; failures are contained per adapter.
150
-
151
- ## What the library does *not* do
152
-
153
- Deliberately — each of these is where teams' own opinions live:
154
-
155
- - **Receive answers.** No inbound endpoint, no validation, no tokens. The router above is yours.
156
- - **Enforce the timeout.** `expires_at` and `default` are published; a sweep of yours
157
- sends the default when the deadline passes. There is a
158
- [working one](https://github.com/skamalj/agent-wait/blob/main/examples/refund_agent/demo_scenarios.py)
159
- in the example.
160
- - **Decide a race.** `pending()` rejects an answer the graph has already moved past.
161
- Two *different* answers in the same instant are your transport's problem — SQS FIFO
162
- keyed by thread solves it; an HTTP endpoint with concurrent handlers needs a
163
- conditional write.
164
- - **Authenticate.** Whoever can write to your entry point can answer.
165
- - **Store anything.** LangGraph's checkpoint is the only state.
166
-
167
- ## Two LangGraph 1.2.x behaviours you should know about
168
-
169
- Both verified against 1.2.11, both pinned by tests that fail if LangGraph changes them.
170
-
171
- **`get_state().tasks[*].interrupts` over-reports** ([#4796](https://github.com/langchain-ai/langgraph/issues/4796),
172
- [#6792](https://github.com/langchain-ai/langgraph/issues/6792)). Resume one of two parallel
173
- interrupts and the finished task still lists its id. `pending()` filters on `task.result`,
174
- which is `None` only while genuinely parked.
175
-
176
- **Two interrupting tools in one `ToolNode` get the same id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626),
177
- [#6624](https://github.com/langchain-ai/langgraph/issues/6624)). A different question under
178
- an identical id defeats deduplication, and there is no filter for it. The rule is **one
179
- `interrupt()` per node** — give each approval-requiring tool its own node, which is also
180
- the fix for a node re-running its side effects on resume.
181
-
182
- Details: [Architecture](https://skamalj.github.io/agent-wait/architecture/).
183
-
184
- ## Layout
185
-
186
- ```
187
- packages/agent-wait core. No LangGraph, no AWS, no dependencies. pyright strict.
188
- packages/langgraph-wait ask(), the adapter, resume_command(). The only LangGraph import.
189
- packages/agent-wait-aws four announce adapters, and a CDK stack.
190
- examples/refund_agent a graph, a router, and four scenarios against real AWS.
191
- docs/ message contract, architecture, consumer guide.
192
- ```
193
-
194
- ```bash
195
- uv sync
196
- uv run pytest
197
- uv run ruff check . && uv run pyright
198
- ```
199
-
200
- MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
1
+ # agent-wait
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
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
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
+
7
+ **Get a LangGraph interrupt out of the process, and the answer back in.**
8
+
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.
15
+
16
+ agent-wait is that code. It takes the pause and puts it somewhere people can see it — a
17
+ topic, a queue, a webhook, a database row — with everything needed to answer it in one
18
+ envelope, and reduces "is this message a new request or an answer?" to a one-line check.
19
+
20
+ It does not receive the answer for you. That part is yours, and it is about a dozen lines.
21
+
22
+ ```bash
23
+ pip install agent-wait langgraph-wait # core + LangGraph
24
+ pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
25
+ ```
26
+
27
+ ## The whole thing
28
+
29
+ **In the graph** — one line, where the decision belongs:
30
+
31
+ ```python
32
+ from agent_wait import WaitPolicy
33
+ from langgraph_wait import ask
34
+
35
+
36
+ def review(state):
37
+ if state["amount"] <= 5_000:
38
+ return {"decision": {"action": "approve", "by": "policy:auto"}}
39
+
40
+ decision = ask(
41
+ {"kind": "refund_approval", "order_id": state["order_id"], "amount": state["amount"]},
42
+ policy=WaitPolicy(
43
+ timeout="P3D",
44
+ default={"action": "reject", "reason": "no response in 3 days"},
45
+ allowed_actions=("approve", "reject"),
46
+ tags={"approver_group": "finance"},
47
+ ),
48
+ )
49
+ return {"decision": decision}
50
+ ```
51
+
52
+ `ask()` is a thin wrapper over `interrupt()`. The node pauses exactly as LangGraph pauses;
53
+ what `ask()` adds is the policy, which rides along and comes back out in the envelope. A
54
+ plain `interrupt(value)` works too, with default policy — a graph that already interrupts
55
+ gets published with no edit at all.
56
+
57
+ **In the host** — wire it once:
58
+
59
+ ```python
60
+ from agent_wait import WaitPublisher
61
+ from agent_wait_aws import SnsAnnounce
62
+ from langgraph_wait import LangGraphAdapter
63
+
64
+ agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
65
+ ```
66
+
67
+ **Then route each message.** Starts and answers arrive at the same place; `interrupt_id`
68
+ tells them apart:
69
+
70
+ ```python
71
+ from langgraph_wait import is_answer, resume_command
72
+
73
+
74
+ def route(message):
75
+ thread_id = message["thread_id"]
76
+ if is_answer(message):
77
+ if not is_still_open(thread_id, message["interrupt_id"]):
78
+ return # somebody already answered
79
+ return agent.invoke(resume_command(message), thread_id)
80
+ if agent.pending(thread_id):
81
+ return agent.republish(thread_id) # a redelivery; don't re-ask
82
+ return agent.invoke(message["input"], thread_id)
83
+
84
+
85
+ def is_still_open(thread_id, interrupt_id):
86
+ return any(p.interrupt_id == interrupt_id for p in agent.pending(thread_id))
87
+ ```
88
+
89
+ That is the complete integration. [`examples/refund_agent/`](https://github.com/skamalj/agent-wait/tree/main/examples/refund_agent)
90
+ is it, deployed to Lambda behind SQS.
91
+
92
+ ## What goes out
93
+
94
+ ```json
95
+ {
96
+ "type": "wait.created",
97
+ "thread_id": "order-4471",
98
+ "interrupt_id": "a1b2c3d4e5f60718",
99
+ "question": { "kind": "refund_approval", "amount": 41000 },
100
+ "allowed_actions": ["approve", "reject"],
101
+ "expires_at": "2026-09-12T09:00:00Z",
102
+ "default": { "action": "reject", "reason": "no response in 3 days" },
103
+ "reply_with": { "thread_id": "order-4471", "interrupt_id": "a1b2c3d4e5f60718", "answer": null }
104
+ }
105
+ ```
106
+
107
+ `reply_with` is a filled-in stub: the consumer copies it, sets `answer`, and posts it to
108
+ wherever your agent listens. Whatever goes in `answer` is what the `ask()` call returns —
109
+ verbatim, with nothing merged into it.
110
+
111
+ A second envelope, `wait.resumed`, goes out when the graph moves past the question, so a
112
+ UI knows to retract the button.
113
+
114
+ Full schema, including how to deduplicate:
115
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
116
+
117
+ ## Announcers
118
+
119
+ An announcer is the only thing you are expected to implement. Subclass `BaseAnnounce`
120
+ and write one method:
121
+
122
+ ```python
123
+ from agent_wait import BaseAnnounce
124
+
125
+
126
+ class RedisAnnounce(BaseAnnounce):
127
+ name = "redis"
128
+
129
+ def __init__(self, client, **kw):
130
+ super().__init__(**kw)
131
+ self.client = client
132
+
133
+ def deliver(self, envelope, transition):
134
+ self.client.set(envelope.dedupe_key, envelope.to_json())
135
+ ```
136
+
137
+ The contract — **an announcer must never raise into the run** — is enforced by the base
138
+ class: an exception from `deliver()` becomes a log line, and the graph that just parked
139
+ stays parked.
140
+
141
+ Because nothing reads state back through this library, "announce" doesn't have to mean
142
+ "publish an event". It means *put the question where whoever answers it will find it*:
143
+
144
+ | Adapter | Package | Where the question lands |
145
+ |---|---|---|
146
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature in the GitHub/Stripe shape. Stdlib only. |
147
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
148
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
149
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; policy `tags` become message attributes for subscription filters. |
150
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread and deduplicated on the stable key. |
151
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus, with the transition as detail-type. Notices partial failures behind a 200. |
152
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row.** `open` on `created`, `closed` on `resumed`. A GSI on `status` gives an approvals UI its query with no broker anywhere. |
153
+
154
+ Pass as many as you like; failures are contained per adapter. The full guide — what
155
+ `deliver()` receives, why `dedupe_key` is the one field to get right, patterns from the
156
+ shipped adapters, and how to test yours:
157
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
158
+
159
+ ## What the library does *not* do
160
+
161
+ Deliberately — each of these is where teams' own opinions live:
162
+
163
+ - **Receive answers.** No inbound endpoint, no validation, no tokens. The router above is yours.
164
+ - **Enforce the timeout.** `expires_at` and `default` are published; a sweep of yours
165
+ sends the default when the deadline passes. There is a
166
+ [working one](https://github.com/skamalj/agent-wait/blob/main/examples/refund_agent/demo_scenarios.py)
167
+ in the example.
168
+ - **Decide a race.** `pending()` rejects an answer the graph has already moved past.
169
+ Two *different* answers in the same instant are your transport's problem — SQS FIFO
170
+ keyed by thread solves it; an HTTP endpoint with concurrent handlers needs a
171
+ conditional write.
172
+ - **Authenticate.** Whoever can write to your entry point can answer.
173
+ - **Store anything.** LangGraph's checkpoint is the only state.
174
+
175
+ ## Two LangGraph 1.2.x behaviours you should know about
176
+
177
+ Both verified against 1.2.11, both pinned by tests that fail if LangGraph changes them.
178
+
179
+ **`get_state().tasks[*].interrupts` over-reports** ([#4796](https://github.com/langchain-ai/langgraph/issues/4796),
180
+ [#6792](https://github.com/langchain-ai/langgraph/issues/6792)). Resume one of two parallel
181
+ interrupts and the finished task still lists its id. `pending()` filters on `task.result`,
182
+ which is `None` only while genuinely parked.
183
+
184
+ **Two interrupting tools in one `ToolNode` get the same id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626),
185
+ [#6624](https://github.com/langchain-ai/langgraph/issues/6624)). A different question under
186
+ an identical id defeats deduplication, and there is no filter for it. The rule is **one
187
+ `interrupt()` per node** — give each approval-requiring tool its own node, which is also
188
+ the fix for a node re-running its side effects on resume.
189
+
190
+ Details: [Architecture](https://skamalj.github.io/agent-wait/architecture/).
191
+
192
+ ## Layout
193
+
194
+ ```
195
+ packages/agent-wait core. No LangGraph, no AWS, no dependencies. pyright strict.
196
+ packages/langgraph-wait ask(), the adapter, resume_command(). The only LangGraph import.
197
+ packages/agent-wait-aws four announce adapters, and a CDK stack.
198
+ examples/refund_agent a graph, a router, and four scenarios against real AWS.
199
+ docs/ message contract, architecture, announcer guide, consumer guide.
200
+ ```
201
+
202
+ ```bash
203
+ uv sync
204
+ uv run pytest
205
+ uv run ruff check . && uv run pyright
206
+ ```
207
+
208
+ MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "agent-wait"
3
- version = "0.2.0"
4
- description = "Publish a LangGraph agent's interrupts to the outside world so a human can answer them -- from a queue, a Lambda, anywhere the process does not stick around."
3
+ version = "0.2.1"
4
+ description = "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."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = "MIT"
@@ -1,81 +1,81 @@
1
- """agent-wait: publish an agent's interrupts to the outside world.
2
-
3
- A graph node asks a question and pauses:
4
-
5
- from langgraph_wait import ask
6
-
7
- decision = ask({"kind": "refund_approval", "amount": amount},
8
- policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
9
-
10
- The host runs the graph through a publisher, and whatever the graph parked on goes out
11
- to wherever people can see it:
12
-
13
- agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
14
-
15
- agent.invoke(payload, thread_id)
16
-
17
- That is the whole library. It publishes questions and it reports what a thread is parked
18
- on. It does not receive answers, hold state, mint credentials or run timers -- see
19
- `docs/migrating-from-0.1.md` for what that means if you are coming from v0.1.
20
- """
21
-
22
- from .announce import (
23
- AnnounceAdapter,
24
- BaseAnnounce,
25
- CompositeAnnounce,
26
- FailingAnnounce,
27
- InMemoryAnnounce,
28
- LogAnnounce,
29
- WebhookAnnounce,
30
- verify_signature,
31
- )
32
- from .errors import PolicyError, QuestionTooLarge, WaitError
33
- from .model import (
34
- MAX_QUESTION_BYTES,
35
- Clock,
36
- EntryPoint,
37
- FakeClock,
38
- PendingInterrupt,
39
- SystemClock,
40
- Transition,
41
- WaitEnvelope,
42
- canonical_json,
43
- check_question_size,
44
- iso,
45
- new_ulid,
46
- )
47
- from .policy import WaitPolicy, parse_duration
48
- from .publisher import FrameworkAdapter, WaitPublisher
49
-
50
- __version__ = "0.2.0"
51
-
52
- __all__ = [
53
- "MAX_QUESTION_BYTES",
54
- "AnnounceAdapter",
55
- "BaseAnnounce",
56
- "Clock",
57
- "CompositeAnnounce",
58
- "EntryPoint",
59
- "FailingAnnounce",
60
- "FakeClock",
61
- "FrameworkAdapter",
62
- "InMemoryAnnounce",
63
- "LogAnnounce",
64
- "PendingInterrupt",
65
- "PolicyError",
66
- "QuestionTooLarge",
67
- "SystemClock",
68
- "Transition",
69
- "WaitEnvelope",
70
- "WaitError",
71
- "WaitPolicy",
72
- "WaitPublisher",
73
- "WebhookAnnounce",
74
- "__version__",
75
- "canonical_json",
76
- "check_question_size",
77
- "iso",
78
- "new_ulid",
79
- "parse_duration",
80
- "verify_signature",
81
- ]
1
+ """agent-wait: publish an agent's interrupts to the outside world.
2
+
3
+ A graph node asks a question and pauses:
4
+
5
+ from langgraph_wait import ask
6
+
7
+ decision = ask({"kind": "refund_approval", "amount": amount},
8
+ policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
9
+
10
+ The host runs the graph through a publisher, and whatever the graph parked on goes out
11
+ to wherever people can see it:
12
+
13
+ agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
14
+
15
+ agent.invoke(payload, thread_id)
16
+
17
+ That is the whole library. It publishes questions and it reports what a thread is parked
18
+ on. It does not receive answers, hold state, mint credentials or run timers -- see
19
+ `docs/migrating-from-0.1.md` for what that means if you are coming from v0.1.
20
+ """
21
+
22
+ from .announce import (
23
+ AnnounceAdapter,
24
+ BaseAnnounce,
25
+ CompositeAnnounce,
26
+ FailingAnnounce,
27
+ InMemoryAnnounce,
28
+ LogAnnounce,
29
+ WebhookAnnounce,
30
+ verify_signature,
31
+ )
32
+ from .errors import PolicyError, QuestionTooLarge, WaitError
33
+ from .model import (
34
+ MAX_QUESTION_BYTES,
35
+ Clock,
36
+ EntryPoint,
37
+ FakeClock,
38
+ PendingInterrupt,
39
+ SystemClock,
40
+ Transition,
41
+ WaitEnvelope,
42
+ canonical_json,
43
+ check_question_size,
44
+ iso,
45
+ new_ulid,
46
+ )
47
+ from .policy import WaitPolicy, parse_duration
48
+ from .publisher import FrameworkAdapter, WaitPublisher
49
+
50
+ __version__ = "0.2.1"
51
+
52
+ __all__ = [
53
+ "MAX_QUESTION_BYTES",
54
+ "AnnounceAdapter",
55
+ "BaseAnnounce",
56
+ "Clock",
57
+ "CompositeAnnounce",
58
+ "EntryPoint",
59
+ "FailingAnnounce",
60
+ "FakeClock",
61
+ "FrameworkAdapter",
62
+ "InMemoryAnnounce",
63
+ "LogAnnounce",
64
+ "PendingInterrupt",
65
+ "PolicyError",
66
+ "QuestionTooLarge",
67
+ "SystemClock",
68
+ "Transition",
69
+ "WaitEnvelope",
70
+ "WaitError",
71
+ "WaitPolicy",
72
+ "WaitPublisher",
73
+ "WebhookAnnounce",
74
+ "__version__",
75
+ "canonical_json",
76
+ "check_question_size",
77
+ "iso",
78
+ "new_ulid",
79
+ "parse_duration",
80
+ "verify_signature",
81
+ ]
@@ -1,47 +1,47 @@
1
- """`CompositeAnnounce` -- fan out to several adapters, isolate their failures.
2
-
3
- This is where "must not raise" is actually enforced rather than merely requested of
4
- adapter authors. A third-party adapter that breaks its side of the contract is contained
5
- here, and the publisher never learns about it.
6
-
7
- There is no retry and no reporting of what got through. A failed announce is recovered
8
- the same way everything else is: re-invoke the thread, get the same interrupt ids back,
9
- republish. The consumer discards the duplicate on `dedupe_key`.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import logging
15
- from collections.abc import Sequence
16
-
17
- from ..model import Transition, WaitEnvelope
18
- from .base import AnnounceAdapter
19
-
20
- _log = logging.getLogger("agent_wait.announce")
21
-
22
-
23
- class CompositeAnnounce:
24
- name = "composite"
25
-
26
- def __init__(self, adapters: Sequence[AnnounceAdapter]) -> None:
27
- self.adapters = list(adapters)
28
-
29
- def supports(self, transition: Transition) -> bool:
30
- return any(a.supports(transition) for a in self.adapters)
31
-
32
- def announce(self, envelope: WaitEnvelope, transition: Transition) -> None:
33
- for adapter in self.adapters:
34
- name = getattr(adapter, "name", "?")
35
- try:
36
- if not adapter.supports(transition):
37
- continue
38
- adapter.announce(envelope, transition)
39
- except Exception:
40
- # A contract violation by the adapter. Contained here, on purpose: if
41
- # Slack is down, the graph still parked, and the run still completes.
42
- _log.exception(
43
- "announce adapter %s raised on %s for interrupt %s",
44
- name,
45
- transition,
46
- envelope.interrupt_id,
47
- )
1
+ """`CompositeAnnounce` -- fan out to several adapters, isolate their failures.
2
+
3
+ This is where "must not raise" is actually enforced rather than merely requested of
4
+ adapter authors. A third-party adapter that breaks its side of the contract is contained
5
+ here, and the publisher never learns about it.
6
+
7
+ There is no retry and no reporting of what got through. A failed announce is recovered
8
+ the same way everything else is: re-invoke the thread, get the same interrupt ids back,
9
+ republish. The consumer discards the duplicate on `dedupe_key`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Sequence
16
+
17
+ from ..model import Transition, WaitEnvelope
18
+ from .base import AnnounceAdapter
19
+
20
+ _log = logging.getLogger("agent_wait.announce")
21
+
22
+
23
+ class CompositeAnnounce:
24
+ name = "composite"
25
+
26
+ def __init__(self, adapters: Sequence[AnnounceAdapter]) -> None:
27
+ self.adapters = list(adapters)
28
+
29
+ def supports(self, transition: Transition) -> bool:
30
+ return any(a.supports(transition) for a in self.adapters)
31
+
32
+ def announce(self, envelope: WaitEnvelope, transition: Transition) -> None:
33
+ for adapter in self.adapters:
34
+ name = getattr(adapter, "name", "?")
35
+ try:
36
+ if not adapter.supports(transition):
37
+ continue
38
+ adapter.announce(envelope, transition)
39
+ except Exception:
40
+ # A contract violation by the adapter. Contained here, on purpose: if
41
+ # Slack is down, the graph still parked, and the run still completes.
42
+ _log.exception(
43
+ "announce adapter %s raised on %s for interrupt %s",
44
+ name,
45
+ transition,
46
+ envelope.interrupt_id,
47
+ )
@@ -1,26 +1,26 @@
1
- """The two exceptions agent-wait raises, both at the interrupt site.
2
-
3
- There are no runtime errors to speak of. Announce adapters are forbidden from raising
4
- (see `announce/base.py`) and nothing else in the library makes a decision that can fail.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
-
10
- class WaitError(Exception):
11
- """Base class, so `except WaitError` catches everything this library raises."""
12
-
13
-
14
- class PolicyError(WaitError):
15
- """A `WaitPolicy` that cannot mean anything -- an unparseable timeout, an empty
16
- `allowed_actions`. Raised at construction, in the graph, where the mistake is."""
17
-
18
-
19
- class QuestionTooLarge(WaitError):
20
- """The question would not survive the trip.
21
-
22
- An envelope has to fit through whatever the announce adapter is: SNS caps a message
23
- at 256 KB, EventBridge at 256 KB, SQS at 256 KB. Finding out at publish time means a
24
- parked interrupt nobody ever hears about, so the size is checked when the question is
25
- asked instead.
26
- """
1
+ """The two exceptions agent-wait raises, both at the interrupt site.
2
+
3
+ There are no runtime errors to speak of. Announce adapters are forbidden from raising
4
+ (see `announce/base.py`) and nothing else in the library makes a decision that can fail.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class WaitError(Exception):
11
+ """Base class, so `except WaitError` catches everything this library raises."""
12
+
13
+
14
+ class PolicyError(WaitError):
15
+ """A `WaitPolicy` that cannot mean anything -- an unparseable timeout, an empty
16
+ `allowed_actions`. Raised at construction, in the graph, where the mistake is."""
17
+
18
+
19
+ class QuestionTooLarge(WaitError):
20
+ """The question would not survive the trip.
21
+
22
+ An envelope has to fit through whatever the announce adapter is: SNS caps a message
23
+ at 256 KB, EventBridge at 256 KB, SQS at 256 KB. Finding out at publish time means a
24
+ parked interrupt nobody ever hears about, so the size is checked when the question is
25
+ asked instead.
26
+ """