agent-wait 0.4.1__tar.gz → 0.6.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.
Files changed (28) hide show
  1. {agent_wait-0.4.1 → agent_wait-0.6.0}/PKG-INFO +107 -32
  2. {agent_wait-0.4.1 → agent_wait-0.6.0}/README.md +94 -29
  3. agent_wait-0.6.0/pyproject.toml +111 -0
  4. agent_wait-0.6.0/reference/wait-sim/README.md +31 -0
  5. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/__init__.py +86 -78
  6. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/base.py +1 -1
  7. agent_wait-0.6.0/src/agent_wait/aws/__init__.py +19 -0
  8. agent_wait-0.6.0/src/agent_wait/aws/dynamodb.py +75 -0
  9. agent_wait-0.6.0/src/agent_wait/aws/eventbridge.py +57 -0
  10. agent_wait-0.6.0/src/agent_wait/aws/sns.py +51 -0
  11. agent_wait-0.6.0/src/agent_wait/aws/sqs.py +50 -0
  12. agent_wait-0.6.0/src/agent_wait/framework.py +55 -0
  13. agent_wait-0.6.0/src/agent_wait/langgraph/__init__.py +69 -0
  14. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/publish.py +1 -1
  15. agent_wait-0.6.0/src/agent_wait/pydantic_ai/__init__.py +122 -0
  16. agent_wait-0.6.0/src/agent_wait/wait.py +203 -0
  17. agent_wait-0.4.1/pyproject.toml +0 -38
  18. {agent_wait-0.4.1 → agent_wait-0.6.0}/.gitignore +0 -0
  19. {agent_wait-0.4.1 → agent_wait-0.6.0}/LICENSE +0 -0
  20. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/__init__.py +0 -0
  21. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/composite.py +0 -0
  22. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/log.py +0 -0
  23. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/memory.py +0 -0
  24. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/announce/webhook.py +0 -0
  25. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/errors.py +0 -0
  26. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/model.py +0 -0
  27. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/policy.py +0 -0
  28. {agent_wait-0.4.1 → agent_wait-0.6.0}/src/agent_wait/py.typed +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-wait
3
- Version: 0.4.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.
3
+ Version: 0.6.0
4
+ Summary: Human-in-the-loop for LangGraph and Pydantic AI agents -- and anything else a run has to wait for. Get the interrupt out of the process (SNS, SQS, EventBridge, DynamoDB, webhook) with everything needed to answer it; get 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
@@ -10,7 +10,7 @@ Project-URL: Changelog, https://github.com/skamalj/agent-wait/blob/main/CHANGELO
10
10
  Author-email: Kamaljeet Singh <skamalj@gmail.com>
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
13
- Keywords: agents,approval,durable,human-in-the-loop,interrupt,langgraph,serverless
13
+ Keywords: agents,approval,callback,durable,dynamodb,eventbridge,hitl,human-in-the-loop,interrupt,lambda,langchain,langgraph,pydantic-ai,serverless,sns,sqs,webhook
14
14
  Classifier: Development Status :: 4 - Beta
15
15
  Classifier: Intended Audience :: Developers
16
16
  Classifier: License :: OSI Approved :: MIT License
@@ -20,6 +20,16 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Software Development :: Libraries
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.12
23
+ Provides-Extra: all
24
+ Requires-Dist: boto3>=1.35; extra == 'all'
25
+ Requires-Dist: langgraph<2,>=1.2; extra == 'all'
26
+ Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'all'
27
+ Provides-Extra: aws
28
+ Requires-Dist: boto3>=1.35; extra == 'aws'
29
+ Provides-Extra: langgraph
30
+ Requires-Dist: langgraph<2,>=1.2; extra == 'langgraph'
31
+ Provides-Extra: pydantic-ai
32
+ Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'pydantic-ai'
23
33
  Description-Content-Type: text/markdown
24
34
 
25
35
  # agent-wait
