agent-wait 0.5.0__tar.gz → 0.7.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.5.0 → agent_wait-0.7.0}/PKG-INFO +106 -15
  2. {agent_wait-0.5.0 → agent_wait-0.7.0}/README.md +97 -12
  3. {agent_wait-0.5.0 → agent_wait-0.7.0}/pyproject.toml +7 -3
  4. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/__init__.py +5 -4
  5. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/framework.py +1 -1
  6. agent_wait-0.7.0/src/agent_wait/pydantic_ai/__init__.py +122 -0
  7. agent_wait-0.7.0/src/agent_wait/strands/__init__.py +93 -0
  8. {agent_wait-0.5.0 → agent_wait-0.7.0}/.gitignore +0 -0
  9. {agent_wait-0.5.0 → agent_wait-0.7.0}/LICENSE +0 -0
  10. {agent_wait-0.5.0 → agent_wait-0.7.0}/reference/wait-sim/README.md +0 -0
  11. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/__init__.py +0 -0
  12. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/base.py +0 -0
  13. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/composite.py +0 -0
  14. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/log.py +0 -0
  15. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/memory.py +0 -0
  16. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/webhook.py +0 -0
  17. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/__init__.py +0 -0
  18. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/dynamodb.py +0 -0
  19. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/eventbridge.py +0 -0
  20. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/sns.py +0 -0
  21. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/sqs.py +0 -0
  22. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/errors.py +0 -0
  23. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/langgraph/__init__.py +0 -0
  24. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/model.py +0 -0
  25. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/policy.py +0 -0
  26. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/publish.py +0 -0
  27. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/py.typed +0 -0
  28. {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/wait.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-wait
3
- Version: 0.5.0
4
- Summary: Human-in-the-loop for LangGraph 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.
3
+ Version: 0.7.0
4
+ Summary: Human-in-the-loop for LangGraph, Pydantic AI and Strands 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,callback,durable,dynamodb,eventbridge,hitl,human-in-the-loop,interrupt,lambda,langchain,langgraph,serverless,sns,sqs,webhook
13
+ Keywords: agents,approval,callback,durable,dynamodb,eventbridge,hitl,human-in-the-loop,interrupt,lambda,langchain,langgraph,pydantic-ai,serverless,sns,sqs,strands,webhook
14
14
  Classifier: Development Status :: 4 - Beta
15
15
  Classifier: Intended Audience :: Developers
16
16
  Classifier: License :: OSI Approved :: MIT License
@@ -23,10 +23,16 @@ Requires-Python: >=3.12
23
23
  Provides-Extra: all
24
24
  Requires-Dist: boto3>=1.35; extra == 'all'
25
25
  Requires-Dist: langgraph<2,>=1.2; extra == 'all'
26
+ Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'all'
27
+ Requires-Dist: strands-agents<2,>=1.55; extra == 'all'
26
28
  Provides-Extra: aws
27
29
  Requires-Dist: boto3>=1.35; extra == 'aws'
28
30
  Provides-Extra: langgraph
29
31
  Requires-Dist: langgraph<2,>=1.2; extra == 'langgraph'
32
+ Provides-Extra: pydantic-ai
33
+ Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'pydantic-ai'
34
+ Provides-Extra: strands
35
+ Requires-Dist: strands-agents<2,>=1.55; extra == 'strands'
30
36
  Description-Content-Type: text/markdown
31
37
 
32
38
  # agent-wait
@@ -43,12 +49,13 @@ deploy. But the same shape covers a vendor callback, a payment processor, a KYC
43
49
  another agent — anything the run cannot continue without, and nothing inside the process
44
50
  can supply.
45
51
 
46
- LangGraph has `interrupt()` for this. When a node calls it the graph pauses and the
47
- interrupt is handed to whatever called `invoke()` — and that is where LangGraph stops.
48
- There is no built-in way to tell anyone *else* that a question was asked, and no built-in
49
- way for anyone else to answer it. The moment the question has to reach Slack, an approvals
50
- dashboard, a ticket queue or another service, you are writing that code yourself — whether
51
- your agent is a server that runs for a year or a Lambda that is gone in seconds.
52
+ LangGraph has `interrupt()` for this; Pydantic AI has `ApprovalRequired`; Strands has
53
+ `tool_context.interrupt()`. In all of them the run pauses and the question is handed to whatever called it — and that is where the
54
+ framework stops. There is no built-in way to tell anyone *else* that a question was asked,
55
+ and no built-in way for anyone else to answer it. The moment the question has to reach
56
+ Slack, an approvals dashboard, a ticket queue or another service, you are writing that
57
+ code yourself — whether your agent is a server that runs for a year or a Lambda that is
58
+ gone in seconds.
52
59
 
53
60
  agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
54
61
  can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
@@ -56,11 +63,15 @@ it in one envelope, and documents the shape of the answer so the return leg is o
56
63
 
57
64
  ```bash
58
65
  pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
66
+ pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
67
+ pip install "agent-wait[strands]" # ... and for Strands Agents
59
68
  pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
60
69
  ```
61
70
 
62
71
  The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
63
- announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
72
+ announcers) and depends on nothing. Each extra pulls in exactly one framework or provider —
73
+ the full list, with what each framework leaves to you:
74
+ [Frameworks and providers](https://skamalj.github.io/agent-wait/frameworks-and-providers/).
64
75
 
65
76
  ## The whole thing
66
77
 
@@ -145,6 +156,84 @@ publish_interrupts(result, message["thread_id"], announce)
145
156
  That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
146
157
  resume for a question the thread has moved past — so there is nothing to check.
147
158
 
159
+ ## The same two names on Pydantic AI
160
+
161
+ ```python
162
+ from agent_wait.pydantic_ai import publish_interrupts, wait
163
+ from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext, ToolApproved, ToolDenied
164
+
165
+ agent = Agent("openai:gpt-5.4", output_type=[str, DeferredToolRequests])
166
+
167
+
168
+ @agent.tool
169
+ @wait(FINANCE)
170
+ def issue_refund(ctx: RunContext[None], order_id: str, amount: int) -> str:
171
+ payments.refund(order_id, amount)
172
+ return "refunded"
173
+
174
+
175
+ result = agent.run_sync("refund order 4471", message_history=history)
176
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
177
+ store(thread_id, result.all_messages()) # Pydantic AI has no checkpointer; the history is yours
178
+ ```
179
+
180
+ The run ends with `DeferredToolRequests` in `result.output`; `question_id` is the
181
+ `tool_call_id`. The answer goes back through the framework's own resume, with the answer
182
+ itself riding in `metadata` so the `@wait` rules apply unchanged:
183
+
184
+ ```python
185
+ approved = answer.get("action") == "approve"
186
+ result = agent.run_sync(
187
+ None,
188
+ message_history=load(thread_id),
189
+ deferred_tool_results=DeferredToolResults(
190
+ approvals={question_id: ToolApproved() if approved else ToolDenied(str(answer))},
191
+ metadata={question_id: answer},
192
+ ),
193
+ )
194
+ store(thread_id, result.all_messages()) # before acknowledging the answer message
195
+ ```
196
+
197
+ Two rules the framework leaves to you: name the `RunContext` parameter `ctx`, and store
198
+ the post-run history *before* you acknowledge an answer — a duplicate answer is refused
199
+ only if the history you pass already holds the tool's return. `requires_approval=True`
200
+ tools and `CallDeferred` calls (a job handed to an external system) are published too,
201
+ with the default policy.
202
+
203
+ ## The same two names on Strands
204
+
205
+ ```python
206
+ from agent_wait.strands import publish_interrupts, wait
207
+ from strands import Agent, tool
208
+ from strands.session.file_session_manager import FileSessionManager
209
+ from strands.types.tools import ToolContext
210
+
211
+
212
+ @tool(context=True)
213
+ @wait(FINANCE)
214
+ def issue_refund(order_id: str, amount: int, tool_context: ToolContext) -> str:
215
+ payments.refund(order_id, amount)
216
+ return "refunded"
217
+
218
+
219
+ agent = Agent(tools=[issue_refund], session_manager=FileSessionManager(session_id=thread_id))
220
+ result = agent("refund order 4471")
221
+ publish_interrupts(result, agent.session_id, announce=[SnsAnnounce(topic_arn)])
222
+ ```
223
+
224
+ The run stops with `result.stop_reason == "interrupt"`; `question_id` is the interrupt id
225
+ (one per tool call). The answer goes back through the framework's own resume, verbatim:
226
+
227
+ ```python
228
+ agent = Agent(tools=[issue_refund], session_manager=FileSessionManager(session_id=thread_id))
229
+ result = agent([{"interruptResponse": {"interruptId": question_id, "response": answer}}])
230
+ ```
231
+
232
+ Two rules the framework leaves to you: `@tool(context=True)` with the parameter named
233
+ `tool_context`, and a session manager — that is what keeps the parked question alive
234
+ between the return and the resume. A second answer after the run has moved on raises
235
+ (`ValueError`), so a host that may see duplicates checks `stop_reason` first or catches.
236
+
148
237
  ## Async mode — the thread does not park
149
238
 
150
239
  ```python
@@ -240,16 +329,18 @@ One distribution, one import root. Frameworks and providers are subpackages behi
240
329
  ```
241
330
  agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
242
331
  Webhook/Log/InMemory announcers, and the Framework interface. No deps.
243
- agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
244
- agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
332
+ agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
333
+ agent_wait.pydantic_ai [pydantic-ai] the same two names bound to Pydantic AI's deferred tools.
334
+ agent_wait.strands [strands] the same two names bound to Strands' tool_context.interrupt().
335
+ agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
245
336
  examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
246
337
  docs/ message contract, architecture, announcer guide, consumer guide.
247
338
  ```
248
339
 
249
340
  `@wait` and `publish_interrupts` are written once against a three-method `Framework`
250
- interface; `agent_wait.langgraph` is one implementor of it. Announcers are the same
251
- split: `BaseAnnounce` is the interface, each provider subpackage the implementors. A
252
- `strands` extra follows the same pattern — see
341
+ interface; `agent_wait.langgraph`, `agent_wait.pydantic_ai` and `agent_wait.strands` are
342
+ implementors of it, thirty to fifty lines each. Announcers are the same split: `BaseAnnounce` is the interface,
343
+ each provider subpackage the implementors. See
253
344
  [Architecture](https://skamalj.github.io/agent-wait/architecture/).
254
345
 
255
346
  MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -12,12 +12,13 @@ deploy. But the same shape covers a vendor callback, a payment processor, a KYC
12
12
  another agent — anything the run cannot continue without, and nothing inside the process
13
13
  can supply.
14
14
 
15
- LangGraph has `interrupt()` for this. When a node calls it the graph pauses and the
16
- interrupt is handed to whatever called `invoke()` — and that is where LangGraph stops.
17
- There is no built-in way to tell anyone *else* that a question was asked, and no built-in
18
- way for anyone else to answer it. The moment the question has to reach Slack, an approvals
19
- dashboard, a ticket queue or another service, you are writing that code yourself — whether
20
- your agent is a server that runs for a year or a Lambda that is gone in seconds.
15
+ LangGraph has `interrupt()` for this; Pydantic AI has `ApprovalRequired`; Strands has
16
+ `tool_context.interrupt()`. In all of them the 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.
21
22
 
22
23
  agent-wait is that code. It takes the interrupt and puts it somewhere people (or systems)
23
24
  can see it — a topic, a queue, a webhook, a database row — with everything needed to answer
@@ -25,11 +26,15 @@ it in one envelope, and documents the shape of the answer so the return leg is o
25
26
 
26
27
  ```bash
27
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[strands]" # ... and for Strands Agents
28
31
  pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
29
32
  ```
30
33
 
31
34
  The bare `agent-wait` is the framework-free core (policy, envelope, webhook and in-memory
32
- announcers) and depends on nothing. Each extra pulls in exactly one framework or provider.
35
+ announcers) and depends on nothing. Each extra pulls in exactly one framework or provider —
36
+ the full list, with what each framework leaves to you:
37
+ [Frameworks and providers](https://skamalj.github.io/agent-wait/frameworks-and-providers/).
33
38
 
34
39
  ## The whole thing
35
40
 
@@ -114,6 +119,84 @@ publish_interrupts(result, message["thread_id"], announce)
114
119
  That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
115
120
  resume for a question the thread has moved past — so there is nothing to check.
116
121
 
122
+ ## The same two names on Pydantic AI
123
+
124
+ ```python
125
+ from agent_wait.pydantic_ai import publish_interrupts, wait
126
+ from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, RunContext, ToolApproved, ToolDenied
127
+
128
+ agent = Agent("openai:gpt-5.4", output_type=[str, DeferredToolRequests])
129
+
130
+
131
+ @agent.tool
132
+ @wait(FINANCE)
133
+ def issue_refund(ctx: RunContext[None], order_id: str, amount: int) -> str:
134
+ payments.refund(order_id, amount)
135
+ return "refunded"
136
+
137
+
138
+ result = agent.run_sync("refund order 4471", message_history=history)
139
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
140
+ store(thread_id, result.all_messages()) # Pydantic AI has no checkpointer; the history is yours
141
+ ```
142
+
143
+ The run ends with `DeferredToolRequests` in `result.output`; `question_id` is the
144
+ `tool_call_id`. The answer goes back through the framework's own resume, with the answer
145
+ itself riding in `metadata` so the `@wait` rules apply unchanged:
146
+
147
+ ```python
148
+ approved = answer.get("action") == "approve"
149
+ result = agent.run_sync(
150
+ None,
151
+ message_history=load(thread_id),
152
+ deferred_tool_results=DeferredToolResults(
153
+ approvals={question_id: ToolApproved() if approved else ToolDenied(str(answer))},
154
+ metadata={question_id: answer},
155
+ ),
156
+ )
157
+ store(thread_id, result.all_messages()) # before acknowledging the answer message
158
+ ```
159
+
160
+ Two rules the framework leaves to you: name the `RunContext` parameter `ctx`, and store
161
+ the post-run history *before* you acknowledge an answer — a duplicate answer is refused
162
+ only if the history you pass already holds the tool's return. `requires_approval=True`
163
+ tools and `CallDeferred` calls (a job handed to an external system) are published too,
164
+ with the default policy.
165
+
166
+ ## The same two names on Strands
167
+
168
+ ```python
169
+ from agent_wait.strands import publish_interrupts, wait
170
+ from strands import Agent, tool
171
+ from strands.session.file_session_manager import FileSessionManager
172
+ from strands.types.tools import ToolContext
173
+
174
+
175
+ @tool(context=True)
176
+ @wait(FINANCE)
177
+ def issue_refund(order_id: str, amount: int, tool_context: ToolContext) -> str:
178
+ payments.refund(order_id, amount)
179
+ return "refunded"
180
+
181
+
182
+ agent = Agent(tools=[issue_refund], session_manager=FileSessionManager(session_id=thread_id))
183
+ result = agent("refund order 4471")
184
+ publish_interrupts(result, agent.session_id, announce=[SnsAnnounce(topic_arn)])
185
+ ```
186
+
187
+ The run stops with `result.stop_reason == "interrupt"`; `question_id` is the interrupt id
188
+ (one per tool call). The answer goes back through the framework's own resume, verbatim:
189
+
190
+ ```python
191
+ agent = Agent(tools=[issue_refund], session_manager=FileSessionManager(session_id=thread_id))
192
+ result = agent([{"interruptResponse": {"interruptId": question_id, "response": answer}}])
193
+ ```
194
+
195
+ Two rules the framework leaves to you: `@tool(context=True)` with the parameter named
196
+ `tool_context`, and a session manager — that is what keeps the parked question alive
197
+ between the return and the resume. A second answer after the run has moved on raises
198
+ (`ValueError`), so a host that may see duplicates checks `stop_reason` first or catches.
199
+
117
200
  ## Async mode — the thread does not park
118
201
 
119
202
  ```python
@@ -209,16 +292,18 @@ One distribution, one import root. Frameworks and providers are subpackages behi
209
292
  ```
210
293
  agent_wait core: WaitPolicy, Question, the envelope, publish(), BaseAnnounce,
211
294
  Webhook/Log/InMemory announcers, and the Framework interface. No deps.
212
- agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
213
- agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
295
+ agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
296
+ agent_wait.pydantic_ai [pydantic-ai] the same two names bound to Pydantic AI's deferred tools.
297
+ agent_wait.strands [strands] the same two names bound to Strands' tool_context.interrupt().
298
+ agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
214
299
  examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
215
300
  docs/ message contract, architecture, announcer guide, consumer guide.
216
301
  ```
217
302
 
218
303
  `@wait` and `publish_interrupts` are written once against a three-method `Framework`
219
- interface; `agent_wait.langgraph` is one implementor of it. Announcers are the same
220
- split: `BaseAnnounce` is the interface, each provider subpackage the implementors. A
221
- `strands` extra follows the same pattern — see
304
+ interface; `agent_wait.langgraph`, `agent_wait.pydantic_ai` and `agent_wait.strands` are
305
+ implementors of it, thirty to fifty lines each. Announcers are the same split: `BaseAnnounce` is the interface,
306
+ each provider subpackage the implementors. See
222
307
  [Architecture](https://skamalj.github.io/agent-wait/architecture/).
223
308
 
224
309
  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.5.0"
4
- description = "Human-in-the-loop for LangGraph 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."
3
+ version = "0.7.0"
4
+ description = "Human-in-the-loop for LangGraph, Pydantic AI and Strands 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
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = "MIT"
@@ -13,6 +13,8 @@ keywords = [
13
13
  "approval",
14
14
  "langgraph",
15
15
  "langchain",
16
+ "pydantic-ai",
17
+ "strands",
16
18
  "agents",
17
19
  "interrupt",
18
20
  "durable",
@@ -39,8 +41,10 @@ dependencies = []
39
41
 
40
42
  [project.optional-dependencies]
41
43
  langgraph = ["langgraph>=1.2,<2"]
44
+ pydantic-ai = ["pydantic-ai-slim>=2.43,<3"]
45
+ strands = ["strands-agents>=1.55,<2"]
42
46
  aws = ["boto3>=1.35"]
43
- all = ["agent-wait[langgraph,aws]"]
47
+ all = ["agent-wait[langgraph,pydantic-ai,strands,aws]"]
44
48
 
45
49
  [project.urls]
46
50
  Homepage = "https://skamalj.github.io/agent-wait/"
@@ -12,9 +12,10 @@
12
12
  publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
13
13
 
14
14
  The core is framework- and provider-neutral: the policy, the question, the envelope,
15
- `publish()`, and the announcer interface. `agent_wait.langgraph` implements the
16
- `Framework` interface for LangGraph; `agent_wait.aws` implements announcers for AWS.
17
- Install the extras you need: `pip install agent-wait[langgraph,aws]`.
15
+ `publish()`, and the announcer interface. `agent_wait.langgraph`, `agent_wait.pydantic_ai`
16
+ and `agent_wait.strands` implement the `Framework` interface for LangGraph, Pydantic AI
17
+ and Strands Agents; `agent_wait.aws` implements announcers for AWS. Install the extras
18
+ you need: `pip install agent-wait[langgraph,aws]`, `[pydantic-ai,aws]`, `[strands,aws]`.
18
19
  """
19
20
 
20
21
  from .announce import (
@@ -47,7 +48,7 @@ from .policy import WaitPolicy, parse_duration
47
48
  from .publish import build_envelope, publish
48
49
  from .wait import make_publish_interrupts, make_wait, question_id_for
49
50
 
50
- __version__ = "0.5.0"
51
+ __version__ = "0.7.0"
51
52
 
52
53
  __all__ = [
53
54
  "MAX_QUESTION_BYTES",
@@ -8,7 +8,7 @@ agent-wait needs exactly three things from an agent framework, and nothing else:
8
8
 
9
9
  Everything users touch -- `@wait`, `publish_interrupts`, the envelope, the announcers --
10
10
  is built once in `agent_wait.wait` on top of this interface. A framework subpackage
11
- (`agent_wait.langgraph`, `agent_wait.strands`, ...) is one implementor of it and two
11
+ (`agent_wait.langgraph`, `agent_wait.pydantic_ai`, ...) is one implementor of it and two
12
12
  lines of binding. Users never see this class; they import the bound names.
13
13
 
14
14
  The same split holds for announcers: `announce.base.BaseAnnounce` is the interface, and
@@ -0,0 +1,122 @@
1
+ """Pydantic AI implementor of `agent_wait.framework.Framework`, and the bound names.
2
+
3
+ from agent_wait.pydantic_ai import wait, publish_interrupts
4
+
5
+ Install with `pip install agent-wait[pydantic-ai]`.
6
+
7
+ ## What Pydantic AI provides, and what we use
8
+
9
+ - **Park:** a tool raises `ApprovalRequired(metadata=...)`. The run ends and
10
+ `result.output` is a `DeferredToolRequests` whose `approvals` list the parked calls
11
+ (`ToolCallPart.tool_call_id`, `.tool_name`, `.args`) and whose `metadata[id]` is
12
+ whatever the tool attached. The agent must list `DeferredToolRequests` in its
13
+ `output_type`, or the raise is an error.
14
+ - **Read back:** the same `result.output`. `stream()` ends with the same object.
15
+ - **Thread:** none. Pydantic AI has no checkpointer; `message_history` is a list the
16
+ host owns and stores under a name of its choosing. For `mode="async"` the host puts
17
+ that name in `deps` as `thread_id` (an attribute or a mapping key).
18
+ - **Resume** (the host's call, not ours):
19
+
20
+ agent.run_sync(None, message_history=history, deferred_tool_results=DeferredToolResults(
21
+ approvals={question_id: ToolApproved()}, # or ToolDenied(reason)
22
+ metadata={question_id: answer}, # the answer, verbatim, if it is a dict
23
+ ))
24
+
25
+ On approval the framework calls the tool again with `ctx.tool_call_approved = True`
26
+ and `ctx.tool_call_metadata = answer`; the `@wait` wrapper then applies the usual
27
+ rules (a `decision` parameter receives the answer; `answer["args"]` edits the call).
28
+ On denial the framework does not call the tool; the model sees the denial message.
29
+ Pass the answer through `metadata`, not `override_args` -- `override_args` replaces
30
+ the whole argument set and skips the wrapper's merge.
31
+
32
+ ## Three things to know
33
+
34
+ - **The host owns idempotency.** A duplicate answer is rejected only if the
35
+ `message_history` passed on resume already contains the tool's return -- so store
36
+ the post-run history *before* acknowledging the answer message. Resuming twice from
37
+ the pre-resume history runs the tool twice.
38
+ - **Name the `RunContext` parameter `ctx`.** The wrapper hides it from the published
39
+ `args` by name, and Pydantic AI finds it by annotation -- both hold under
40
+ `functools.wraps`.
41
+ - **`CallDeferred` calls are published too**, with the default policy: they are the
42
+ non-human wait (a job handed to an external system). The answer for those goes in
43
+ `DeferredToolResults.calls`, not `approvals`.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from collections.abc import Mapping
49
+ from typing import Any, cast
50
+
51
+ try:
52
+ from pydantic_ai import ApprovalRequired, DeferredToolRequests, RunContext
53
+ except ImportError as _err: # pragma: no cover - exercised only without the extra
54
+ raise ImportError(
55
+ "agent_wait.pydantic_ai needs Pydantic AI: pip install 'agent-wait[pydantic-ai]'"
56
+ ) from _err
57
+
58
+ from ..framework import Framework
59
+ from ..wait import WAIT_KEY, make_publish_interrupts, make_wait
60
+
61
+ __all__ = ["PydanticAIFramework", "publish_interrupts", "questions_in", "wait"]
62
+
63
+
64
+ def _run_context(call_args: Mapping[str, Any]) -> RunContext[Any] | None:
65
+ for value in call_args.values():
66
+ if isinstance(value, RunContext):
67
+ return value # pyright: ignore[reportUnknownVariableType]
68
+ return None
69
+
70
+
71
+ class PydanticAIFramework(Framework):
72
+ name = "pydantic_ai"
73
+ hidden_params = ("ctx",)
74
+
75
+ def interrupt(self, value: Mapping[str, Any], call_args: Mapping[str, Any]) -> Any:
76
+ ctx = _run_context(call_args)
77
+ if ctx is None:
78
+ # Without the context the re-run after approval is indistinguishable from
79
+ # the first call, and the tool would park forever.
80
+ raise TypeError("a @wait tool on Pydantic AI needs a `ctx: RunContext` parameter")
81
+ if ctx.tool_call_approved:
82
+ # The re-run after approval. The answer rides in the metadata the host
83
+ # attached; without one, approval is the whole answer.
84
+ answer: Any = ctx.tool_call_metadata
85
+ return answer if answer is not None else {"action": "approve"}
86
+ raise ApprovalRequired(metadata=dict(value))
87
+
88
+ def interrupts_in(self, result: Any) -> list[tuple[str, Any]]:
89
+ output: Any = getattr(result, "output", result)
90
+ if not isinstance(output, DeferredToolRequests):
91
+ return []
92
+ out: list[tuple[str, Any]] = []
93
+ for part in [*output.approvals, *output.calls]:
94
+ attached = output.metadata.get(part.tool_call_id)
95
+ if isinstance(attached, Mapping) and WAIT_KEY in attached:
96
+ out.append((part.tool_call_id, dict(attached))) # pyright: ignore[reportUnknownArgumentType]
97
+ else:
98
+ # A tool that never heard of this library: `requires_approval=True`, a bare
99
+ # `ApprovalRequired`, or a `CallDeferred`. The call is the question.
100
+ bare: dict[str, Any] = {"function": part.tool_name, "args": part.args_as_dict()}
101
+ if attached:
102
+ bare["metadata"] = dict(attached) # pyright: ignore[reportUnknownArgumentType]
103
+ out.append((part.tool_call_id, bare))
104
+ return out
105
+
106
+ def current_thread_id(self, call_args: Mapping[str, Any]) -> str:
107
+ ctx = _run_context(call_args)
108
+ deps: Any = ctx.deps if ctx is not None else None
109
+ if isinstance(deps, Mapping):
110
+ thread_id: Any = cast(Mapping[str, Any], deps).get("thread_id")
111
+ else:
112
+ thread_id = getattr(deps, "thread_id", None)
113
+ if not thread_id:
114
+ raise RuntimeError("@wait(mode='async') on Pydantic AI needs `thread_id` in the run's deps")
115
+ return str(thread_id)
116
+
117
+
118
+ _framework = PydanticAIFramework()
119
+
120
+ wait = make_wait(_framework)
121
+ publish_interrupts = make_publish_interrupts(_framework)
122
+ questions_in = publish_interrupts.questions_in # type: ignore[attr-defined]
@@ -0,0 +1,93 @@
1
+ """Strands Agents implementor of `agent_wait.framework.Framework`, and the bound names.
2
+
3
+ from agent_wait.strands import wait, publish_interrupts
4
+
5
+ Install with `pip install agent-wait[strands]`.
6
+
7
+ ## What Strands provides, and what we use
8
+
9
+ - **Park:** a tool declared `@tool(context=True)` receives a `ToolContext`;
10
+ `tool_context.interrupt(name, reason=value)` raises `InterruptException` the first
11
+ time and returns the human's response when the tool runs again after resume. The id
12
+ is `v1:tool_call:<toolUseId>:<uuid5(name)>` -- one per tool call, no collisions.
13
+ - **Read back:** `result.stop_reason == "interrupt"` and `result.interrupts`, each an
14
+ `Interrupt(id, name, reason, response)`. `reason` is what we parked on, verbatim.
15
+ - **Thread:** `agent.session_id` -- the session manager's id when one is attached, a
16
+ random per-instance id otherwise.
17
+ - **Resume** (the host's call, not ours):
18
+
19
+ agent([{"interruptResponse": {"interruptId": question_id, "response": answer}}])
20
+
21
+ The tool runs again, `interrupt()` returns `answer`, and the `@wait` wrapper applies
22
+ its usual rules.
23
+
24
+ ## Three things to know
25
+
26
+ - **`@tool(context=True)` and name the parameter `tool_context`.** The wrapper hides it
27
+ from the published `args` by that name; Strands injects it by that name.
28
+ - **A session manager is what makes the wait durable.** Interrupt state lives on the
29
+ agent instance and is persisted by `FileSessionManager` / `S3SessionManager` (or
30
+ your own) between the return and the resume. Without one, the parked question dies
31
+ with the process.
32
+ - **A duplicate answer is refused, not ignored.** Resuming a run that is not parked
33
+ raises `ValueError("... not in interrupt state")`; an unknown id raises `KeyError`;
34
+ a plain prompt to a parked run raises `TypeError`. None of them runs anything. The
35
+ host checks `result.stop_reason` (or its own record) before resuming, or catches.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from collections.abc import Mapping
41
+ from typing import Any
42
+
43
+ try:
44
+ from strands.types.tools import ToolContext
45
+ except ImportError as _err: # pragma: no cover - exercised only without the extra
46
+ raise ImportError("agent_wait.strands needs Strands Agents: pip install 'agent-wait[strands]'") from _err
47
+
48
+ from ..framework import Framework
49
+ from ..wait import make_publish_interrupts, make_wait
50
+
51
+ __all__ = ["StrandsFramework", "publish_interrupts", "questions_in", "wait"]
52
+
53
+ INTERRUPT_NAME = "agent_wait"
54
+
55
+
56
+ def _tool_context(call_args: Mapping[str, Any]) -> ToolContext[Any] | None:
57
+ for value in call_args.values():
58
+ if isinstance(value, ToolContext):
59
+ return value # pyright: ignore[reportUnknownVariableType]
60
+ return None
61
+
62
+
63
+ class StrandsFramework(Framework):
64
+ name = "strands"
65
+ hidden_params = ("tool_context",)
66
+
67
+ def interrupt(self, value: Mapping[str, Any], call_args: Mapping[str, Any]) -> Any:
68
+ ctx = _tool_context(call_args)
69
+ if ctx is None:
70
+ raise TypeError(
71
+ "a @wait tool on Strands needs `@tool(context=True)` and a `tool_context` parameter"
72
+ )
73
+ return ctx.interrupt(INTERRUPT_NAME, reason=dict(value))
74
+
75
+ def interrupts_in(self, result: Any) -> list[tuple[str, Any]]:
76
+ if getattr(result, "stop_reason", None) != "interrupt":
77
+ return []
78
+ interrupts: Any = getattr(result, "interrupts", None) or ()
79
+ return [(str(item.id), item.reason) for item in interrupts]
80
+
81
+ def current_thread_id(self, call_args: Mapping[str, Any]) -> str:
82
+ ctx = _tool_context(call_args)
83
+ session_id: Any = getattr(getattr(ctx, "agent", None), "session_id", None)
84
+ if not session_id:
85
+ raise RuntimeError("@wait(mode='async') on Strands needs the agent's session_id")
86
+ return str(session_id)
87
+
88
+
89
+ _framework = StrandsFramework()
90
+
91
+ wait = make_wait(_framework)
92
+ publish_interrupts = make_publish_interrupts(_framework)
93
+ questions_in = publish_interrupts.questions_in # type: ignore[attr-defined]
File without changes
File without changes