agent-wait 0.2.0__tar.gz → 0.3.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.2.0 → agent_wait-0.3.0}/.gitignore +7 -7
- {agent_wait-0.2.0 → agent_wait-0.3.0}/LICENSE +21 -21
- agent_wait-0.3.0/PKG-INFO +218 -0
- agent_wait-0.3.0/README.md +194 -0
- {agent_wait-0.2.0 → agent_wait-0.3.0}/pyproject.toml +2 -2
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/__init__.py +78 -81
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/announce/base.py +1 -1
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/announce/composite.py +47 -47
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/announce/log.py +2 -2
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/announce/webhook.py +1 -1
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/errors.py +26 -26
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/model.py +26 -23
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/policy.py +10 -0
- agent_wait-0.3.0/src/agent_wait/publish.py +77 -0
- agent_wait-0.2.0/PKG-INFO +0 -224
- agent_wait-0.2.0/README.md +0 -200
- agent_wait-0.2.0/src/agent_wait/publisher.py +0 -204
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.3.0}/src/agent_wait/py.typed +0 -0
|
@@ -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.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-wait
|
|
3
|
+
Version: 0.3.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.
|
|
5
|
+
Project-URL: Homepage, https://skamalj.github.io/agent-wait/
|
|
6
|
+
Project-URL: Documentation, https://skamalj.github.io/agent-wait/
|
|
7
|
+
Project-URL: Source, https://github.com/skamalj/agent-wait
|
|
8
|
+
Project-URL: Issues, https://github.com/skamalj/agent-wait/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/skamalj/agent-wait/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Kamaljeet Singh <skamalj@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,approval,durable,human-in-the-loop,interrupt,langgraph,serverless
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# agent-wait
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/agent-wait/)
|
|
28
|
+
[](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
|
|
29
|
+
[](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
**Get a LangGraph interrupt out of the process, and the answer back in.**
|
|
32
|
+
|
|
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.
|
|
39
|
+
|
|
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`.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install agent-wait langgraph-wait # core + LangGraph
|
|
46
|
+
pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## The whole thing
|
|
50
|
+
|
|
51
|
+
**In the graph** — one line, where the decision belongs:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agent_wait import WaitPolicy
|
|
55
|
+
from langgraph_wait import ask
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def review(state):
|
|
59
|
+
decision = ask(
|
|
60
|
+
{"kind": "refund_approval", "order_id": state["order_id"], "amount": state["amount"]},
|
|
61
|
+
policy=WaitPolicy(
|
|
62
|
+
timeout="P3D",
|
|
63
|
+
default={"action": "reject"},
|
|
64
|
+
allowed_actions=("approve", "reject"),
|
|
65
|
+
tags={"approver_group": "finance"},
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
return {"decision": decision} # exactly what the approver sent
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`ask()` is a thin wrapper over `interrupt()`; the policy rides along inside the
|
|
72
|
+
interrupt value. A plain `interrupt(value)` works too, with the default policy.
|
|
73
|
+
|
|
74
|
+
**After the run** — one call:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from agent_wait_aws import SnsAnnounce
|
|
78
|
+
from langgraph_wait import publish_interrupts
|
|
79
|
+
|
|
80
|
+
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
|
|
81
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
|
|
85
|
+
and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
|
|
86
|
+
no checkpointer. If nothing interrupted, it does nothing.
|
|
87
|
+
|
|
88
|
+
**When the answer comes back** — the one `if` you write:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from langgraph.types import Command
|
|
92
|
+
|
|
93
|
+
if "question_id" in message: # an answer
|
|
94
|
+
value = Command(resume={message["question_id"]: message["answer"]})
|
|
95
|
+
else: # a start
|
|
96
|
+
value = message["input"]
|
|
97
|
+
|
|
98
|
+
result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
|
|
99
|
+
publish_interrupts(result, message["thread_id"], announce)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
|
|
103
|
+
resume for a question the thread has moved past — so there is nothing to check.
|
|
104
|
+
|
|
105
|
+
## Or tag the tool
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from langgraph_wait import hitl
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@tool
|
|
112
|
+
@hitl(WaitPolicy(timeout="P1D", default={"action": "reject"}, tags={"approver_group": "finance"}))
|
|
113
|
+
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`@hitl` calls `ask()` with `{tool, args}` before the tool runs. `{"action": "approve"}`
|
|
117
|
+
runs it (optionally with edited `args`); anything else is returned to the model as the
|
|
118
|
+
tool's result. It also registers the policy by tool name, so questions raised by
|
|
119
|
+
LangChain's `HumanInTheLoopMiddleware` — which batches several tool calls into one
|
|
120
|
+
interrupt — are published with it.
|
|
121
|
+
|
|
122
|
+
**Async mode — the thread does not park:**
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
@tool
|
|
126
|
+
@hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
|
|
127
|
+
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The decorator *is* the publisher: the tool call announces the question and returns
|
|
131
|
+
`{"status": "pending_approval", "question_id": ...}` without running. The graph carries
|
|
132
|
+
on; nothing is parked; nothing to call after the run. The decision arrives later as a new
|
|
133
|
+
message and your graph acts on it. This is the mode for a single-thread channel like
|
|
134
|
+
WhatsApp, where the approver is not the person on the thread.
|
|
135
|
+
|
|
136
|
+
## What goes out
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"type": "wait.created",
|
|
141
|
+
"thread_id": "order-4471",
|
|
142
|
+
"question_id": "a1b2c3d4e5f60718",
|
|
143
|
+
"question": { "kind": "refund_approval", "amount": 41000 },
|
|
144
|
+
"allowed_actions": ["approve", "reject"],
|
|
145
|
+
"expires_at": "2026-09-14T09:00:00Z",
|
|
146
|
+
"default": { "action": "reject" },
|
|
147
|
+
"source": { "tool": "issue_refund" },
|
|
148
|
+
"reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
|
|
153
|
+
`answer` is what `ask()` returns — verbatim.
|
|
154
|
+
|
|
155
|
+
Full schema, the recommended answer shape, and how to deduplicate:
|
|
156
|
+
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
157
|
+
|
|
158
|
+
## Announcers
|
|
159
|
+
|
|
160
|
+
Subclass `BaseAnnounce`, write one method:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from agent_wait import BaseAnnounce
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class RedisAnnounce(BaseAnnounce):
|
|
167
|
+
name = "redis"
|
|
168
|
+
|
|
169
|
+
def __init__(self, client, **kw):
|
|
170
|
+
super().__init__(**kw)
|
|
171
|
+
self.client = client
|
|
172
|
+
|
|
173
|
+
def deliver(self, envelope, transition):
|
|
174
|
+
self.client.set(envelope.dedupe_key, envelope.to_json())
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A raise inside `deliver()` becomes a log line; the run completes. Shipped:
|
|
178
|
+
|
|
179
|
+
| Adapter | Package | Where the question lands |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
|
|
182
|
+
| `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
|
|
183
|
+
| `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
|
|
184
|
+
| `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
|
|
185
|
+
| `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
|
|
186
|
+
| `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
|
|
187
|
+
| `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
|
|
188
|
+
|
|
189
|
+
[Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
|
|
190
|
+
|
|
191
|
+
## What the library does *not* do
|
|
192
|
+
|
|
193
|
+
- **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
|
|
194
|
+
- **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
|
|
195
|
+
published so the consumer has the asker's intent. Acting on them is the consumer's.
|
|
196
|
+
- **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
|
|
197
|
+
async mode there is none unless you keep one.
|
|
198
|
+
|
|
199
|
+
## One LangGraph 1.2.x behaviour to know
|
|
200
|
+
|
|
201
|
+
Two tools that each call `interrupt()`, dispatched by one `ToolNode`, get the **same
|
|
202
|
+
interrupt id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only
|
|
203
|
+
one surfaces per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)).
|
|
204
|
+
A different question under an identical id defeats deduplication. Use one interrupting
|
|
205
|
+
tool per node, or `HumanInTheLoopMiddleware`, which batches them into one interrupt.
|
|
206
|
+
Pinned by a test that fails if LangGraph changes it.
|
|
207
|
+
|
|
208
|
+
## Layout
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
|
|
212
|
+
packages/langgraph-wait ask(), @hitl, publish_interrupts(). The only LangGraph import.
|
|
213
|
+
packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
|
|
214
|
+
examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
|
|
215
|
+
docs/ message contract, architecture, announcer guide, consumer guide.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# agent-wait
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/agent-wait/)
|
|
4
|
+
[](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
|
|
5
|
+
[](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 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`.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install agent-wait langgraph-wait # core + LangGraph
|
|
22
|
+
pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The whole thing
|
|
26
|
+
|
|
27
|
+
**In the graph** — one line, where the decision belongs:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from agent_wait import WaitPolicy
|
|
31
|
+
from langgraph_wait import ask
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def review(state):
|
|
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"},
|
|
40
|
+
allowed_actions=("approve", "reject"),
|
|
41
|
+
tags={"approver_group": "finance"},
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
return {"decision": decision} # exactly what the approver sent
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`ask()` is a thin wrapper over `interrupt()`; the policy rides along inside the
|
|
48
|
+
interrupt value. A plain `interrupt(value)` works too, with the default policy.
|
|
49
|
+
|
|
50
|
+
**After the run** — one call:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from agent_wait_aws import SnsAnnounce
|
|
54
|
+
from langgraph_wait import publish_interrupts
|
|
55
|
+
|
|
56
|
+
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
|
|
57
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
|
|
61
|
+
and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
|
|
62
|
+
no checkpointer. If nothing interrupted, it does nothing.
|
|
63
|
+
|
|
64
|
+
**When the answer comes back** — the one `if` you write:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from langgraph.types import Command
|
|
68
|
+
|
|
69
|
+
if "question_id" in message: # an answer
|
|
70
|
+
value = Command(resume={message["question_id"]: message["answer"]})
|
|
71
|
+
else: # a start
|
|
72
|
+
value = message["input"]
|
|
73
|
+
|
|
74
|
+
result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
|
|
75
|
+
publish_interrupts(result, message["thread_id"], announce)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
|
|
79
|
+
resume for a question the thread has moved past — so there is nothing to check.
|
|
80
|
+
|
|
81
|
+
## Or tag the tool
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from langgraph_wait import hitl
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@tool
|
|
88
|
+
@hitl(WaitPolicy(timeout="P1D", default={"action": "reject"}, tags={"approver_group": "finance"}))
|
|
89
|
+
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`@hitl` calls `ask()` with `{tool, args}` before the tool runs. `{"action": "approve"}`
|
|
93
|
+
runs it (optionally with edited `args`); anything else is returned to the model as the
|
|
94
|
+
tool's result. It also registers the policy by tool name, so questions raised by
|
|
95
|
+
LangChain's `HumanInTheLoopMiddleware` — which batches several tool calls into one
|
|
96
|
+
interrupt — are published with it.
|
|
97
|
+
|
|
98
|
+
**Async mode — the thread does not park:**
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
@tool
|
|
102
|
+
@hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
|
|
103
|
+
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The decorator *is* the publisher: the tool call announces the question and returns
|
|
107
|
+
`{"status": "pending_approval", "question_id": ...}` without running. The graph carries
|
|
108
|
+
on; nothing is parked; nothing to call after the run. The decision arrives later as a new
|
|
109
|
+
message and your graph acts on it. This is the mode for a single-thread channel like
|
|
110
|
+
WhatsApp, where the approver is not the person on the thread.
|
|
111
|
+
|
|
112
|
+
## What goes out
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"type": "wait.created",
|
|
117
|
+
"thread_id": "order-4471",
|
|
118
|
+
"question_id": "a1b2c3d4e5f60718",
|
|
119
|
+
"question": { "kind": "refund_approval", "amount": 41000 },
|
|
120
|
+
"allowed_actions": ["approve", "reject"],
|
|
121
|
+
"expires_at": "2026-09-14T09:00:00Z",
|
|
122
|
+
"default": { "action": "reject" },
|
|
123
|
+
"source": { "tool": "issue_refund" },
|
|
124
|
+
"reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
|
|
129
|
+
`answer` is what `ask()` returns — verbatim.
|
|
130
|
+
|
|
131
|
+
Full schema, the recommended answer shape, and how to deduplicate:
|
|
132
|
+
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
133
|
+
|
|
134
|
+
## Announcers
|
|
135
|
+
|
|
136
|
+
Subclass `BaseAnnounce`, write one method:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from agent_wait import BaseAnnounce
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RedisAnnounce(BaseAnnounce):
|
|
143
|
+
name = "redis"
|
|
144
|
+
|
|
145
|
+
def __init__(self, client, **kw):
|
|
146
|
+
super().__init__(**kw)
|
|
147
|
+
self.client = client
|
|
148
|
+
|
|
149
|
+
def deliver(self, envelope, transition):
|
|
150
|
+
self.client.set(envelope.dedupe_key, envelope.to_json())
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A raise inside `deliver()` becomes a log line; the run completes. Shipped:
|
|
154
|
+
|
|
155
|
+
| Adapter | Package | Where the question lands |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
|
|
158
|
+
| `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
|
|
159
|
+
| `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
|
|
160
|
+
| `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
|
|
161
|
+
| `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
|
|
162
|
+
| `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
|
|
163
|
+
| `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
|
|
164
|
+
|
|
165
|
+
[Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
|
|
166
|
+
|
|
167
|
+
## What the library does *not* do
|
|
168
|
+
|
|
169
|
+
- **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
|
|
170
|
+
- **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
|
|
171
|
+
published so the consumer has the asker's intent. Acting on them is the consumer's.
|
|
172
|
+
- **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
|
|
173
|
+
async mode there is none unless you keep one.
|
|
174
|
+
|
|
175
|
+
## One LangGraph 1.2.x behaviour to know
|
|
176
|
+
|
|
177
|
+
Two tools that each call `interrupt()`, dispatched by one `ToolNode`, get the **same
|
|
178
|
+
interrupt id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only
|
|
179
|
+
one surfaces per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)).
|
|
180
|
+
A different question under an identical id defeats deduplication. Use one interrupting
|
|
181
|
+
tool per node, or `HumanInTheLoopMiddleware`, which batches them into one interrupt.
|
|
182
|
+
Pinned by a test that fails if LangGraph changes it.
|
|
183
|
+
|
|
184
|
+
## Layout
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
|
|
188
|
+
packages/langgraph-wait ask(), @hitl, publish_interrupts(). The only LangGraph import.
|
|
189
|
+
packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
|
|
190
|
+
examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
|
|
191
|
+
docs/ message contract, architecture, announcer guide, consumer guide.
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
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.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.3.0"
|
|
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,78 @@
|
|
|
1
|
-
"""agent-wait:
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
"parse_duration",
|
|
80
|
-
"verify_signature",
|
|
81
|
-
]
|
|
1
|
+
"""agent-wait: announce 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
|
+
After the run, the host announces whatever it parked on:
|
|
11
|
+
|
|
12
|
+
result = graph.invoke(value, config)
|
|
13
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
14
|
+
|
|
15
|
+
That is the whole library. It builds one envelope per interrupt and hands it to the
|
|
16
|
+
announcers. It does not run the graph, receive answers, hold state or run timers.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .announce import (
|
|
20
|
+
AnnounceAdapter,
|
|
21
|
+
BaseAnnounce,
|
|
22
|
+
CompositeAnnounce,
|
|
23
|
+
FailingAnnounce,
|
|
24
|
+
InMemoryAnnounce,
|
|
25
|
+
LogAnnounce,
|
|
26
|
+
WebhookAnnounce,
|
|
27
|
+
verify_signature,
|
|
28
|
+
)
|
|
29
|
+
from .errors import PolicyError, QuestionTooLarge, WaitError
|
|
30
|
+
from .model import (
|
|
31
|
+
MAX_QUESTION_BYTES,
|
|
32
|
+
Clock,
|
|
33
|
+
EntryPoint,
|
|
34
|
+
FakeClock,
|
|
35
|
+
Question,
|
|
36
|
+
SystemClock,
|
|
37
|
+
Transition,
|
|
38
|
+
WaitEnvelope,
|
|
39
|
+
canonical_json,
|
|
40
|
+
check_question_size,
|
|
41
|
+
iso,
|
|
42
|
+
new_ulid,
|
|
43
|
+
)
|
|
44
|
+
from .policy import WaitPolicy, parse_duration
|
|
45
|
+
from .publish import build_envelope, publish
|
|
46
|
+
|
|
47
|
+
__version__ = "0.3.0"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"MAX_QUESTION_BYTES",
|
|
51
|
+
"AnnounceAdapter",
|
|
52
|
+
"BaseAnnounce",
|
|
53
|
+
"Clock",
|
|
54
|
+
"CompositeAnnounce",
|
|
55
|
+
"EntryPoint",
|
|
56
|
+
"FailingAnnounce",
|
|
57
|
+
"FakeClock",
|
|
58
|
+
"InMemoryAnnounce",
|
|
59
|
+
"LogAnnounce",
|
|
60
|
+
"PolicyError",
|
|
61
|
+
"Question",
|
|
62
|
+
"QuestionTooLarge",
|
|
63
|
+
"SystemClock",
|
|
64
|
+
"Transition",
|
|
65
|
+
"WaitEnvelope",
|
|
66
|
+
"WaitError",
|
|
67
|
+
"WaitPolicy",
|
|
68
|
+
"WebhookAnnounce",
|
|
69
|
+
"__version__",
|
|
70
|
+
"build_envelope",
|
|
71
|
+
"canonical_json",
|
|
72
|
+
"check_question_size",
|
|
73
|
+
"iso",
|
|
74
|
+
"new_ulid",
|
|
75
|
+
"parse_duration",
|
|
76
|
+
"publish",
|
|
77
|
+
"verify_signature",
|
|
78
|
+
]
|
|
@@ -89,7 +89,7 @@ class BaseAnnounce(ABC):
|
|
|
89
89
|
except Exception:
|
|
90
90
|
# A backend being unreachable must not fail a run that has already parked.
|
|
91
91
|
self._log.exception(
|
|
92
|
-
"%s failed for interrupt %s (%s)", type(self).__name__, envelope.
|
|
92
|
+
"%s failed for interrupt %s (%s)", type(self).__name__, envelope.question_id, transition
|
|
93
93
|
)
|
|
94
94
|
|
|
95
95
|
@abstractmethod
|