agent-wait 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-wait
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
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/
@@ -48,28 +48,38 @@ pip install agent-wait-aws # SNS / SQS / EventBridge / Dynam
48
48
 
49
49
  ## The whole thing
50
50
 
51
- **In the graph** — one line, where the decision belongs:
51
+ **In the graph** — one decorator, on the function where the decision belongs:
52
52
 
53
53
  ```python
54
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
55
+ from langgraph_wait import hitl
56
+
57
+ FINANCE = WaitPolicy(
58
+ timeout="P3D",
59
+ default={"action": "reject"},
60
+ allowed_actions=("approve", "reject"),
61
+ tags={"approver_group": "finance"},
62
+ )
63
+
64
+
65
+ @tool
66
+ @hitl(FINANCE)
67
+ def issue_refund(order_id: str, amount: int) -> str:
68
+ payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
69
+ return "refunded"
69
70
  ```
70
71
 
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.
72
+ Calling `issue_refund(...)` inside a run parks the graph on `{"function": "issue_refund",
73
+ "args": {...}}`. On resume, `{"action": "approve"}` runs the body — optionally with edited
74
+ `args` — and anything else is returned in its place, so a model sees why it did not run.
75
+
76
+ A node that needs the answer itself declares a `decision` parameter and always runs:
77
+
78
+ ```python
79
+ @hitl(FINANCE)
80
+ def review(state, decision=None):
81
+ return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
82
+ ```
73
83
 
74
84
  **After the run** — one call:
75
85
 
@@ -83,7 +93,8 @@ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
83
93
 
84
94
  It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
85
95
  and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
86
- no checkpointer. If nothing interrupted, it does nothing.
96
+ no checkpointer. If nothing interrupted, it does nothing. A plain `interrupt(value)` from
97
+ a node that never heard of this library is published too, with the default policy.
87
98
 
88
99
  **When the answer comes back** — the one `if` you write:
89
100
 
@@ -102,24 +113,7 @@ publish_interrupts(result, message["thread_id"], announce)
102
113
  That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
103
114
  resume for a question the thread has moved past — so there is nothing to check.
104
115
 
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:**
116
+ ## Async mode — the thread does not park
123
117
 
124
118
  ```python
125
119
  @tool
@@ -127,7 +121,7 @@ interrupt — are published with it.
127
121
  def issue_refund(order_id: str, amount: int) -> str: ...
128
122
  ```
129
123
 
130
- The decorator *is* the publisher: the tool call announces the question and returns
124
+ The decorator *is* the publisher: the call announces the question and returns
131
125
  `{"status": "pending_approval", "question_id": ...}` without running. The graph carries
132
126
  on; nothing is parked; nothing to call after the run. The decision arrives later as a new
133
127
  message and your graph acts on it. This is the mode for a single-thread channel like
@@ -140,17 +134,17 @@ WhatsApp, where the approver is not the person on the thread.
140
134
  "type": "wait.created",
141
135
  "thread_id": "order-4471",
142
136
  "question_id": "a1b2c3d4e5f60718",
143
- "question": { "kind": "refund_approval", "amount": 41000 },
137
+ "question": { "function": "issue_refund", "args": { "order_id": "order-4471", "amount": 41000 } },
144
138
  "allowed_actions": ["approve", "reject"],
145
139
  "expires_at": "2026-09-14T09:00:00Z",
146
140
  "default": { "action": "reject" },
147
- "source": { "tool": "issue_refund" },
141
+ "source": { "function": "issue_refund" },
148
142
  "reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
149
143
  }
150
144
  ```
151
145
 
152
146
  `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
153
- `answer` is what `ask()` returns — verbatim.
147
+ `answer` is what the `@hitl` function receives — verbatim.
154
148
 
155
149
  Full schema, the recommended answer shape, and how to deduplicate:
156
150
  [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
@@ -196,20 +190,23 @@ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
196
190
  - **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
197
191
  async mode there is none unless you keep one.
198
192
 
199
- ## One LangGraph 1.2.x behaviour to know
193
+ ## Two things to know
194
+
195
+ **Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
196
+ tool is called, in its own shape; `@hitl` interrupts inside the call. Both on one tool
197
+ means two interrupts for one approval. Use one or the other.
200
198
 
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.
199
+ **One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
200
+ one `ToolNode` get the **same interrupt id**
201
+ ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only one surfaces
202
+ per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)). Give each
203
+ interrupting tool its own node. Pinned by a test that fails if LangGraph changes it.
207
204
 
208
205
  ## Layout
209
206
 
