agent-wait 0.7.0__tar.gz → 0.8.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.7.0 → agent_wait-0.8.0}/PKG-INFO +19 -1
- {agent_wait-0.7.0 → agent_wait-0.8.0}/README.md +18 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/pyproject.toml +1 -1
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/__init__.py +1 -1
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/wait.py +62 -1
- {agent_wait-0.7.0 → agent_wait-0.8.0}/.gitignore +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/LICENSE +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/reference/wait-sim/README.md +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/__init__.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/base.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/composite.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/log.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/memory.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/webhook.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/__init__.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/dynamodb.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/eventbridge.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/sns.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/sqs.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/errors.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/framework.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/langgraph/__init__.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/model.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/policy.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/publish.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/py.typed +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/pydantic_ai/__init__.py +0 -0
- {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/strands/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-wait
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
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/
|
|
@@ -124,6 +124,24 @@ def review(state, decision=None):
|
|
|
124
124
|
|
|
125
125
|
The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
|
|
126
126
|
|
|
127
|
+
**Ask only sometimes.** Most approvals have a threshold, and without a condition the
|
|
128
|
+
decorator parks every call — so the rule ends up somewhere else: two tools with a model
|
|
129
|
+
choosing between them, or a router node in front. Both put the rule where you cannot
|
|
130
|
+
enforce it. `when=` keeps it in the function it belongs to:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
@tool
|
|
134
|
+
@wait(FINANCE, when=lambda order_id, amount: amount > 25_000)
|
|
135
|
+
def issue_refund(order_id: str, amount: int) -> str:
|
|
136
|
+
payments.refund(order_id, amount) # under the limit this just runs
|
|
137
|
+
return "refunded"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
One tool, one rule, in code. A model that picks the wrong tool is a routing mistake; a
|
|
141
|
+
model that decides whether an approval applies is an incident. The predicate sees the
|
|
142
|
+
published `args` and not the framework's injected context, it must be deterministic
|
|
143
|
+
because it runs again on resume, and if it raises the call parks anyway.
|
|
144
|
+
|
|
127
145
|
**After the run** — one call:
|
|
128
146
|
|
|
129
147
|
```python
|
|
@@ -87,6 +87,24 @@ def review(state, decision=None):
|
|
|
87
87
|
|
|
88
88
|
The parameter name is yours — `@wait(FINANCE, decision="verdict")` looks for `verdict`.
|
|
89
89
|
|
|
90
|
+
**Ask only sometimes.** Most approvals have a threshold, and without a condition the
|
|
91
|
+
decorator parks every call — so the rule ends up somewhere else: two tools with a model
|
|
92
|
+
choosing between them, or a router node in front. Both put the rule where you cannot
|
|
93
|
+
enforce it. `when=` keeps it in the function it belongs to:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@tool
|
|
97
|
+
@wait(FINANCE, when=lambda order_id, amount: amount > 25_000)
|
|
98
|
+
def issue_refund(order_id: str, amount: int) -> str:
|
|
99
|
+
payments.refund(order_id, amount) # under the limit this just runs
|
|
100
|
+
return "refunded"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
One tool, one rule, in code. A model that picks the wrong tool is a routing mistake; a
|
|
104
|
+
model that decides whether an approval applies is an incident. The predicate sees the
|
|
105
|
+
published `args` and not the framework's injected context, it must be deterministic
|
|
106
|
+
because it runs again on resume, and if it raises the call parks anyway.
|
|
107
|
+
|
|
90
108
|
**After the run** — one call:
|
|
91
109
|
|
|
92
110
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-wait"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
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"
|
|
@@ -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.
|
|
51
|
+
__version__ = "0.8.0"
|
|
52
52
|
|
|
53
53
|
__all__ = [
|
|
54
54
|
"MAX_QUESTION_BYTES",
|
|
@@ -33,6 +33,33 @@ the answer:
|
|
|
33
33
|
def review(state, decision=None):
|
|
34
34
|
return {"decision": decision}
|
|
35
35
|
|
|
36
|
+
## `when=` -- ask only sometimes
|
|
37
|
+
|
|
38
|
+
Most approvals have a threshold: small refunds go through, large ones need a human.
|
|
39
|
+
Without `when=`, `@wait` parks *every* call, so that rule has to live somewhere else --
|
|
40
|
+
two tools and a model choosing between them, or a router node in front. Both put the
|
|
41
|
+
rule where you cannot enforce it.
|
|
42
|
+
|
|
43
|
+
@tool
|
|
44
|
+
@wait(FINANCE, when=lambda order_id, amount: amount > 25_000)
|
|
45
|
+
def issue_refund(order_id: str, amount: int) -> str:
|
|
46
|
+
payments.refund(order_id, amount)
|
|
47
|
+
return "refunded"
|
|
48
|
+
|
|
49
|
+
One tool. Under the limit the body runs and nobody is asked; over it, the call parks
|
|
50
|
+
exactly as an unconditional `@wait` would. The threshold is in code, in one place, and
|
|
51
|
+
no model decides whether it applies.
|
|
52
|
+
|
|
53
|
+
Three things about the predicate:
|
|
54
|
+
|
|
55
|
+
* **It sees what the approver sees** -- the published `args`, not the framework's
|
|
56
|
+
injected context. What you branch on is what the question shows.
|
|
57
|
+
* **It must be deterministic on the same arguments.** The framework re-runs the node
|
|
58
|
+
from the top when the answer comes back, so `when` is called again. One that answered
|
|
59
|
+
differently the second time would let the body run without the answer.
|
|
60
|
+
* **If it raises, the call parks anyway.** A refund is not skipped because a lambda had
|
|
61
|
+
a typo. The exception is logged on `agent_wait.wait`.
|
|
62
|
+
|
|
36
63
|
## `mode="async"` -- the run does not park
|
|
37
64
|
|
|
38
65
|
@tool
|
|
@@ -62,6 +89,7 @@ from __future__ import annotations
|
|
|
62
89
|
import functools
|
|
63
90
|
import hashlib
|
|
64
91
|
import inspect
|
|
92
|
+
import logging
|
|
65
93
|
from collections.abc import Callable, Mapping, Sequence
|
|
66
94
|
from typing import Any, Literal, cast
|
|
67
95
|
|
|
@@ -73,6 +101,8 @@ from .publish import publish
|
|
|
73
101
|
|
|
74
102
|
Mode = Literal["interrupt", "async"]
|
|
75
103
|
|
|
104
|
+
_log = logging.getLogger("agent_wait.wait")
|
|
105
|
+
|
|
76
106
|
WAIT_KEY = "__wait__"
|
|
77
107
|
SOURCE_KEY = "__source__"
|
|
78
108
|
QUESTION_KEY = "question"
|
|
@@ -115,6 +145,27 @@ def question_id_for(thread_id: str, function: str, args: Mapping[str, Any]) -> s
|
|
|
115
145
|
|
|
116
146
|
|
|
117
147
|
# ------------------------------------------------------------------ the decorator
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _asks(when: Callable[..., bool], visible: Mapping[str, Any], function: str) -> bool:
|
|
151
|
+
"""Does this call need a question? `when` decides, and a broken `when` says yes.
|
|
152
|
+
|
|
153
|
+
The predicate sees exactly the arguments the approver would see -- not the
|
|
154
|
+
framework's injected context -- so what you branch on is what gets published.
|
|
155
|
+
|
|
156
|
+
It is called again when the run resumes, because the framework re-runs the node
|
|
157
|
+
from the top. A predicate that answers differently the second time would let the
|
|
158
|
+
body run without the answer, so it has to be deterministic on the same arguments.
|
|
159
|
+
|
|
160
|
+
If it raises, we ask. A refund is not skipped because a lambda had a typo.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
return bool(when(**visible))
|
|
164
|
+
except Exception:
|
|
165
|
+
_log.warning("@wait(when=...) on %s raised; asking anyway", function, exc_info=True)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
118
169
|
def make_wait(framework: Framework) -> Callable[..., Callable[[Callable[..., Any]], Callable[..., Any]]]:
|
|
119
170
|
"""Build the `@wait` decorator for one framework."""
|
|
120
171
|
|
|
@@ -124,6 +175,7 @@ def make_wait(framework: Framework) -> Callable[..., Callable[[Callable[..., Any
|
|
|
124
175
|
mode: Mode = "interrupt",
|
|
125
176
|
announce: Sequence[AnnounceAdapter] | None = None,
|
|
126
177
|
decision: str = "decision",
|
|
178
|
+
when: Callable[..., bool] | None = None,
|
|
127
179
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
128
180
|
resolved = policy or WaitPolicy(allowed_actions=("approve", "reject"))
|
|
129
181
|
if mode == "async" and not announce:
|
|
@@ -145,6 +197,10 @@ def make_wait(framework: Framework) -> Callable[..., Callable[[Callable[..., Any
|
|
|
145
197
|
bound.apply_defaults()
|
|
146
198
|
call_args: dict[str, Any] = dict(bound.arguments)
|
|
147
199
|
visible = {k: v for k, v in call_args.items() if k not in hidden}
|
|
200
|
+
|
|
201
|
+
if when is not None and not _asks(when, visible, name):
|
|
202
|
+
return fn(**call_args)
|
|
203
|
+
|
|
148
204
|
question = {"function": name, "args": visible}
|
|
149
205
|
|
|
150
206
|
if mode == "async":
|
|
@@ -163,7 +219,12 @@ def make_wait(framework: Framework) -> Callable[..., Callable[[Callable[..., Any
|
|
|
163
219
|
return fn(**{**passthrough, **edited})
|
|
164
220
|
return answer
|
|
165
221
|
|
|
166
|
-
wrapper.__agent_wait__ = {
|
|
222
|
+
wrapper.__agent_wait__ = { # type: ignore[attr-defined]
|
|
223
|
+
"framework": framework.name,
|
|
224
|
+
"mode": mode,
|
|
225
|
+
"policy": resolved,
|
|
226
|
+
"conditional": when is not None,
|
|
227
|
+
}
|
|
167
228
|
return wrapper
|
|
168
229
|
|
|
169
230
|
return decorate
|
|
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
|
|
File without changes
|
|
File without changes
|