agent-wait 0.2.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/.gitignore +19 -0
- agent_wait-0.2.0/LICENSE +21 -0
- agent_wait-0.2.0/PKG-INFO +224 -0
- agent_wait-0.2.0/README.md +200 -0
- agent_wait-0.2.0/pyproject.toml +38 -0
- agent_wait-0.2.0/src/agent_wait/__init__.py +81 -0
- agent_wait-0.2.0/src/agent_wait/announce/__init__.py +16 -0
- agent_wait-0.2.0/src/agent_wait/announce/base.py +98 -0
- agent_wait-0.2.0/src/agent_wait/announce/composite.py +47 -0
- agent_wait-0.2.0/src/agent_wait/announce/log.py +47 -0
- agent_wait-0.2.0/src/agent_wait/announce/memory.py +51 -0
- agent_wait-0.2.0/src/agent_wait/announce/webhook.py +99 -0
- agent_wait-0.2.0/src/agent_wait/errors.py +26 -0
- agent_wait-0.2.0/src/agent_wait/model.py +179 -0
- agent_wait-0.2.0/src/agent_wait/policy.py +122 -0
- agent_wait-0.2.0/src/agent_wait/publisher.py +204 -0
- agent_wait-0.2.0/src/agent_wait/py.typed +0 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
*.egg-info/
|
|
2
|
+
*.pyc
|
|
3
|
+
.coverage
|
|
4
|
+
.env
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.scratch/
|
|
8
|
+
.venv/
|
|
9
|
+
__pycache__/
|
|
10
|
+
build/
|
|
11
|
+
cdk.out/
|
|
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/
|
agent_wait-0.2.0/LICENSE
ADDED
|
@@ -0,0 +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.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
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.
|
|
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
|
+
**Publish a LangGraph agent's interrupts to the outside world, so a human can answer them.**
|
|
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.
|
|
38
|
+
|
|
39
|
+
It does not receive the answer. That part is yours, and it is about a dozen lines.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install agent-wait langgraph-wait # core + LangGraph
|
|
43
|
+
pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## The whole thing
|
|
47
|
+
|
|
48
|
+
**In the graph** — one line, where the decision belongs:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from agent_wait import WaitPolicy
|
|
52
|
+
from langgraph_wait import ask
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def review(state):
|
|
56
|
+
if state["amount"] <= 5_000:
|
|
57
|
+
return {"decision": {"action": "approve", "by": "policy:auto"}}
|
|
58
|
+
|
|
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", "reason": "no response in 3 days"},
|
|
64
|
+
allowed_actions=("approve", "reject"),
|
|
65
|
+
tags={"approver_group": "finance"},
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
return {"decision": decision}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`ask()` is a thin wrapper over `interrupt()`. The node pauses exactly as LangGraph pauses;
|
|
72
|
+
what `ask()` adds is the policy, which rides along and comes back out in the envelope. A
|
|
73
|
+
plain `interrupt(value)` works too, with default policy — a graph that already interrupts
|
|
74
|
+
gets published with no edit at all.
|
|
75
|
+
|
|
76
|
+
**In the host** — wire it once:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from agent_wait import WaitPublisher
|
|
80
|
+
from agent_wait_aws import SnsAnnounce
|
|
81
|
+
from langgraph_wait import LangGraphAdapter
|
|
82
|
+
|
|
83
|
+
agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Then route each message.** Starts and answers arrive at the same place; `interrupt_id`
|
|
87
|
+
tells them apart:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from langgraph_wait import is_answer, resume_command
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def route(message):
|
|
94
|
+
thread_id = message["thread_id"]
|
|
95
|
+
if is_answer(message):
|
|
96
|
+
if not is_still_open(thread_id, message["interrupt_id"]):
|
|
97
|
+
return # somebody already answered
|
|
98
|
+
return agent.invoke(resume_command(message), thread_id)
|
|
99
|
+
if agent.pending(thread_id):
|
|
100
|
+
return agent.republish(thread_id) # a redelivery; don't re-ask
|
|
101
|
+
return agent.invoke(message["input"], thread_id)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_still_open(thread_id, interrupt_id):
|
|
105
|
+
return any(p.interrupt_id == interrupt_id for p in agent.pending(thread_id))
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
That is the complete integration. [`examples/refund_agent/`](https://github.com/skamalj/agent-wait/tree/main/examples/refund_agent)
|
|
109
|
+
is it, deployed to Lambda behind SQS.
|
|
110
|
+
|
|
111
|
+
## What goes out
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"type": "wait.created",
|
|
116
|
+
"thread_id": "order-4471",
|
|
117
|
+
"interrupt_id": "a1b2c3d4e5f60718",
|
|
118
|
+
"question": { "kind": "refund_approval", "amount": 41000 },
|
|
119
|
+
"allowed_actions": ["approve", "reject"],
|
|
120
|
+
"expires_at": "2026-09-12T09:00:00Z",
|
|
121
|
+
"default": { "action": "reject", "reason": "no response in 3 days" },
|
|
122
|
+
"reply_with": { "thread_id": "order-4471", "interrupt_id": "a1b2c3d4e5f60718", "answer": null }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`reply_with` is a filled-in stub: the consumer copies it, sets `answer`, and posts it to
|
|
127
|
+
wherever your agent listens. Whatever goes in `answer` is what the `ask()` call returns —
|
|
128
|
+
verbatim, with nothing merged into it.
|
|
129
|
+
|
|
130
|
+
A second envelope, `wait.resumed`, goes out when the graph moves past the question, so a
|
|
131
|
+
UI knows to retract the button.
|
|
132
|
+
|
|
133
|
+
Full schema, including how to deduplicate:
|
|
134
|
+
[Message formats](https://skamalj.github.io/agent-wait/message-formats/).
|
|
135
|
+
|
|
136
|
+
## Announcers
|
|
137
|
+
|
|
138
|
+
An announcer is the only thing you are expected to implement. Subclass `BaseAnnounce`
|
|
139
|
+
and write one method:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from agent_wait import BaseAnnounce
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RedisAnnounce(BaseAnnounce):
|
|
146
|
+
name = "redis"
|
|
147
|
+
|
|
148
|
+
def __init__(self, client, **kw):
|
|
149
|
+
super().__init__(**kw)
|
|
150
|
+
self.client = client
|
|
151
|
+
|
|
152
|
+
def deliver(self, envelope, transition):
|
|
153
|
+
self.client.set(envelope.dedupe_key, envelope.to_json())
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The contract — **an announcer must never raise into the run** — is enforced by the base
|
|
157
|
+
class: an exception from `deliver()` becomes a log line, and the graph that just parked
|
|
158
|
+
stays parked.
|
|
159
|
+
|
|
160
|
+
Because nothing reads state back through this library, "announce" doesn't have to mean
|
|
161
|
+
"publish an event". It means *put the question where whoever answers it will find it*:
|
|
162
|
+
|
|
163
|
+
| Adapter | Package | Where the question lands |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature in the GitHub/Stripe shape. Stdlib only. |
|
|
166
|
+
| `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
|
|
167
|
+
| `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
|
|
168
|
+
| `SnsAnnounce` | `agent-wait-aws` | A topic; policy `tags` become message attributes for subscription filters. |
|
|
169
|
+
| `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread and deduplicated on the stable key. |
|
|
170
|
+
| `EventBridgeAnnounce` | `agent-wait-aws` | A bus, with the transition as detail-type. Notices partial failures behind a 200. |
|
|
171
|
+
| `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
|
+
|
|
173
|
+
Pass as many as you like; failures are contained per adapter.
|
|
174
|
+
|
|
175
|
+
## What the library does *not* do
|
|
176
|
+
|
|
177
|
+
Deliberately — each of these is where teams' own opinions live:
|
|
178
|
+
|
|
179
|
+
- **Receive answers.** No inbound endpoint, no validation, no tokens. The router above is yours.
|
|
180
|
+
- **Enforce the timeout.** `expires_at` and `default` are published; a sweep of yours
|
|
181
|
+
sends the default when the deadline passes. There is a
|
|
182
|
+
[working one](https://github.com/skamalj/agent-wait/blob/main/examples/refund_agent/demo_scenarios.py)
|
|
183
|
+
in the example.
|
|
184
|
+
- **Decide a race.** `pending()` rejects an answer the graph has already moved past.
|
|
185
|
+
Two *different* answers in the same instant are your transport's problem — SQS FIFO
|
|
186
|
+
keyed by thread solves it; an HTTP endpoint with concurrent handlers needs a
|
|
187
|
+
conditional write.
|
|
188
|
+
- **Authenticate.** Whoever can write to your entry point can answer.
|
|
189
|
+
- **Store anything.** LangGraph's checkpoint is the only state.
|
|
190
|
+
|
|
191
|
+
## Two LangGraph 1.2.x behaviours you should know about
|
|
192
|
+
|
|
193
|
+
Both verified against 1.2.11, both pinned by tests that fail if LangGraph changes them.
|
|
194
|
+
|
|
195
|
+
**`get_state().tasks[*].interrupts` over-reports** ([#4796](https://github.com/langchain-ai/langgraph/issues/4796),
|
|
196
|
+
[#6792](https://github.com/langchain-ai/langgraph/issues/6792)). Resume one of two parallel
|
|
197
|
+
interrupts and the finished task still lists its id. `pending()` filters on `task.result`,
|
|
198
|
+
which is `None` only while genuinely parked.
|
|
199
|
+
|
|
200
|
+
**Two interrupting tools in one `ToolNode` get the same id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626),
|
|
201
|
+
[#6624](https://github.com/langchain-ai/langgraph/issues/6624)). A different question under
|
|
202
|
+
an identical id defeats deduplication, and there is no filter for it. The rule is **one
|
|
203
|
+
`interrupt()` per node** — give each approval-requiring tool its own node, which is also
|
|
204
|
+
the fix for a node re-running its side effects on resume.
|
|
205
|
+
|
|
206
|
+
Details: [Architecture](https://skamalj.github.io/agent-wait/architecture/).
|
|
207
|
+
|
|
208
|
+
## Layout
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
packages/agent-wait core. No LangGraph, no AWS, no dependencies. pyright strict.
|
|
212
|
+
packages/langgraph-wait ask(), the adapter, resume_command(). The only LangGraph import.
|
|
213
|
+
packages/agent-wait-aws four announce adapters, and a CDK stack.
|
|
214
|
+
examples/refund_agent a graph, a router, and four scenarios against real AWS.
|
|
215
|
+
docs/ message contract, architecture, consumer guide.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
uv sync
|
|
220
|
+
uv run pytest
|
|
221
|
+
uv run ruff check . && uv run pyright
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
**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).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
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."
|
|
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 = ["agents", "langgraph", "human-in-the-loop", "interrupt", "approval", "serverless", "durable"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://skamalj.github.io/agent-wait/"
|
|
25
|
+
Documentation = "https://skamalj.github.io/agent-wait/"
|
|
26
|
+
Source = "https://github.com/skamalj/agent-wait"
|
|
27
|
+
Issues = "https://github.com/skamalj/agent-wait/issues"
|
|
28
|
+
Changelog = "https://github.com/skamalj/agent-wait/blob/main/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/agent_wait"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src/agent_wait", "README.md", "LICENSE"]
|
|
@@ -0,0 +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
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .base import AnnounceAdapter, BaseAnnounce
|
|
2
|
+
from .composite import CompositeAnnounce
|
|
3
|
+
from .log import LogAnnounce
|
|
4
|
+
from .memory import FailingAnnounce, InMemoryAnnounce
|
|
5
|
+
from .webhook import WebhookAnnounce, verify_signature
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AnnounceAdapter",
|
|
9
|
+
"BaseAnnounce",
|
|
10
|
+
"CompositeAnnounce",
|
|
11
|
+
"FailingAnnounce",
|
|
12
|
+
"InMemoryAnnounce",
|
|
13
|
+
"LogAnnounce",
|
|
14
|
+
"WebhookAnnounce",
|
|
15
|
+
"verify_signature",
|
|
16
|
+
]
|