210
207
  ```
211
208
  packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
212
- packages/langgraph-wait ask(), @hitl, publish_interrupts(). The only LangGraph import.
209
+ packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
213
210
  packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
214
211
  examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
215
212
  docs/ message contract, architecture, announcer guide, consumer guide.
@@ -1,194 +1,191 @@
1
- # agent-wait
2
-
3
- [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
4
- [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
-
7
- **Get a LangGraph interrupt out of the process, and the answer back in.**
8
-
9
- When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
10
- called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
11
- anyone *else* that a question was asked, and no built-in way for anyone else to answer
12
- it. The moment the question has to reach a person on Slack, an approvals dashboard, a
13
- ticket queue or another service, you are writing that code yourself — whether your agent
14
- is a server that runs for a year or a Lambda that is gone in seconds.
15
-
16
- agent-wait is that code. It takes the 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
+ # agent-wait
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
4
+ [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
+
7
+ **Get a LangGraph interrupt out of the process, and the answer back in.**
8
+
9
+ When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
10
+ called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
11
+ anyone *else* that a question was asked, and no built-in way for anyone else to answer
12
+ it. The moment the question has to reach a person on Slack, an approvals dashboard, a
13
+ ticket queue or another service, you are writing that code yourself — whether your agent
14
+ is a server that runs for a year or a Lambda that is gone in seconds.
15
+
16
+ agent-wait is that code. It takes the 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 decorator, on the function where the decision belongs:
28
+
29
+ ```python
30
+ from agent_wait import WaitPolicy
31
+ from langgraph_wait import hitl
32
+
33
+ FINANCE = WaitPolicy(
34
+ timeout="P3D",
35
+ default={"action": "reject"},
36
+ allowed_actions=("approve", "reject"),
37
+ tags={"approver_group": "finance"},
38
+ )
39
+
40
+
41
+ @tool
42
+ @hitl(FINANCE)
43
+ def issue_refund(order_id: str, amount: int) -> str:
44
+ payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
45
+ return "refunded"
46
+ ```
47
+
48
+ Calling `issue_refund(...)` inside a run parks the graph on `{"function": "issue_refund",
49
+ "args": {...}}`. On resume, `{"action": "approve"}` runs the body — optionally with edited
50
+ `args` — and anything else is returned in its place, so a model sees why it did not run.
51
+
52
+ A node that needs the answer itself declares a `decision` parameter and always runs:
53
+
54
+ ```python
55
+ @hitl(FINANCE)
56
+ def review(state, decision=None):
57
+ return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
58
+ ```
59
+
60
+ **After the run** — one call:
61
+
62
+ ```python
63
+ from agent_wait_aws import SnsAnnounce
64
+ from langgraph_wait import publish_interrupts
65
+
66
+ result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
67
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
68
+ ```
69
+
70
+ It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
71
+ and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
72
+ no checkpointer. If nothing interrupted, it does nothing. A plain `interrupt(value)` from
73
+ a node that never heard of this library is published too, with the default policy.
74
+
75
+ **When the answer comes back** — the one `if` you write:
76
+
77
+ ```python
78
+ from langgraph.types import Command
79
+
80
+ if "question_id" in message: # an answer
81
+ value = Command(resume={message["question_id"]: message["answer"]})
82
+ else: # a start
83
+ value = message["input"]
84
+
85
+ result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
86
+ publish_interrupts(result, message["thread_id"], announce)
87
+ ```
88
+
89
+ That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
90
+ resume for a question the thread has moved past — so there is nothing to check.
91
+
92
+ ## Async mode — the thread does not park
93
+
94
+ ```python
95
+ @tool
96
+ @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
97
+ def issue_refund(order_id: str, amount: int) -> str: ...
98
+ ```
99
+
100
+ The decorator *is* the publisher: the call announces the question and returns
101
+ `{"status": "pending_approval", "question_id": ...}` without running. The graph carries
102
+ on; nothing is parked; nothing to call after the run. The decision arrives later as a new
103
+ message and your graph acts on it. This is the mode for a single-thread channel like
104
+ WhatsApp, where the approver is not the person on the thread.
105
+
106
+ ## What goes out
107
+
108
+ ```json
109
+ {
110
+ "type": "wait.created",
111
+ "thread_id": "order-4471",
112
+ "question_id": "a1b2c3d4e5f60718",
113
+ "question": { "function": "issue_refund", "args": { "order_id": "order-4471", "amount": 41000 } },
114
+ "allowed_actions": ["approve", "reject"],
115
+ "expires_at": "2026-09-14T09:00:00Z",
116
+ "default": { "action": "reject" },
117
+ "source": { "function": "issue_refund" },
118
+ "reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
119
+ }
120
+ ```
121
+
122
+ `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
123
+ `answer` is what the `@hitl` function receives — verbatim.
124
+
125
+ Full schema, the recommended answer shape, and how to deduplicate:
126
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
127
+
128
+ ## Announcers
129
+
130
+ Subclass `BaseAnnounce`, write one method:
131
+
132
+ ```python
133
+ from agent_wait import BaseAnnounce
134
+
135
+
136
+ class RedisAnnounce(BaseAnnounce):
137
+ name = "redis"
138
+
139
+ def __init__(self, client, **kw):
140
+ super().__init__(**kw)
141
+ self.client = client
142
+
143
+ def deliver(self, envelope, transition):
144
+ self.client.set(envelope.dedupe_key, envelope.to_json())
145
+ ```
146
+
147
+ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
148
+
149
+ | Adapter | Package | Where the question lands |
150
+ |---|---|---|
151
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
152
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
153
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
154
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
155
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
156
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
157
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
158
+
159
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
160
+
161
+ ## What the library does *not* do
162
+
163
+ - **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
164
+ - **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
165
+ published so the consumer has the asker's intent. Acting on them is the consumer's.
166
+ - **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
167
+ async mode there is none unless you keep one.
168
+
169
+ ## Two things to know
170
+
171
+ **Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
172
+ tool is called, in its own shape; `@hitl` interrupts inside the call. Both on one tool
173
+ means two interrupts for one approval. Use one or the other.
174
+
175
+ **One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
176
+ one `ToolNode` get the **same interrupt id**
177
+ ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only one surfaces
178
+ per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)). Give each
179
+ interrupting tool its own node. Pinned by a test that fails if LangGraph changes it.
180
+
181
+ ## Layout
182
+
183
+ ```
184
+ packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
185
+ packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
186
+ packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
187
+ examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
188
+ docs/ message contract, architecture, announcer guide, consumer guide.
189
+ ```
190
+
191
+ 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.0"
3
+ version = "0.4.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"
@@ -44,7 +44,7 @@ from .model import (
44
44
  from .policy import WaitPolicy, parse_duration
45
45
  from .publish import build_envelope, publish
46
46
 
47
- __version__ = "0.3.0"
47
+ __version__ = "0.4.0"
48
48
 
49
49
  __all__ = [
50
50
  "MAX_QUESTION_BYTES",
File without changes
File without changes