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.
- {agent_wait-0.5.0 → agent_wait-0.7.0}/PKG-INFO +106 -15
- {agent_wait-0.5.0 → agent_wait-0.7.0}/README.md +97 -12
- {agent_wait-0.5.0 → agent_wait-0.7.0}/pyproject.toml +7 -3
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/__init__.py +5 -4
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/framework.py +1 -1
- agent_wait-0.7.0/src/agent_wait/pydantic_ai/__init__.py +122 -0
- agent_wait-0.7.0/src/agent_wait/strands/__init__.py +93 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/.gitignore +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/LICENSE +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/reference/wait-sim/README.md +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/base.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/composite.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/log.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/announce/webhook.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/__init__.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/dynamodb.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/eventbridge.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/sns.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/aws/sqs.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/errors.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/langgraph/__init__.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/model.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/policy.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/publish.py +0 -0
- {agent_wait-0.5.0 → agent_wait-0.7.0}/src/agent_wait/py.typed +0 -0
- {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.
|
|
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
|
|
47
|
-
interrupt is handed to whatever called
|
|
48
|
-
There is no built-in way to tell anyone *else* that a question was asked,
|
|
49
|
-
way for anyone else to answer it. The moment the question has to reach
|
|
50
|
-
dashboard, a ticket queue or another service, you are writing that
|
|
51
|
-
your agent is a server that runs for a year or a Lambda that is
|
|
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]
|
|
244
|
-
agent_wait.
|
|
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`
|
|
251
|
-
split: `BaseAnnounce` is the interface,
|
|
252
|
-
|
|
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
|
|
16
|
-
interrupt is handed to whatever called
|
|
17
|
-
There is no built-in way to tell anyone *else* that a question was asked,
|
|
18
|
-
way for anyone else to answer it. The moment the question has to reach
|
|
19
|
-
dashboard, a ticket queue or another service, you are writing that
|
|
20
|
-
your agent is a server that runs for a year or a Lambda that is
|
|
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]
|
|
213
|
-
agent_wait.
|
|
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`
|
|
220
|
-
split: `BaseAnnounce` is the interface,
|
|
221
|
-
|
|
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.
|
|
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`
|
|
16
|
-
`Framework` interface for LangGraph
|
|
17
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|