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.
- {agent_wait-0.2.0 → agent_wait-0.2.1}/.gitignore +7 -7
- {agent_wait-0.2.0 → agent_wait-0.2.1}/LICENSE +21 -21
- {agent_wait-0.2.0 → agent_wait-0.2.1}/PKG-INFO +19 -11
- {agent_wait-0.2.0 → agent_wait-0.2.1}/README.md +208 -200
- {agent_wait-0.2.0 → agent_wait-0.2.1}/pyproject.toml +2 -2
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/__init__.py +81 -81
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/announce/composite.py +47 -47
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/errors.py +26 -26
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/announce/base.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/announce/log.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/announce/webhook.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/model.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/policy.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/src/agent_wait/publisher.py +0 -0
- {agent_wait-0.2.0 → agent_wait-0.2.1}/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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-wait
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
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
|
[](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
|
|
29
29
|
[](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
|
|
30
30
|
|
|
31
|
-
**
|
|
31
|
+
**Get a LangGraph interrupt out of the process, and the answer back in.**
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
[](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
|
-
**
|
|
8
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
|
145
|
-
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- **
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
**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.
|
|
4
|
-
description = "
|
|
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.
|
|
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
|
+
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|