agent-wait 0.6.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.6.0 → agent_wait-0.7.0}/PKG-INFO +49 -8
  2. {agent_wait-0.6.0 → agent_wait-0.7.0}/README.md +43 -5
  3. {agent_wait-0.6.0 → agent_wait-0.7.0}/pyproject.toml +5 -3
  4. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/__init__.py +5 -5
  5. agent_wait-0.7.0/src/agent_wait/strands/__init__.py +93 -0
  6. {agent_wait-0.6.0 → agent_wait-0.7.0}/.gitignore +0 -0
  7. {agent_wait-0.6.0 → agent_wait-0.7.0}/LICENSE +0 -0
  8. {agent_wait-0.6.0 → agent_wait-0.7.0}/reference/wait-sim/README.md +0 -0
  9. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/announce/__init__.py +0 -0
  10. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/announce/base.py +0 -0
  11. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/announce/composite.py +0 -0
  12. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/announce/log.py +0 -0
  13. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/announce/memory.py +0 -0
  14. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/announce/webhook.py +0 -0
  15. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/aws/__init__.py +0 -0
  16. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/aws/dynamodb.py +0 -0
  17. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/aws/eventbridge.py +0 -0
  18. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/aws/sns.py +0 -0
  19. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/aws/sqs.py +0 -0
  20. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/errors.py +0 -0
  21. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/framework.py +0 -0
  22. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/langgraph/__init__.py +0 -0
  23. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/model.py +0 -0
  24. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/policy.py +0 -0
  25. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/publish.py +0 -0
  26. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/py.typed +0 -0
  27. {agent_wait-0.6.0 → agent_wait-0.7.0}/src/agent_wait/pydantic_ai/__init__.py +0 -0
  28. {agent_wait-0.6.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.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.
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,pydantic-ai,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
@@ -24,12 +24,15 @@ Provides-Extra: all
24
24
  Requires-Dist: boto3>=1.35; extra == 'all'
25
25
  Requires-Dist: langgraph<2,>=1.2; extra == 'all'
26
26
  Requires-Dist: pydantic-ai-slim<3,>=2.43; extra == 'all'
27
+ Requires-Dist: strands-agents<2,>=1.55; extra == 'all'
27
28
  Provides-Extra: aws
28
29
  Requires-Dist: boto3>=1.35; extra == 'aws'
29
30
  Provides-Extra: langgraph
30
31
  Requires-Dist: langgraph<2,>=1.2; extra == 'langgraph'
31
32
  Provides-Extra: pydantic-ai
32
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'
33
36
  Description-Content-Type: text/markdown
34
37
 
35
38
  # agent-wait
@@ -46,8 +49,8 @@ deploy. But the same shape covers a vendor callback, a payment processor, a KYC
46
49
  another agent — anything the run cannot continue without, and nothing inside the process
47
50
  can supply.
48
51
 
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
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
51
54
  framework stops. There is no built-in way to tell anyone *else* that a question was asked,
52
55
  and no built-in way for anyone else to answer it. The moment the question has to reach
53
56
  Slack, an approvals dashboard, a ticket queue or another service, you are writing that
@@ -61,11 +64,14 @@ it in one envelope, and documents the shape of the answer so the return leg is o
61
64
  ```bash
62
65
  pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
63
66
  pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
67
+ pip install "agent-wait[strands]" # ... and for Strands Agents
64
68
  pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
65
69
  ```
66
70
 
67
71
  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.
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/).
69
75
 
70
76
  ## The whole thing
71
77
 
@@ -194,6 +200,40 @@ only if the history you pass already holds the tool's return. `requires_approval
194
200
  tools and `CallDeferred` calls (a job handed to an external system) are published too,
195
201
  with the default policy.
196
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
+
197
237
  ## Async mode — the thread does not park
198
238
 
199
239
  ```python
@@ -291,14 +331,15 @@ agent_wait core: WaitPolicy, Question, the envelope, publish(), Bas
291
331
  Webhook/Log/InMemory announcers, and the Framework interface. No deps.
292
332
  agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
293
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().
294
335
  agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
295
336
  examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
296
337
  docs/ message contract, architecture, announcer guide, consumer guide.
297
338
  ```
298
339
 
