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.
Files changed (28) hide show
  1. {agent_wait-0.7.0 → agent_wait-0.8.0}/PKG-INFO +19 -1
  2. {agent_wait-0.7.0 → agent_wait-0.8.0}/README.md +18 -0
  3. {agent_wait-0.7.0 → agent_wait-0.8.0}/pyproject.toml +1 -1
  4. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/__init__.py +1 -1
  5. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/wait.py +62 -1
  6. {agent_wait-0.7.0 → agent_wait-0.8.0}/.gitignore +0 -0
  7. {agent_wait-0.7.0 → agent_wait-0.8.0}/LICENSE +0 -0
  8. {agent_wait-0.7.0 → agent_wait-0.8.0}/reference/wait-sim/README.md +0 -0
  9. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/__init__.py +0 -0
  10. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/base.py +0 -0
  11. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/composite.py +0 -0
  12. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/log.py +0 -0
  13. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/memory.py +0 -0
  14. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/announce/webhook.py +0 -0
  15. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/__init__.py +0 -0
  16. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/dynamodb.py +0 -0
  17. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/eventbridge.py +0 -0
  18. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/sns.py +0 -0
  19. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/aws/sqs.py +0 -0
  20. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/errors.py +0 -0
  21. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/framework.py +0 -0
  22. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/langgraph/__init__.py +0 -0
  23. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/model.py +0 -0
  24. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/policy.py +0 -0
  25. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/publish.py +0 -0
  26. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/py.typed +0 -0
  27. {agent_wait-0.7.0 → agent_wait-0.8.0}/src/agent_wait/pydantic_ai/__init__.py +0 -0
  28. {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.7.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.7.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.7.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__ = {"framework": framework.name, "mode": mode, "policy": resolved} # type: ignore[attr-defined]
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