agent-wait 0.2.1__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.3.0/PKG-INFO +218 -0
- agent_wait-0.3.0/README.md +194 -0
- {agent_wait-0.2.1 → agent_wait-0.3.0}/pyproject.toml +1 -1
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/__init__.py +12 -15
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/announce/base.py +1 -1
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/announce/composite.py +1 -1
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/announce/log.py +2 -2
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/announce/webhook.py +1 -1
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/model.py +26 -23
- {agent_wait-0.2.1 → 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.1/PKG-INFO +0 -232
- agent_wait-0.2.1/README.md +0 -208
- agent_wait-0.2.1/src/agent_wait/publisher.py +0 -204
- {agent_wait-0.2.1 → agent_wait-0.3.0}/.gitignore +0 -0
- {agent_wait-0.2.1 → agent_wait-0.3.0}/LICENSE +0 -0
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/errors.py +0 -0
- {agent_wait-0.2.1 → agent_wait-0.3.0}/src/agent_wait/py.typed +0 -0
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-wait"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
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"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""agent-wait:
|
|
1
|
+
"""agent-wait: announce an agent's interrupts to the outside world.
|
|
2
2
|
|
|
3
3
|
A graph node asks a question and pauses:
|
|
4
4
|
|
|
@@ -7,16 +7,13 @@ A graph node asks a question and pauses:
|
|
|
7
7
|
decision = ask({"kind": "refund_approval", "amount": amount},
|
|
8
8
|
policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
to wherever people can see it:
|
|
10
|
+
After the run, the host announces whatever it parked on:
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
result = graph.invoke(value, config)
|
|
13
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
14
14
|
|
|
15
|
-
|
|
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.
|
|
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.
|
|
20
17
|
"""
|
|
21
18
|
|
|
22
19
|
from .announce import (
|
|
@@ -35,7 +32,7 @@ from .model import (
|
|
|
35
32
|
Clock,
|
|
36
33
|
EntryPoint,
|
|
37
34
|
FakeClock,
|
|
38
|
-
|
|
35
|
+
Question,
|
|
39
36
|
SystemClock,
|
|
40
37
|
Transition,
|
|
41
38
|
WaitEnvelope,
|
|
@@ -45,9 +42,9 @@ from .model import (
|
|
|
45
42
|
new_ulid,
|
|
46
43
|
)
|
|
47
44
|
from .policy import WaitPolicy, parse_duration
|
|
48
|
-
from .
|
|
45
|
+
from .publish import build_envelope, publish
|
|
49
46
|
|
|
50
|
-
__version__ = "0.
|
|
47
|
+
__version__ = "0.3.0"
|
|
51
48
|
|
|
52
49
|
__all__ = [
|
|
53
50
|
"MAX_QUESTION_BYTES",
|
|
@@ -58,24 +55,24 @@ __all__ = [
|
|
|
58
55
|
"EntryPoint",
|
|
59
56
|
"FailingAnnounce",
|
|
60
57
|
"FakeClock",
|
|
61
|
-
"FrameworkAdapter",
|
|
62
58
|
"InMemoryAnnounce",
|
|
63
59
|
"LogAnnounce",
|
|
64
|
-
"PendingInterrupt",
|
|
65
60
|
"PolicyError",
|
|
61
|
+
"Question",
|
|
66
62
|
"QuestionTooLarge",
|
|
67
63
|
"SystemClock",
|
|
68
64
|
"Transition",
|
|
69
65
|
"WaitEnvelope",
|
|
70
66
|
"WaitError",
|
|
71
67
|
"WaitPolicy",
|
|
72
|
-
"WaitPublisher",
|
|
73
68
|
"WebhookAnnounce",
|
|
74
69
|
"__version__",
|
|
70
|
+
"build_envelope",
|
|
75
71
|
"canonical_json",
|
|
76
72
|
"check_question_size",
|
|
77
73
|
"iso",
|
|
78
74
|
"new_ulid",
|
|
79
75
|
"parse_duration",
|
|
76
|
+
"publish",
|
|
80
77
|
"verify_signature",
|
|
81
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
|
|
@@ -33,7 +33,7 @@ class LogAnnounce(BaseAnnounce):
|
|
|
33
33
|
"event": envelope.type,
|
|
34
34
|
"event_id": envelope.event_id,
|
|
35
35
|
"thread_id": envelope.thread_id,
|
|
36
|
-
"
|
|
36
|
+
"question_id": envelope.question_id,
|
|
37
37
|
"allowed_actions": list(envelope.allowed_actions),
|
|
38
38
|
"expires_at": envelope.expires_at,
|
|
39
39
|
"tags": dict(envelope.tags),
|
|
@@ -42,6 +42,6 @@ class LogAnnounce(BaseAnnounce):
|
|
|
42
42
|
if self._out.isEnabledFor(logging.DEBUG):
|
|
43
43
|
self._out.debug(
|
|
44
44
|
"agent-wait question for %s: %s",
|
|
45
|
-
envelope.
|
|
45
|
+
envelope.question_id,
|
|
46
46
|
json.dumps(envelope.question, default=str),
|
|
47
47
|
)
|
|
@@ -8,7 +8,7 @@ transition, JSON body, three headers a receiver can route or verify on:
|
|
|
8
8
|
|
|
9
9
|
Content-Type: application/json
|
|
10
10
|
X-Agent-Wait-Event: wait.created | wait.resumed
|
|
11
|
-
X-Agent-Wait-Dedupe-Key: wait.created:<
|
|
11
|
+
X-Agent-Wait-Dedupe-Key: wait.created:<question_id>
|
|
12
12
|
X-Agent-Wait-Signature: sha256=<hex> (only when `secret` is given)
|
|
13
13
|
|
|
14
14
|
## Signing
|
|
@@ -18,15 +18,11 @@ from typing import Any, Literal, Protocol
|
|
|
18
18
|
from .errors import QuestionTooLarge
|
|
19
19
|
from .policy import WaitPolicy
|
|
20
20
|
|
|
21
|
-
Transition = Literal["created"
|
|
22
|
-
"""The
|
|
21
|
+
Transition = Literal["created"]
|
|
22
|
+
"""The one thing said about a wait: the graph is parked on this question.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
There is no `answered`, `expired` or `cancelled`. Those would describe the library's
|
|
28
|
-
opinion about an answer, and no answer ever reaches the library. Only the graph's own
|
|
29
|
-
state is reported, and the graph knows exactly two things: parked, or not.
|
|
24
|
+
Announce adapters receive it as a second argument so an adapter can be written once for
|
|
25
|
+
any future transition without changing its signature; today only `created` is emitted.
|
|
30
26
|
"""
|
|
31
27
|
|
|
32
28
|
# 256 KB is the smallest of the caps on the way out (SNS, SQS and EventBridge all sit
|
|
@@ -113,21 +109,24 @@ class EntryPoint:
|
|
|
113
109
|
|
|
114
110
|
|
|
115
111
|
@dataclass(frozen=True)
|
|
116
|
-
class
|
|
117
|
-
"""One question a
|
|
112
|
+
class Question:
|
|
113
|
+
"""One question for a human. What `publish()` takes.
|
|
114
|
+
|
|
115
|
+
`question_id` is whatever the answer must carry back. In interrupt mode it is
|
|
116
|
+
LangGraph's `Interrupt.id`; in async mode it is an id you minted -- derive it from the
|
|
117
|
+
inputs (`sha256(thread | tool | args)`) so the same trigger produces the same question
|
|
118
|
+
and the consumer sees one, not two.
|
|
119
|
+
"""
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
question_id: str
|
|
120
122
|
question: Any
|
|
121
123
|
policy: WaitPolicy
|
|
122
124
|
asked_at: float | None = None
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
timeout` on each republish would walk forward forever, which is precisely the
|
|
129
|
-
failure a timeout exists to prevent.
|
|
130
|
-
"""
|
|
125
|
+
"""POSIX timestamp of when the question was asked, if known. `expires_at` is measured
|
|
126
|
+
from it when set and from publish time otherwise."""
|
|
127
|
+
source: Mapping[str, Any] | None = None
|
|
128
|
+
"""Where it came from -- `{"tool": "issue_refund"}`, `{"node": "review"}`. The first
|
|
129
|
+
thing an approver reads, so worth filling in when you can."""
|
|
131
130
|
|
|
132
131
|
|
|
133
132
|
@dataclass(frozen=True)
|
|
@@ -137,13 +136,15 @@ class WaitEnvelope:
|
|
|
137
136
|
type: str
|
|
138
137
|
event_id: str
|
|
139
138
|
thread_id: str
|
|
140
|
-
|
|
139
|
+
question_id: str
|
|
141
140
|
question: Any
|
|
142
141
|
allowed_actions: tuple[str, ...]
|
|
143
142
|
expires_at: str | None
|
|
144
143
|
reply_with: Mapping[str, Any]
|
|
145
144
|
reply_to: Mapping[str, str] | None = None
|
|
146
145
|
default: Any = None
|
|
146
|
+
answer_ttl: str | int | None = None
|
|
147
|
+
source: Mapping[str, Any] | None = None
|
|
147
148
|
correlation: Mapping[str, str] | None = None
|
|
148
149
|
tags: Mapping[str, str] = field(default_factory=_no_str_map)
|
|
149
150
|
|
|
@@ -153,22 +154,24 @@ class WaitEnvelope:
|
|
|
153
154
|
|
|
154
155
|
Not `event_id`: that is fresh per publish, so a redelivered start message
|
|
155
156
|
republishing the same question would look like a second question. LangGraph
|
|
156
|
-
guarantees `
|
|
157
|
+
guarantees `question_id` is stable across re-entry and resume-from-checkpoint
|
|
157
158
|
(verified in `test_spike_langgraph.py`), which makes this key stable for exactly
|
|
158
159
|
as long as the question is.
|
|
159
160
|
"""
|
|
160
|
-
return f"{self.type}:{self.
|
|
161
|
+
return f"{self.type}:{self.question_id}"
|
|
161
162
|
|
|
162
163
|
def to_dict(self) -> dict[str, Any]:
|
|
163
164
|
return {
|
|
164
165
|
"type": self.type,
|
|
165
166
|
"event_id": self.event_id,
|
|
166
167
|
"thread_id": self.thread_id,
|
|
167
|
-
"
|
|
168
|
+
"question_id": self.question_id,
|
|
168
169
|
"question": self.question,
|
|
169
170
|
"allowed_actions": list(self.allowed_actions),
|
|
170
171
|
"expires_at": self.expires_at,
|
|
171
172
|
"default": self.default,
|
|
173
|
+
"answer_ttl": self.answer_ttl,
|
|
174
|
+
"source": dict(self.source) if self.source else None,
|
|
172
175
|
"reply_to": dict(self.reply_to) if self.reply_to else None,
|
|
173
176
|
"reply_with": dict(self.reply_with),
|
|
174
177
|
"correlation": dict(self.correlation) if self.correlation else None,
|