@@ -29,32 +39,42 @@ Description-Content-Type: text/markdown
29
39
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
30
40
  [![Docs](https://img.shields.io/badge/docs-skamalj.github.io%2Fagent--wait-black.svg)](https://skamalj.github.io/agent-wait/)
31
41
 
32
- **Get a LangGraph interrupt out of the process, and the answer back in.**
42
+ **Make an agent wait for something outside the process — and get the answer back in.**
33
43
 
34
- When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
35
- called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
36
- anyone *else* that a question was asked, and no built-in way for anyone else to answer
37
- it. The moment the question has to reach a person on Slack, an approvals dashboard, a
38
- ticket queue or another service, you are writing that code yourself — whether your agent
39
- is a server that runs for a year or a Lambda that is gone in seconds.
44
+ Human-in-the-loop is the common case: a refund over the limit, a contract clause, a
45
+ deploy. But the same shape covers a vendor callback, a payment processor, a KYC check, or
46
+ another agent — anything the run cannot continue without, and nothing inside the process
47
+ can supply.
40
48
 
41
- agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
42
- a topic, a queue, a webhook, a database row — with everything needed to answer it in one
43
- envelope, and documents the shape of the answer so the return leg is one `if`.
49
+ LangGraph has `interrupt()` for this; Pydantic AI has `ApprovalRequired`. In both, the
50
+ run pauses and the question is handed to whatever called it — and that is where the
51
+ framework stops. There is no built-in way to tell anyone *else* that a question was asked,
52
+ and no built-in way for anyone else to answer it. The moment the question has to reach
53
+ Slack, an approvals dashboard, a ticket queue or another service, you are writing that
54
+ code yourself — whether your agent is a server that runs for a year or a Lambda that is
55
+ gone in seconds.
56
+
57
+ agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
58
+ can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
59
+ it in one envelope, and documents the shape of the answer so the return leg is one `if`.
44
60
 
45
61
  ```bash
46
- pip install agent-wait langgraph-wait # core + LangGraph
47
- pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
62
+ pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
63
+ pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
64
+ pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
48
65
  ```
49
66
 
67
+ The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
68
+ announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
69
+
50
70
  ## The whole thing
51
71
 
52
72
  **In the graph** — one decorator, on the function where the decision belongs:
53
73
 
54
74
  ```python
55
75
  from agent_wait import WaitPolicy
76
+ from agent_wait.langgraph import wait
56
77
  from langchain_core.tools import tool
57
- from langgraph_wait import hitl
58
78
 
59
79
  FINANCE = WaitPolicy(
60
80
  timeout="P3D",
@@ -65,7 +85,7 @@ FINANCE = WaitPolicy(
65
85
 
66
86
 
67
87
  @tool
68
- @hitl(FINANCE)
88
+ @wait(FINANCE)
69
89
  def issue_refund(order_id: str, amount: int) -> str:
70
90
  payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
71
91
  return "refunded"
@@ -85,23 +105,24 @@ none is enforced — the consumer acts on them:
85
105
  | `answer_ttl` | How long an answer stays usable after it is given. |
86
106
  | `allowed_actions` | Which answers are meaningful — the buttons to draw. |
87
107
  | `tags` | Routing hints; become SNS message attributes. |
108
+ | `correlation` | Your reference for the thing being waited on — a vendor job id, a ticket. |
88
109
 
89
110
  A node that needs the answer itself declares a `decision` parameter and always runs,
90
111
  approve or not, with the answer in it:
91
112
 
92
113
  ```python
93
- @hitl(FINANCE)
114
+ @wait(FINANCE)
94
115
  def review(state, decision=None):
95
116
  return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
96
117
  ```
97
118
 
98
- The parameter name is yours — `@hitl(FINANCE, decision="verdict")` looks for `verdict`.
119
+ The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
99
120
 
100
121
  **After the run** — one call:
101
122
 
102
123
  ```python
103
- from agent_wait_aws import SnsAnnounce
104
- from langgraph_wait import publish_interrupts
124
+ from agent_wait.aws import SnsAnnounce
125
+ from agent_wait.langgraph import publish_interrupts
105
126
 
106
127
  result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
107
128
  publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
@@ -129,14 +150,58 @@ publish_interrupts(result, message["thread_id"], announce)
129
150
  That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
130
151
  resume for a question the thread has moved past — so there is nothing to check.
131
152
 
153
+ ## The same two names on Pydantic AI
154
+
155
+ ```python
156
+ from agent_wait.pydantic_ai import publish_interrupts, wait
157
+ from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext, ToolApproved, ToolDenied
158
+
159
+ agent = Agent("openai:gpt-5.4", output_type=[str, DeferredToolRequests])
160
+
161
+
162
+ @agent.tool
163
+ @wait(FINANCE)
164
+ def issue_refund(ctx: RunContext[None], order_id: str, amount: int) -> str:
165
+ payments.refund(order_id, amount)
166
+ return "refunded"
167
+
168
+
169
+ result = agent.run_sync("refund order 4471", message_history=history)
170
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
171
+ store(thread_id, result.all_messages()) # Pydantic AI has no checkpointer; the history is yours
172
+ ```
173
+
174
+ The run ends with `DeferredToolRequests` in `result.output`; `question_id` is the
175
+ `tool_call_id`. The answer goes back through the framework's own resume, with the answer
176
+ itself riding in `metadata` so the `@wait` rules apply unchanged:
177
+
178
+ ```python
179
+ approved = answer.get("action") == "approve"
180
+ result = agent.run_sync(
181
+ None,
182
+ message_history=load(thread_id),
183
+ deferred_tool_results=DeferredToolResults(
184
+ approvals={question_id: ToolApproved() if approved else ToolDenied(str(answer))},
185
+ metadata={question_id: answer},
186
+ ),
187
+ )
188
+ store(thread_id, result.all_messages()) # before acknowledging the answer message
189
+ ```
190
+
191
+ Two rules the framework leaves to you: name the `RunContext` parameter `ctx`, and store
192
+ the post-run history *before* you acknowledge an answer — a duplicate answer is refused
193
+ only if the history you pass already holds the tool's return. `requires_approval=True`
194
+ tools and `CallDeferred` calls (a job handed to an external system) are published too,
195
+ with the default policy.
196
+
132
197
  ## Async mode — the thread does not park
133
198
 
134
199
  ```python
135
- from agent_wait_aws import DynamoDbAnnounce, SnsAnnounce
200
+ from agent_wait.aws import DynamoDbAnnounce, SnsAnnounce
136
201
 
137
202
 
138
203
  @tool
139
- @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
204
+ @wait(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
140
205
  def issue_refund(order_id: str, amount: int) -> str: ...
141
206
  ```
142
207
 
@@ -165,7 +230,7 @@ question; the `DynamoDbAnnounce` row (or whatever you keep) is the only record.
165
230
  ```
166
231
 
167
232
  `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
168
- `answer` is what the `@hitl` function receives — verbatim.
233
+ `answer` is what the `@wait` function receives — verbatim.
169
234
 
170
235
  Full schema, the recommended answer shape, and how to deduplicate:
171
236
  [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
@@ -191,8 +256,8 @@ class RedisAnnounce(BaseAnnounce):
191
256
 
192
257
  A raise inside `deliver()` becomes a log line; the run completes.
193
258
 
194
- Shipped: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`, `InMemoryAnnounce`,
195
- and in `agent-wait-aws`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
259
+ Shipped in the core: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
260
+ `InMemoryAnnounce`. With `[aws]`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
196
261
  `DynamoDbAnnounce` (the question as a row an approvals UI can query). Each one, its
197
262
  constructor and where the question lands: [Announcers](https://skamalj.github.io/agent-wait/announcers/).
198
263
  Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
@@ -208,7 +273,7 @@ Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an
208
273
  ## Two things to know
209
274
 
210
275
  **Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
211
- tool is called, in its own shape; `@hitl` interrupts inside the call. Both on one tool
276
+ tool is called, in its own shape; `@wait` interrupts inside the call. Both on one tool
212
277
  means two interrupts for one approval. Use one or the other.
213
278
 
214
279
  **One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
@@ -219,12 +284,22 @@ interrupting tool its own node. Pinned by a test that fails if LangGraph changes
219
284
 
220
285
  ## Layout
221
286
 
287
+ One distribution, one import root. Frameworks and providers are subpackages behind extras:
288
+
222
289
  ```
223
- packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
224
- packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
225
- packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
226
- examples/refund_agent a graph and a host, written for Lambda behind SQS.
227
- docs/ message contract, architecture, announcer guide, consumer guide.
290
+ agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
291
+ Webhook/Log/InMemory announcers, and the Framework interface. No deps.
292
+ agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
293
+ agent_wait.pydantic_ai [pydantic-ai] the same two names bound to Pydantic AI's deferred tools.
294
+ agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
295
+ examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
296
+ docs/ message contract, architecture, announcer guide, consumer guide.
228
297
  ```
229
298
 
299
+ `@wait` and `publish_interrupts` are written once against a three-method `Framework`
300
+ interface; `agent_wait.langgraph` and `agent_wait.pydantic_ai` are implementors of it,
301
+ about fifty lines each. Announcers are the same split: `BaseAnnounce` is the interface,
302
+ each provider subpackage the implementors. See
303
+ [Architecture](https://skamalj.github.io/agent-wait/architecture/).
304
+
230
305
  MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -5,32 +5,42 @@
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
6
  [![Docs](https://img.shields.io/badge/docs-skamalj.github.io%2Fagent--wait-black.svg)](https://skamalj.github.io/agent-wait/)
7
7
 
8
- **Get a LangGraph interrupt out of the process, and the answer back in.**
8
+ **Make an agent wait for something outside the process — and get the answer back in.**
9
9
 
10
- When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
11
- called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
12
- anyone *else* that a question was asked, and no built-in way for anyone else to answer
13
- it. The moment the question has to reach a person on Slack, an approvals dashboard, a
14
- ticket queue or another service, you are writing that code yourself — whether your agent
15
- is a server that runs for a year or a Lambda that is gone in seconds.
10
+ Human-in-the-loop is the common case: a refund over the limit, a contract clause, a
11
+ deploy. But the same shape covers a vendor callback, a payment processor, a KYC check, or
12
+ another agent — anything the run cannot continue without, and nothing inside the process
13
+ can supply.
16
14
 
17
- agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
18
- a topic, a queue, a webhook, a database row — with everything needed to answer it in one
19
- envelope, and documents the shape of the answer so the return leg is one `if`.
15
+ LangGraph has `interrupt()` for this; Pydantic AI has `ApprovalRequired`. In both, the
16
+ run pauses and the question is handed to whatever called it — and that is where the
17
+ framework stops. There is no built-in way to tell anyone *else* that a question was asked,
18
+ and no built-in way for anyone else to answer it. The moment the question has to reach
19
+ Slack, an approvals dashboard, a ticket queue or another service, you are writing that
20
+ code yourself — whether your agent is a server that runs for a year or a Lambda that is
21
+ gone in seconds.
22
+
23
+ agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
24
+ can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
25
+ it in one envelope, and documents the shape of the answer so the return leg is one `if`.
20
26
 
21
27
  ```bash
22
- pip install agent-wait langgraph-wait # core + LangGraph
23
- pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
28
+ pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
29
+ pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
30
+ pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
24
31
  ```
25
32
 
33
+ The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
34
+ announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
35
+
26
36
  ## The whole thing
27
37
 
28
38
  **In the graph** — one decorator, on the function where the decision belongs:
29
39
 
30
40
  ```python
31
41
  from agent_wait import WaitPolicy
42
+ from agent_wait.langgraph import wait
32
43
  from langchain_core.tools import tool
33
- from langgraph_wait import hitl
34
44
 
35
45
  FINANCE = WaitPolicy(
36
46
  timeout="P3D",
@@ -41,7 +51,7 @@ FINANCE = WaitPolicy(
41
51
 
42
52
 
43
53
  @tool
44
- @hitl(FINANCE)
54
+ @wait(FINANCE)
45
55
  def issue_refund(order_id: str, amount: int) -> str:
46
56
  payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
47
57
  return "refunded"
@@ -61,23 +71,24 @@ none is enforced — the consumer acts on them:
61
71
  | `answer_ttl` | How long an answer stays usable after it is given. |
62
72
  | `allowed_actions` | Which answers are meaningful — the buttons to draw. |
63
73
  | `tags` | Routing hints; become SNS message attributes. |
74
+ | `correlation` | Your reference for the thing being waited on — a vendor job id, a ticket. |
64
75
 
65
76
  A node that needs the answer itself declares a `decision` parameter and always runs,
66
77
  approve or not, with the answer in it:
67
78
 
68
79
  ```python
69
- @hitl(FINANCE)
80
+ @wait(FINANCE)
70
81
  def review(state, decision=None):
71
82
  return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
72
83
  ```
73
84
 
74
- The parameter name is yours — `@hitl(FINANCE, decision="verdict")` looks for `verdict`.
85
+ The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
75
86
 
76
87
  **After the run** — one call:
77
88
 
78
89
  ```python
79
- from agent_wait_aws import SnsAnnounce
80
- from langgraph_wait import publish_interrupts
90
+ from agent_wait.aws import SnsAnnounce
91
+ from agent_wait.langgraph import publish_interrupts
81
92
 
82
93
  result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
83
94
  publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
@@ -105,14 +116,58 @@ publish_interrupts(result, message["thread_id"], announce)
105
116
  That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
106
117
  resume for a question the thread has moved past — so there is nothing to check.
107
118
 
119
+ ## The same two names on Pydantic AI
120
+
121
+ ```python
122
+ from agent_wait.pydantic_ai import publish_interrupts, wait
123
+ from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext, ToolApproved, ToolDenied
124
+
125
+ agent = Agent("openai:gpt-5.4", output_type=[str, DeferredToolRequests])
126
+
127
+
128
+ @agent.tool
129
+ @wait(FINANCE)
130
+ def issue_refund(ctx: RunContext[None], order_id: str, amount: int) -> str:
131
+ payments.refund(order_id, amount)
132
+ return "refunded"
133
+
134
+
135
+ result = agent.run_sync("refund order 4471", message_history=history)
136
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
137
+ store(thread_id, result.all_messages()) # Pydantic AI has no checkpointer; the history is yours
138
+ ```
139
+
140
+ The run ends with `DeferredToolRequests` in `result.output`; `question_id` is the
141
+ `tool_call_id`. The answer goes back through the framework's own resume, with the answer
142
+ itself riding in `metadata` so the `@wait` rules apply unchanged:
143
+
144
+ ```python
145
+ approved = answer.get("action") == "approve"
146
+ result = agent.run_sync(
147
+ None,
148
+ message_history=load(thread_id),
149
+ deferred_tool_results=DeferredToolResults(
150
+ approvals={question_id: ToolApproved() if approved else ToolDenied(str(answer))},
151
+ metadata={question_id: answer},
152
+ ),
153
+ )
154
+ store(thread_id, result.all_messages()) # before acknowledging the answer message
155
+ ```
156
+
157
+ Two rules the framework leaves to you: name the `RunContext` parameter `ctx`, and store
158
+ the post-run history *before* you acknowledge an answer — a duplicate answer is refused
159
+ only if the history you pass already holds the tool's return. `requires_approval=True`
160
+ tools and `CallDeferred` calls (a job handed to an external system) are published too,
161
+ with the default policy.
162
+
108
163
  ## Async mode — the thread does not park
109
164
 
110
165
  ```python
111
- from agent_wait_aws import DynamoDbAnnounce, SnsAnnounce
166
+ from agent_wait.aws import DynamoDbAnnounce, SnsAnnounce
112
167
 
113
168
 
114
169
  @tool
115
- @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
170
+ @wait(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
116
171
  def issue_refund(order_id: str, amount: int) -> str: ...
117
172
  ```
118
173
 
@@ -141,7 +196,7 @@ question; the `DynamoDbAnnounce` row (or whatever you keep) is the only record.
141
196
  ```
142
197
 
143
198
  `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
144
- `answer` is what the `@hitl` function receives — verbatim.
199
+ `answer` is what the `@wait` function receives — verbatim.
145
200
 
146
201
  Full schema, the recommended answer shape, and how to deduplicate:
147
202
  [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
@@ -167,8 +222,8 @@ class RedisAnnounce(BaseAnnounce):
167
222
 
168
223
  A raise inside `deliver()` becomes a log line; the run completes.
169
224
 
170
- Shipped: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`, `InMemoryAnnounce`,
171
- and in `agent-wait-aws`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
225
+ Shipped in the core: `WebhookAnnounce` (signed JSON POST, stdlib), `LogAnnounce`,
226
+ `InMemoryAnnounce`. With `[aws]`: `SnsAnnounce`, `SqsAnnounce`, `EventBridgeAnnounce`,
172
227
  `DynamoDbAnnounce` (the question as a row an approvals UI can query). Each one, its
173
228
  constructor and where the question lands: [Announcers](https://skamalj.github.io/agent-wait/announcers/).
174
229
  Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
@@ -184,7 +239,7 @@ Your own: [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an
184
239
  ## Two things to know
185
240
 
186
241
  **Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
187
- tool is called, in its own shape; `@hitl` interrupts inside the call. Both on one tool
242
+ tool is called, in its own shape; `@wait` interrupts inside the call. Both on one tool
188
243
  means two interrupts for one approval. Use one or the other.
189
244
 
190
245
  **One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
@@ -195,12 +250,22 @@ interrupting tool its own node. Pinned by a test that fails if LangGraph changes
195
250
 
196
251
  ## Layout
197
252
 
253
+ One distribution, one import root. Frameworks and providers are subpackages behind extras:
254
+
198
255
  ```
199
- packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
200
- packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
201
- packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
202
- examples/refund_agent a graph and a host, written for Lambda behind SQS.
203
- docs/ message contract, architecture, announcer guide, consumer guide.
256
+ agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
257
+ Webhook/Log/InMemory announcers, and the Framework interface. No deps.
258
+ agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
259
+ agent_wait.pydantic_ai [pydantic-ai] the same two names bound to Pydantic AI's deferred tools.
260
+ agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
261
+ examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
262
+ docs/ message contract, architecture, announcer guide, consumer guide.
204
263
  ```
205
264
 
265
+ `@wait` and `publish_interrupts` are written once against a three-method `Framework`
266
+ interface; `agent_wait.langgraph` and `agent_wait.pydantic_ai` are implementors of it,
267
+ about fifty lines each. Announcers are the same split: `BaseAnnounce` is the interface,
268
+ each provider subpackage the implementors. See
269
+ [Architecture](https://skamalj.github.io/agent-wait/architecture/).
270
+
206
271
  MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -0,0 +1,111 @@
1
+ [project]
2
+ name = "agent-wait"
3
+ version = "0.6.0"
4
+ description = "Human-in-the-loop for LangGraph and Pydantic AI agents -- and anything else a run has to wait for. Get the interrupt out of the process (SNS, SQS, EventBridge, DynamoDB, webhook) with everything needed to answer it; get the answer back in."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Kamaljeet Singh", email = "skamalj@gmail.com" }]
10
+ keywords = [
11
+ "human-in-the-loop",
12
+ "hitl",
13
+ "approval",
14
+ "langgraph",
15
+ "langchain",
16
+ "pydantic-ai",
17
+ "agents",
18
+ "interrupt",
19
+ "durable",
20
+ "serverless",
21
+ "lambda",
22
+ "sqs",
23
+ "sns",
24
+ "eventbridge",
25
+ "dynamodb",
26
+ "webhook",
27
+ "callback",
28
+ ]
29
+ classifiers = [
30
+ "Development Status :: 4 - Beta",
31
+ "Intended Audience :: Developers",
32
+ "License :: OSI Approved :: MIT License",
33
+ "Programming Language :: Python :: 3",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Topic :: Software Development :: Libraries",
37
+ "Typing :: Typed",
38
+ ]
39
+ dependencies = []
40
+
41
+ [project.optional-dependencies]
42
+ langgraph = ["langgraph>=1.2,<2"]
43
+ pydantic-ai = ["pydantic-ai-slim>=2.43,<3"]
44
+ aws = ["boto3>=1.35"]
45
+ all = ["agent-wait[langgraph,pydantic-ai,aws]"]
46
+
47
+ [project.urls]
48
+ Homepage = "https://skamalj.github.io/agent-wait/"
49
+ Documentation = "https://skamalj.github.io/agent-wait/"
50
+ Source = "https://github.com/skamalj/agent-wait"
51
+ Issues = "https://github.com/skamalj/agent-wait/issues"
52
+ Changelog = "https://github.com/skamalj/agent-wait/blob/main/CHANGELOG.md"
53
+
54
+ [build-system]
55
+ requires = ["hatchling"]
56
+ build-backend = "hatchling.build"
57
+
58
+ [tool.hatch.build.targets.wheel]
59
+ packages = ["src/agent_wait"]
60
+
61
+ [tool.hatch.build.targets.sdist]
62
+ include = ["src/agent_wait", "README.md", "LICENSE"]
63
+
64
+ [dependency-groups]
65
+ dev = [
66
+ "agent-wait[all]",
67
+ "pytest>=8.3",
68
+ "pytest-cov>=5.0",
69
+ "ruff>=0.6",
70
+ "pyright>=1.1.380",
71
+ "moto[dynamodb,sqs,sns,events]>=5.0",
72
+ "boto3-stubs[dynamodb,sqs,sns,events]>=1.35",
73
+ "langgraph-checkpoint-sqlite>=2.0",
74
+ "aws-cdk-lib>=2.160",
75
+ "constructs>=10.3",
76
+ ]
77
+
78
+ [tool.uv.sources]
79
+ agent-wait = { workspace = true }
80
+
81
+ [tool.uv.workspace]
82
+ members = ["."]
83
+
84
+ [tool.pytest.ini_options]
85
+ testpaths = ["tests", "examples"]
86
+ addopts = "-q --strict-markers"
87
+ norecursedirs = ["cdk.out", "build", ".venv", "reference"]
88
+ markers = [
89
+ "e2e: requires real AWS; opt in with AGENT_WAIT_E2E=1",
90
+ "spike: empirical probes of LangGraph behaviour",
91
+ ]
92
+ filterwarnings = ["ignore::DeprecationWarning"]
93
+
94
+ [tool.ruff]
95
+ line-length = 110
96
+ target-version = "py312"
97
+ extend-exclude = ["reference", "cdk.out"]
98
+
99
+ [tool.ruff.lint]
100
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
101
+ ignore = ["B008", "RUF012"]
102
+
103
+ [tool.ruff.lint.per-file-ignores]
104
+ "tests/**" = ["E501"]
105
+
106
+ [tool.pyright]
107
+ include = ["src"]
108
+ exclude = ["reference", "**/node_modules", "**/__pycache__"]
109
+ pythonVersion = "3.12"
110
+ typeCheckingMode = "strict"
111
+ reportMissingTypeStubs = false
@@ -0,0 +1,31 @@
1
+ # agent-wait · end-to-end simulation (LangGraph agent on Lambda)
2
+
3
+ Runs a real LangGraph graph (1.2.x) through the durable-wait design, with in-memory stand-ins
4
+ for the AWS services. Every stand-in mimics the one property the design relies on:
5
+
6
+ | Stand-in | Real service | Property relied on |
7
+ |---------------------|-----------------------|------------------------------------------------------|
8
+ | `DynamoWaitStore` | DynamoDB | conditional writes (status CAS), unique idempotency key |
9
+ | `SqsFifo` | SQS FIFO | one in-flight message per MessageGroupId, visibility timeout, redelivery |
10
+ | `EventBridgeScheduler` | EventBridge Scheduler | one-time schedule named by wait_id, deleted on cancel |
11
+ | `EventBridgeBus` | EventBridge | fan-out to subscribers (SNS/Slack/webhooks) |
12
+ | `run_handler` etc. | Lambda | stateless invocations that can die mid-way |
13
+
14
+ ```
15
+ pip install langgraph
16
+ python simulate.py
17
+ ```
18
+
19
+ Files:
20
+ - `agent_wait/core.py` — the framework-agnostic core: Wait model, 5 ports, HMAC tokens, Waiter, sweeper
21
+ - `agent_wait/langgraph_adapter.py` — `ask()` + the LangGraph adapter (extract interrupts, build resume, still_pending)
22
+ - `agent_wait/aws_sim.py` — the AWS stand-ins
23
+ - `app/graph.py` — the customer's refund graph (knows nothing about AWS)
24
+ - `app/lambdas.py` — three Lambda handlers: run (SQS), answer (API Gateway), on_timer (Scheduler)
25
+ - `simulate.py` — four scenarios; `RUN_OUTPUT.txt` is a captured run
26
+
27
+ Scenarios:
28
+ - A crash after checkpoint but before register → redelivery → idempotent; double-click; stale reject; tampered token; resume on another Lambda; refund issued once
29
+ - B no answer for 3 days → Scheduler fires → default reject → late click gets 409
30
+ - C crash after resume applied but before SQS ack → redelivery → still_pending() guard → no second refund
31
+ - D crash after DynamoDB create but before timer/notify → sweeper completes setup