299
340
  `@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,
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,
302
343
  each provider subpackage the implementors. See
303
344
  [Architecture](https://skamalj.github.io/agent-wait/architecture/).
304
345
 
@@ -12,8 +12,8 @@ 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; Pydantic AI has `ApprovalRequired`. In both, the
16
- run pauses and the question is handed to whatever called it — and that is where the
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
17
  framework stops. There is no built-in way to tell anyone *else* that a question was asked,
18
18
  and no built-in way for anyone else to answer it. The moment the question has to reach
19
19
  Slack, an approvals dashboard, a ticket queue or another service, you are writing that
@@ -27,11 +27,14 @@ it in one envelope, and documents the shape of the answer so the return leg is o
27
27
  ```bash
28
28
  pip install "agent-wait[langgraph]" # @wait + publish_interrupts for LangGraph
29
29
  pip install "agent-wait[pydantic-ai]" # the same two names for Pydantic AI
30
+ pip install "agent-wait[strands]" # ... and for Strands Agents
30
31
  pip install "agent-wait[langgraph,aws]" # + SNS / SQS / EventBridge / DynamoDB announcers
31
32
  ```
32
33
 
33
34
  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
+ 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/).
35
38
 
36
39
  ## The whole thing
37
40
 
@@ -160,6 +163,40 @@ only if the history you pass already holds the tool's return. `requires_approval
160
163
  tools and `CallDeferred` calls (a job handed to an external system) are published too,
161
164
  with the default policy.
162
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
+
163
200
  ## Async mode — the thread does not park
164
201
 
165
202
  ```python
@@ -257,14 +294,15 @@ agent_wait core: WaitPolicy, Question, the envelope, publish(), Bas
257
294
  Webhook/Log/InMemory announcers, and the Framework interface. No deps.
258
295
  agent_wait.langgraph [langgraph] @wait and publish_interrupts() bound to LangGraph.
259
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().
260
298
  agent_wait.aws [aws] SnsAnnounce, SqsAnnounce, EventBridgeAnnounce, DynamoDbAnnounce.
261
299
  examples/refund_agent a graph, a host written for Lambda behind SQS, and its CDK stack.
262
300
  docs/ message contract, architecture, announcer guide, consumer guide.
263
301
  ```
264
302
 
265
303
  `@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,
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,
268
306
  each provider subpackage the implementors. See
269
307
  [Architecture](https://skamalj.github.io/agent-wait/architecture/).
270
308
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
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."
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"
@@ -14,6 +14,7 @@ keywords = [
14
14
  "langgraph",
15
15
  "langchain",
16
16
  "pydantic-ai",
17
+ "strands",
17
18
  "agents",
18
19
  "interrupt",
19
20
  "durable",
@@ -41,8 +42,9 @@ dependencies = []
41
42
  [project.optional-dependencies]
42
43
  langgraph = ["langgraph>=1.2,<2"]
43
44
  pydantic-ai = ["pydantic-ai-slim>=2.43,<3"]
45
+ strands = ["strands-agents>=1.55,<2"]
44
46
  aws = ["boto3>=1.35"]
45
- all = ["agent-wait[langgraph,pydantic-ai,aws]"]
47
+ all = ["agent-wait[langgraph,pydantic-ai,strands,aws]"]
46
48
 
47
49
  [project.urls]
48
50
  Homepage = "https://skamalj.github.io/agent-wait/"
@@ -12,10 +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` and
16
- `agent_wait.pydantic_ai` implement the `Framework` interface for LangGraph and Pydantic
17
- AI; `agent_wait.aws` implements announcers for AWS. Install the extras you need:
18
- `pip install agent-wait[langgraph,aws]` or `agent-wait[pydantic-ai,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]`.
19
19
  """
20
20
 
21
21
  from .announce import (
@@ -48,7 +48,7 @@ from .policy import WaitPolicy, parse_duration
48
48
  from .publish import build_envelope, publish
49
49
  from .wait import make_publish_interrupts, make_wait, question_id_for
50
50
 
51
- __version__ = "0.6.0"
51
+ __version__ = "0.7.0"
52
52
 
53
53
  __all__ = [
54
54
  "MAX_QUESTION_BYTES",
@@ -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