agent-wait 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """agent-wait: publish an agent's interrupts to the outside world.
2
+
3
+ A graph node asks a question and pauses:
4
+
5
+ from langgraph_wait import ask
6
+
7
+ decision = ask({"kind": "refund_approval", "amount": amount},
8
+ policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
9
+
10
+ The host runs the graph through a publisher, and whatever the graph parked on goes out
11
+ to wherever people can see it:
12
+
13
+ agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
14
+
15
+ agent.invoke(payload, thread_id)
16
+
17
+ That is the whole library. It publishes questions and it reports what a thread is parked
18
+ on. It does not receive answers, hold state, mint credentials or run timers -- see
19
+ `docs/migrating-from-0.1.md` for what that means if you are coming from v0.1.
20
+ """
21
+
22
+ from .announce import (
23
+ AnnounceAdapter,
24
+ BaseAnnounce,
25
+ CompositeAnnounce,
26
+ FailingAnnounce,
27
+ InMemoryAnnounce,
28
+ LogAnnounce,
29
+ WebhookAnnounce,
30
+ verify_signature,
31
+ )
32
+ from .errors import PolicyError, QuestionTooLarge, WaitError
33
+ from .model import (
34
+ MAX_QUESTION_BYTES,
35
+ Clock,
36
+ EntryPoint,
37
+ FakeClock,
38
+ PendingInterrupt,
39
+ SystemClock,
40
+ Transition,
41
+ WaitEnvelope,
42
+ canonical_json,
43
+ check_question_size,
44
+ iso,
45
+ new_ulid,
46
+ )
47
+ from .policy import WaitPolicy, parse_duration
48
+ from .publisher import FrameworkAdapter, WaitPublisher
49
+
50
+ __version__ = "0.2.0"
51
+
52
+ __all__ = [
53
+ "MAX_QUESTION_BYTES",
54
+ "AnnounceAdapter",
55
+ "BaseAnnounce",
56
+ "Clock",
57
+ "CompositeAnnounce",
58
+ "EntryPoint",
59
+ "FailingAnnounce",
60
+ "FakeClock",
61
+ "FrameworkAdapter",
62
+ "InMemoryAnnounce",
63
+ "LogAnnounce",
64
+ "PendingInterrupt",
65
+ "PolicyError",
66
+ "QuestionTooLarge",
67
+ "SystemClock",
68
+ "Transition",
69
+ "WaitEnvelope",
70
+ "WaitError",
71
+ "WaitPolicy",
72
+ "WaitPublisher",
73
+ "WebhookAnnounce",
74
+ "__version__",
75
+ "canonical_json",
76
+ "check_question_size",
77
+ "iso",
78
+ "new_ulid",
79
+ "parse_duration",
80
+ "verify_signature",
81
+ ]
@@ -0,0 +1,16 @@
1
+ from .base import AnnounceAdapter, BaseAnnounce
2
+ from .composite import CompositeAnnounce
3
+ from .log import LogAnnounce
4
+ from .memory import FailingAnnounce, InMemoryAnnounce
5
+ from .webhook import WebhookAnnounce, verify_signature
6
+
7
+ __all__ = [
8
+ "AnnounceAdapter",
9
+ "BaseAnnounce",
10
+ "CompositeAnnounce",
11
+ "FailingAnnounce",
12
+ "InMemoryAnnounce",
13
+ "LogAnnounce",
14
+ "WebhookAnnounce",
15
+ "verify_signature",
16
+ ]
@@ -0,0 +1,98 @@
1
+ """The announce port: a `Protocol` to satisfy, and a base class that satisfies it for you.
2
+
3
+ An announce adapter puts the envelope somewhere. That is all it does. It makes no
4
+ decisions, it is never consulted, and its return value is discarded. The contract is one
5
+ line long:
6
+
7
+ **`announce()` must not raise into the caller.**
8
+
9
+ If Slack is down, the graph still parked and the run still completes.
10
+
11
+ ## Writing one
12
+
13
+ Subclass `BaseAnnounce` and implement `deliver()`:
14
+
15
+ class RedisAnnounce(BaseAnnounce):
16
+ name = "redis"
17
+
18
+ def __init__(self, client, **kw):
19
+ super().__init__(**kw)
20
+ self.client = client
21
+
22
+ def deliver(self, envelope, transition):
23
+ self.client.set(envelope.dedupe_key, envelope.to_json())
24
+
25
+ That is a complete, contract-conforming adapter. The base class wraps `deliver()` so an
26
+ exception becomes a log line rather than a failed run, and gives you `only=` so a caller
27
+ can restrict the adapter to `("created",)` without you writing the filter.
28
+
29
+ You do not *have* to subclass. `AnnounceAdapter` is a `Protocol`: any object with `name`,
30
+ `supports()` and `announce()` is accepted, and `CompositeAnnounce` contains its failures
31
+ either way. The base class exists so that the common case is the correct case by default.
32
+
33
+ ## Anything can be an announcer
34
+
35
+ Nothing reads state back through this library, so "announce" does not mean "publish an
36
+ event". It means *put the question where whoever answers it will find it*.
37
+ That can be a topic, a queue, a bus -- or a DynamoDB table, a Redis key, a Postgres row,
38
+ a database your UI already queries, a file on disk. `DynamoDbAnnounce` in
39
+ `agent-wait-aws` is there to make the point.
40
+
41
+ `supports()` lets an adapter opt out cheaply: a UI that only wants to draw new questions
42
+ takes `only=("created",)`. Most adapters want both -- `created` is "draw the button",
43
+ `resumed` is "retract it".
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ from abc import ABC, abstractmethod
50
+ from typing import Protocol, runtime_checkable
51
+
52
+ from ..model import Transition, WaitEnvelope
53
+
54
+
55
+ @runtime_checkable
56
+ class AnnounceAdapter(Protocol):
57
+ """What `WaitPublisher` accepts. Structural: subclassing is not required."""
58
+
59
+ name: str
60
+
61
+ def announce(self, envelope: WaitEnvelope, transition: Transition) -> None:
62
+ """Fire-and-forget. Log failures; never raise."""
63
+ ...
64
+
65
+ def supports(self, transition: Transition) -> bool:
66
+ """Which transitions this adapter wants. Called before every announce."""
67
+ ...
68
+
69
+
70
+ class BaseAnnounce(ABC):
71
+ """Implements the contract once. Subclasses implement `deliver()` and nothing else.
72
+
73
+ `name` is used in log lines and should be set as a class attribute.
74
+ """
75
+
76
+ name: str = "base"
77
+
78
+ def __init__(self, *, only: tuple[Transition, ...] | None = None) -> None:
79
+ self._only = only
80
+ self._log = logging.getLogger(f"agent_wait.announce.{self.name}")
81
+
82
+ def supports(self, transition: Transition) -> bool:
83
+ return self._only is None or transition in self._only
84
+
85
+ def announce(self, envelope: WaitEnvelope, transition: Transition) -> None:
86
+ """The contract, enforced. Do not override this; override `deliver()`."""
87
+ try:
88
+ self.deliver(envelope, transition)
89
+ except Exception:
90
+ # A backend being unreachable must not fail a run that has already parked.
91
+ self._log.exception(
92
+ "%s failed for interrupt %s (%s)", type(self).__name__, envelope.interrupt_id, transition
93
+ )
94
+
95
+ @abstractmethod
96
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
97
+ """Put the envelope where it goes. Raise freely; the base class contains it."""
98
+ ...
@@ -0,0 +1,47 @@
1
+ """`CompositeAnnounce` -- fan out to several adapters, isolate their failures.
2
+
3
+ This is where "must not raise" is actually enforced rather than merely requested of
4
+ adapter authors. A third-party adapter that breaks its side of the contract is contained
5
+ here, and the publisher never learns about it.
6
+
7
+ There is no retry and no reporting of what got through. A failed announce is recovered
8
+ the same way everything else is: re-invoke the thread, get the same interrupt ids back,
9
+ republish. The consumer discards the duplicate on `dedupe_key`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Sequence
16
+
17
+ from ..model import Transition, WaitEnvelope
18
+ from .base import AnnounceAdapter
19
+
20
+ _log = logging.getLogger("agent_wait.announce")
21
+
22
+
23
+ class CompositeAnnounce:
24
+ name = "composite"
25
+
26
+ def __init__(self, adapters: Sequence[AnnounceAdapter]) -> None:
27
+ self.adapters = list(adapters)
28
+
29
+ def supports(self, transition: Transition) -> bool:
30
+ return any(a.supports(transition) for a in self.adapters)
31
+
32
+ def announce(self, envelope: WaitEnvelope, transition: Transition) -> None:
33
+ for adapter in self.adapters:
34
+ name = getattr(adapter, "name", "?")
35
+ try:
36
+ if not adapter.supports(transition):
37
+ continue
38
+ adapter.announce(envelope, transition)
39
+ except Exception:
40
+ # A contract violation by the adapter. Contained here, on purpose: if
41
+ # Slack is down, the graph still parked, and the run still completes.
42
+ _log.exception(
43
+ "announce adapter %s raised on %s for interrupt %s",
44
+ name,
45
+ transition,
46
+ envelope.interrupt_id,
47
+ )
@@ -0,0 +1,47 @@
1
+ """`LogAnnounce` -- a structured log line per transition. The default in tests.
2
+
3
+ Deliberately careful about what it prints. The question may hold anything the agent was
4
+ working on -- a customer's address, the body of a claim -- so it does not go to INFO
5
+ (CLAUDE.md). At DEBUG it is included, because by then someone has opted in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+
13
+ from ..model import Transition, WaitEnvelope
14
+ from .base import BaseAnnounce
15
+
16
+
17
+ class LogAnnounce(BaseAnnounce):
18
+ name = "log"
19
+
20
+ def __init__(
21
+ self,
22
+ logger: logging.Logger | None = None,
23
+ *,
24
+ level: int = logging.INFO,
25
+ only: tuple[Transition, ...] | None = None,
26
+ ) -> None:
27
+ super().__init__(only=only)
28
+ self._out = logger or logging.getLogger("agent_wait.announce")
29
+ self._level = level
30
+
31
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
32
+ record = {
33
+ "event": envelope.type,
34
+ "event_id": envelope.event_id,
35
+ "thread_id": envelope.thread_id,
36
+ "interrupt_id": envelope.interrupt_id,
37
+ "allowed_actions": list(envelope.allowed_actions),
38
+ "expires_at": envelope.expires_at,
39
+ "tags": dict(envelope.tags),
40
+ }
41
+ self._out.log(self._level, "agent-wait %s", json.dumps(record, default=str))
42
+ if self._out.isEnabledFor(logging.DEBUG):
43
+ self._out.debug(
44
+ "agent-wait question for %s: %s",
45
+ envelope.interrupt_id,
46
+ json.dumps(envelope.question, default=str),
47
+ )
@@ -0,0 +1,51 @@
1
+ """`InMemoryAnnounce` -- collects envelopes so tests can assert on what the world saw.
2
+
3
+ `FailingAnnounce` raises on demand, so the suite can check the run is unharmed and the
4
+ other adapters still got their envelope.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..model import Transition, WaitEnvelope
10
+ from .base import BaseAnnounce
11
+
12
+
13
+ class InMemoryAnnounce(BaseAnnounce):
14
+ name = "memory"
15
+
16
+ def __init__(self, *, only: tuple[Transition, ...] | None = None) -> None:
17
+ super().__init__(only=only)
18
+ self.events: list[tuple[Transition, WaitEnvelope]] = []
19
+
20
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
21
+ self.events.append((transition, envelope))
22
+
23
+ # -- test helpers --------------------------------------------------------
24
+ def of(self, transition: Transition) -> list[WaitEnvelope]:
25
+ return [e for t, e in self.events if t == transition]
26
+
27
+ def transitions(self) -> list[Transition]:
28
+ return [t for t, _ in self.events]
29
+
30
+ def last(self) -> WaitEnvelope | None:
31
+ return self.events[-1][1] if self.events else None
32
+
33
+ def clear(self) -> None:
34
+ self.events.clear()
35
+
36
+
37
+ class FailingAnnounce:
38
+ """Raises on every announce. Used to prove the isolation in `CompositeAnnounce`."""
39
+
40
+ name = "failing"
41
+
42
+ def __init__(self, message: str = "announce backend is down") -> None:
43
+ self.calls = 0
44
+ self._message = message
45
+
46
+ def supports(self, transition: Transition) -> bool:
47
+ return True
48
+
49
+ def announce(self, envelope: WaitEnvelope, transition: Transition) -> None:
50
+ self.calls += 1
51
+ raise RuntimeError(self._message)
@@ -0,0 +1,99 @@
1
+ """`WebhookAnnounce` -- POST the envelope to a URL.
2
+
3
+ announce=[WebhookAnnounce("https://approvals.internal/hooks/agent-wait",
4
+ secret=b"shared-with-the-receiver")]
5
+
6
+ Stdlib only, so it lives in the core package with no new dependency. One POST per
7
+ transition, JSON body, three headers a receiver can route or verify on:
8
+
9
+ Content-Type: application/json
10
+ X-Agent-Wait-Event: wait.created | wait.resumed
11
+ X-Agent-Wait-Dedupe-Key: wait.created:<interrupt_id>
12
+ X-Agent-Wait-Signature: sha256=<hex> (only when `secret` is given)
13
+
14
+ ## Signing
15
+
16
+ The signature is `HMAC-SHA256(secret, raw body)`, hex-encoded, in the same shape GitHub
17
+ and Stripe use. It answers one question for the receiver -- *did this come from the
18
+ agent?* -- and nothing else. It is not a credential for answering; the library has no
19
+ inbound path and nothing here creates one. `verify_signature()` is the
20
+ receiver's half, and is a pure function so it can be copied into a service that does not
21
+ install this package.
22
+
23
+ ## Failure
24
+
25
+ A non-2xx response raises, and `BaseAnnounce` turns that into a log line: the graph that
26
+ just parked stays parked. There is no retry -- `republish()` is the retry, same as every
27
+ other adapter -- so a receiver that was down gets the question again the next time the
28
+ thread is re-invoked, with the same dedupe key.
29
+
30
+ The timeout is deliberately short. This runs inside the agent's own invocation, after the
31
+ graph has already done its work; a slow receiver must not turn a two-second run into a
32
+ thirty-second one.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import hmac
39
+ import urllib.error
40
+ import urllib.request
41
+ from collections.abc import Mapping
42
+
43
+ from ..model import Transition, WaitEnvelope
44
+ from .base import BaseAnnounce
45
+
46
+ SIGNATURE_HEADER = "X-Agent-Wait-Signature"
47
+ EVENT_HEADER = "X-Agent-Wait-Event"
48
+ DEDUPE_HEADER = "X-Agent-Wait-Dedupe-Key"
49
+
50
+
51
+ def sign(secret: bytes, body: bytes) -> str:
52
+ return "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
53
+
54
+
55
+ def verify_signature(secret: bytes, body: bytes, header: str | None) -> bool:
56
+ """The receiver's half. Constant-time; a missing or malformed header is False."""
57
+ if not header:
58
+ return False
59
+ return hmac.compare_digest(sign(secret, body), header)
60
+
61
+
62
+ class WebhookAnnounce(BaseAnnounce):
63
+ name = "webhook"
64
+
65
+ def __init__(
66
+ self,
67
+ url: str,
68
+ *,
69
+ secret: bytes | None = None,
70
+ headers: Mapping[str, str] | None = None,
71
+ timeout: float = 5.0,
72
+ only: tuple[Transition, ...] | None = None,
73
+ ) -> None:
74
+ super().__init__(only=only)
75
+ self.url = url
76
+ self._secret = secret
77
+ self._headers = dict(headers or {})
78
+ self._timeout = timeout
79
+
80
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
81
+ body = envelope.to_json().encode("utf-8")
82
+ headers = {
83
+ **self._headers,
84
+ "Content-Type": "application/json",
85
+ EVENT_HEADER: envelope.type,
86
+ DEDUPE_HEADER: envelope.dedupe_key,
87
+ }
88
+ if self._secret is not None:
89
+ headers[SIGNATURE_HEADER] = sign(self._secret, body)
90
+
91
+ request = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
92
+ try:
93
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
94
+ status = response.status
95
+ except urllib.error.HTTPError as err:
96
+ # A 4xx/5xx is an HTTPError in urllib; make the log line say the status.
97
+ raise RuntimeError(f"webhook {self.url} returned {err.code}") from err
98
+ if not 200 <= status < 300: # pragma: no cover - urllib raises for these
99
+ raise RuntimeError(f"webhook {self.url} returned {status}")
agent_wait/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ """The two exceptions agent-wait raises, both at the interrupt site.
2
+
3
+ There are no runtime errors to speak of. Announce adapters are forbidden from raising
4
+ (see `announce/base.py`) and nothing else in the library makes a decision that can fail.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class WaitError(Exception):
11
+ """Base class, so `except WaitError` catches everything this library raises."""
12
+
13
+
14
+ class PolicyError(WaitError):
15
+ """A `WaitPolicy` that cannot mean anything -- an unparseable timeout, an empty
16
+ `allowed_actions`. Raised at construction, in the graph, where the mistake is."""
17
+
18
+
19
+ class QuestionTooLarge(WaitError):
20
+ """The question would not survive the trip.
21
+
22
+ An envelope has to fit through whatever the announce adapter is: SNS caps a message
23
+ at 256 KB, EventBridge at 256 KB, SQS at 256 KB. Finding out at publish time means a
24
+ parked interrupt nobody ever hears about, so the size is checked when the question is
25
+ asked instead.
26
+ """
agent_wait/model.py ADDED
@@ -0,0 +1,179 @@
1
+ """The data that crosses the boundary: what comes out of a run, and what goes out to
2
+ the world.
3
+
4
+ `WaitEnvelope` is the contract. Everything else in this repository is an implementation
5
+ detail; that one is not. It is specified in `docs/message-formats.md`, and a team that
6
+ has read only that file should be able to write a working consumer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import secrets
13
+ import time
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Literal, Protocol
17
+
18
+ from .errors import QuestionTooLarge
19
+ from .policy import WaitPolicy
20
+
21
+ Transition = Literal["created", "resumed"]
22
+ """The two things that can be said about a wait.
23
+
24
+ `created` -- the graph is parked on this question; here is everything needed to answer
25
+ it. `resumed` -- the graph has moved past it; close the ticket, retract the button.
26
+
27
+ There is no `answered`, `expired` or `cancelled`. Those would describe the library's
28
+ opinion about an answer, and no answer ever reaches the library. Only the graph's own
29
+ state is reported, and the graph knows exactly two things: parked, or not.
30
+ """
31
+
32
+ # 256 KB is the smallest of the caps on the way out (SNS, SQS and EventBridge all sit
33
+ # there). Leave headroom for the rest of the envelope.
34
+ MAX_QUESTION_BYTES = 200 * 1024
35
+
36
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
37
+
38
+
39
+ class Clock(Protocol):
40
+ def now(self) -> float: ...
41
+
42
+
43
+ class SystemClock:
44
+ def now(self) -> float:
45
+ return time.time()
46
+
47
+
48
+ class FakeClock:
49
+ """A clock tests can drive. Nothing in the library reads the wall clock directly."""
50
+
51
+ def __init__(self, start: float = 1_760_000_000.0) -> None:
52
+ self._now = start
53
+
54
+ def now(self) -> float:
55
+ return self._now
56
+
57
+ def advance(self, seconds: float) -> None:
58
+ self._now += seconds
59
+
60
+
61
+ def new_ulid(clock: Clock | None = None) -> str:
62
+ """A ULID: 48 bits of millisecond timestamp then 80 bits of randomness, Crockford
63
+ base32. Lexicographically sortable, which is what makes it useful as a key."""
64
+ ms = int((clock.now() if clock else time.time()) * 1000)
65
+ value = (ms << 80) | int.from_bytes(secrets.token_bytes(10), "big")
66
+ out = [""] * 26
67
+ for i in range(25, -1, -1):
68
+ out[i] = _CROCKFORD[value & 0x1F]
69
+ value >>= 5
70
+ return "".join(out)
71
+
72
+
73
+ def canonical_json(obj: Any) -> str:
74
+ """One byte-exact rendering of a value, so a size check or a hash of it means
75
+ something."""
76
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str, ensure_ascii=False)
77
+
78
+
79
+ def check_question_size(question: Any) -> None:
80
+ size = len(canonical_json(question).encode("utf-8"))
81
+ if size > MAX_QUESTION_BYTES:
82
+ raise QuestionTooLarge(
83
+ f"question is {size} bytes; the limit is {MAX_QUESTION_BYTES}. "
84
+ "Publish a reference (an id, an S3 key) and let the consumer fetch the rest."
85
+ )
86
+
87
+
88
+ def iso(ts: float | None) -> str | None:
89
+ if ts is None:
90
+ return None
91
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
92
+
93
+
94
+ def _no_str_map() -> dict[str, str]:
95
+ """A typed factory: a bare `dict` leaves the field's value type unknown."""
96
+ return {}
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class EntryPoint:
101
+ """Where the agent listens, if you want consumers told.
102
+
103
+ Optional. agent-wait builds no return leg, so it never needs this itself; it is
104
+ published as `reply_to` purely as a hint, so that a consumer you write does not have
105
+ to hardcode an address that differs between environments."""
106
+
107
+ kind: Literal["sqs", "lambda", "http"]
108
+ address: str
109
+
110
+ def to_dict(self) -> dict[str, str]:
111
+ key = {"sqs": "url", "lambda": "arn", "http": "url"}[self.kind]
112
+ return {"kind": self.kind, key: self.address}
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class PendingInterrupt:
117
+ """One question a thread is parked on. What a `FrameworkAdapter` hands back."""
118
+
119
+ interrupt_id: str
120
+ question: Any
121
+ policy: WaitPolicy
122
+ asked_at: float | None = None
123
+ """When the framework checkpointed this interrupt, if it can say.
124
+
125
+ This is what `expires_at` is measured from. It has to come from the checkpoint
126
+ rather than from the clock at publish time, because the same question is republished
127
+ whenever a start message is redelivered -- and a deadline recomputed as `now +
128
+ timeout` on each republish would walk forward forever, which is precisely the
129
+ failure a timeout exists to prevent.
130
+ """
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class WaitEnvelope:
135
+ """The outbound contract. One per interrupt, per transition."""
136
+
137
+ type: str
138
+ event_id: str
139
+ thread_id: str
140
+ interrupt_id: str
141
+ question: Any
142
+ allowed_actions: tuple[str, ...]
143
+ expires_at: str | None
144
+ reply_with: Mapping[str, Any]
145
+ reply_to: Mapping[str, str] | None = None
146
+ default: Any = None
147
+ correlation: Mapping[str, str] | None = None
148
+ tags: Mapping[str, str] = field(default_factory=_no_str_map)
149
+
150
+ @property
151
+ def dedupe_key(self) -> str:
152
+ """What a consumer -- or an adapter with a natural key -- should dedupe on.
153
+
154
+ Not `event_id`: that is fresh per publish, so a redelivered start message
155
+ republishing the same question would look like a second question. LangGraph
156
+ guarantees `interrupt_id` is stable across re-entry and resume-from-checkpoint
157
+ (verified in `test_spike_langgraph.py`), which makes this key stable for exactly
158
+ as long as the question is.
159
+ """
160
+ return f"{self.type}:{self.interrupt_id}"
161
+
162
+ def to_dict(self) -> dict[str, Any]:
163
+ return {
164
+ "type": self.type,
165
+ "event_id": self.event_id,
166
+ "thread_id": self.thread_id,
167
+ "interrupt_id": self.interrupt_id,
168
+ "question": self.question,
169
+ "allowed_actions": list(self.allowed_actions),
170
+ "expires_at": self.expires_at,
171
+ "default": self.default,
172
+ "reply_to": dict(self.reply_to) if self.reply_to else None,
173
+ "reply_with": dict(self.reply_with),
174
+ "correlation": dict(self.correlation) if self.correlation else None,
175
+ "tags": dict(self.tags),
176
+ }
177
+
178
+ def to_json(self) -> str:
179
+ return json.dumps(self.to_dict(), default=str, ensure_ascii=False)
agent_wait/policy.py ADDED
@@ -0,0 +1,122 @@
1
+ """`WaitPolicy` -- what the author of an interrupt declares about the wait.
2
+
3
+ Every field here is **advisory**. agent-wait publishes the policy and does nothing else
4
+ with it: it does not enforce the timeout, apply the default, or reject a disallowed
5
+ action. It cannot -- the library never sees the answer.
6
+
7
+ What these fields buy you is that the *world* is told the rules in a machine-readable
8
+ way, on the same envelope as the question, so whatever does the enforcing has what it
9
+ needs:
10
+
11
+ * `timeout` becomes `expires_at` -- an absolute instant, so a scheduler, a cron sweep or
12
+ a UI countdown can act on it without re-parsing a duration;
13
+ * `allowed_actions` tells a UI which buttons to draw;
14
+ * `default` tells whoever enforces the timeout what to send when it fires;
15
+ * `tags` become SNS message attributes, so filter policies can route on them.
16
+
17
+ The policy rides inside the interrupt value under the `__wait__` key, because that is the
18
+ only channel a framework interrupt gives us, so it must round-trip through JSON.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from .errors import PolicyError
29
+
30
+ # ISO-8601 durations, restricted to the parts that make sense for a wait. Years and
31
+ # months are refused on purpose: they are not fixed-length, so "P1M" cannot be turned
32
+ # into the definite instant that `expires_at` promises to be.
33
+ _ISO_DURATION = re.compile(
34
+ r"^P(?!$)(?:(?P<weeks>\d+(?:\.\d+)?)W)?(?:(?P<days>\d+(?:\.\d+)?)D)?"
35
+ r"(?:T(?!$)(?:(?P<hours>\d+(?:\.\d+)?)H)?(?:(?P<minutes>\d+(?:\.\d+)?)M)?"
36
+ r"(?:(?P<seconds>\d+(?:\.\d+)?)S)?)?$"
37
+ )
38
+
39
+ _UNIT_SECONDS = {
40
+ "weeks": 604800.0,
41
+ "days": 86400.0,
42
+ "hours": 3600.0,
43
+ "minutes": 60.0,
44
+ "seconds": 1.0,
45
+ }
46
+
47
+
48
+ def _no_tags() -> dict[str, str]:
49
+ """A typed factory: a bare `dict` leaves the field's value type unknown."""
50
+ return {}
51
+
52
+
53
+ def parse_duration(value: str | int | float | None) -> float | None:
54
+ """Return seconds for an ISO-8601 duration string, a number, or None.
55
+
56
+ >>> parse_duration("P3D")
57
+ 259200.0
58
+ >>> parse_duration("PT2H30M")
59
+ 9000.0
60
+ """
61
+ if value is None:
62
+ return None
63
+ if isinstance(value, bool): # bool is an int subclass; almost certainly a mistake
64
+ raise PolicyError(f"timeout must be a duration, got {value!r}")
65
+ if isinstance(value, int | float):
66
+ if value <= 0:
67
+ raise PolicyError(f"timeout must be positive, got {value!r}")
68
+ return float(value)
69
+ match = _ISO_DURATION.match(value.strip().upper())
70
+ if match is None:
71
+ raise PolicyError(
72
+ f"unparseable timeout {value!r}: expected seconds or an ISO-8601 duration "
73
+ "such as 'P3D' or 'PT2H30M' (years and months are not accepted)"
74
+ )
75
+ total = sum(_UNIT_SECONDS[k] * float(v) for k, v in match.groupdict().items() if v is not None)
76
+ if total <= 0:
77
+ raise PolicyError(f"timeout must be positive, got {value!r}")
78
+ return total
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class WaitPolicy:
83
+ """Declared at the interrupt site, by whoever asked the question. All advisory."""
84
+
85
+ timeout: str | int | None = None
86
+ default: Any = None
87
+ allowed_actions: tuple[str, ...] = ("resume",)
88
+ tags: Mapping[str, str] = field(default_factory=_no_tags)
89
+ correlation: Mapping[str, str] | None = None
90
+
91
+ def __post_init__(self) -> None:
92
+ # Fail here, in the graph, at the line that got it wrong -- not three days later
93
+ # in whatever consumer tried to read `expires_at`.
94
+ parse_duration(self.timeout)
95
+ if not self.allowed_actions:
96
+ raise PolicyError("allowed_actions must not be empty")
97
+
98
+ @property
99
+ def timeout_seconds(self) -> float | None:
100
+ return parse_duration(self.timeout)
101
+
102
+ def to_dict(self) -> dict[str, Any]:
103
+ return {
104
+ "timeout": self.timeout,
105
+ "default": self.default,
106
+ "allowed_actions": list(self.allowed_actions),
107
+ "tags": dict(self.tags),
108
+ "correlation": dict(self.correlation) if self.correlation is not None else None,
109
+ }
110
+
111
+ @classmethod
112
+ def from_dict(cls, data: Mapping[str, Any] | None) -> WaitPolicy:
113
+ if not data:
114
+ return cls()
115
+ correlation = data.get("correlation")
116
+ return cls(
117
+ timeout=data.get("timeout"),
118
+ default=data.get("default"),
119
+ allowed_actions=tuple(data.get("allowed_actions") or ("resume",)),
120
+ tags=dict(data.get("tags") or {}),
121
+ correlation=dict(correlation) if correlation else None,
122
+ )
@@ -0,0 +1,204 @@
1
+ """`WaitPublisher` -- run the graph, and tell the world about anything it parked on.
2
+
3
+ That is the entire library. There is one call:
4
+
5
+ agent.invoke(payload, thread_id="order-4471")
6
+
7
+ It runs the graph, works out which questions opened and which closed, and publishes an
8
+ envelope for each. Nothing else happens: no store, no tokens, no timers, no inbound
9
+ message handling. Whoever receives the envelope decides what to do with it, and calls
10
+ `invoke()` again with the answer.
11
+
12
+ ## How "opened" and "closed" are worked out
13
+
14
+ By diffing the framework's own state, not by tracking anything ourselves:
15
+
16
+ before = {what the thread was parked on}
17
+ result = graph.invoke(...)
18
+ after = {what the thread is parked on now}
19
+
20
+ created = after - before # new questions
21
+ resumed = before - after # questions the graph has moved past
22
+
23
+ Two `get_state()` reads per invoke, and the framework stays the only source of truth.
24
+
25
+ ## Recovering a lost announce
26
+
27
+ If the process dies between the invoke and the announce, the graph is parked and nobody
28
+ was told. `republish()` is the repair: it publishes `created` for whatever the thread is
29
+ parked on right now, with the same interrupt ids and therefore the same `dedupe_key`, so
30
+ a consumer that already saw the question discards it.
31
+
32
+ The caller has to know when to use it, and the rule is short enough to inline:
33
+
34
+ if agent.pending(thread_id): # a redelivered start for a thread already parked
35
+ agent.republish(thread_id)
36
+ else:
37
+ agent.invoke(message["input"], thread_id)
38
+
39
+ **Do not re-invoke a parked thread with its original input.** LangGraph will treat it as
40
+ a fresh turn, ask the question a second time, and give it a new interrupt id -- so the
41
+ consumer sees two questions and the deduplication that repairs everything else cannot
42
+ help. This is the one rule the caller has to remember, and it is why `pending()` is part
43
+ of the public surface.
44
+
45
+ ## What happens if the graph raises
46
+
47
+ The exception propagates and nothing is published, because nothing is known: a run that
48
+ raised has not necessarily parked or unparked anything. There is no bookkeeping left
49
+ half-done, which is the advantage of keeping none.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import logging
55
+ from collections.abc import Sequence
56
+ from typing import Any, Protocol, runtime_checkable
57
+
58
+ from .announce.base import AnnounceAdapter
59
+ from .announce.composite import CompositeAnnounce
60
+ from .model import (
61
+ Clock,
62
+ EntryPoint,
63
+ PendingInterrupt,
64
+ SystemClock,
65
+ Transition,
66
+ WaitEnvelope,
67
+ iso,
68
+ new_ulid,
69
+ )
70
+
71
+ _log = logging.getLogger("agent_wait.publisher")
72
+
73
+
74
+ @runtime_checkable
75
+ class FrameworkAdapter(Protocol):
76
+ """Everything framework-specific, in three methods.
77
+
78
+ `langgraph_wait.LangGraphAdapter` is the one implementation; the protocol exists so
79
+ the core can be tested without LangGraph and so a second framework is a day's work
80
+ rather than a fork.
81
+ """
82
+
83
+ name: str
84
+
85
+ def config_for(self, thread_id: str) -> dict[str, Any]:
86
+ """The framework's handle for one conversation."""
87
+ ...
88
+
89
+ def invoke(self, value: Any, config: Any) -> Any:
90
+ """Run the graph. `value` is whatever the caller passed -- fresh input on a
91
+ start, a resume command on an answer. The adapter does not inspect it."""
92
+ ...
93
+
94
+ def pending(self, thread_id: str) -> list[PendingInterrupt]:
95
+ """What this thread is parked on *right now*, read from the checkpoint.
96
+
97
+ Must return `[]` for a thread that has never run. Must not over-report: an
98
+ interrupt the graph has already moved past does not belong here, however the
99
+ framework's own API chooses to report it.
100
+ """
101
+ ...
102
+
103
+
104
+ class WaitPublisher:
105
+ """Wraps a graph. Runs it, and publishes what it parks on."""
106
+
107
+ def __init__(
108
+ self,
109
+ adapter: FrameworkAdapter,
110
+ *,
111
+ announce: Sequence[AnnounceAdapter],
112
+ reply_to: EntryPoint | None = None,
113
+ clock: Clock | None = None,
114
+ ) -> None:
115
+ """`reply_to` is a hint for consumers, and nothing more.
116
+
117
+ This library builds no return leg. It does not listen anywhere, does not receive
118
+ answers and does not verify them -- so it has no need to know where the agent
119
+ lives. If you *do* build a return leg and want the envelope to tell consumers
120
+ where it is, pass an `EntryPoint` and it is published verbatim. Leave it out and
121
+ `reply_to` is `null`, which is fine for any consumer that already knows.
122
+ """
123
+ self.adapter = adapter
124
+ self.announce = CompositeAnnounce(announce)
125
+ self.reply_to = reply_to
126
+ self.clock: Clock = clock or SystemClock()
127
+
128
+ # ------------------------------------------------------------------ the one call
129
+ def invoke(self, value: Any, thread_id: str, *, config: Any = None) -> Any:
130
+ """Run the graph for `thread_id` and publish whatever changed.
131
+
132
+ Returns the framework's own result, unchanged -- the wrapper is not a filter.
133
+ """
134
+ resolved = config if config is not None else self.adapter.config_for(thread_id)
135
+
136
+ before = {p.interrupt_id: p for p in self.adapter.pending(thread_id)}
137
+ result = self.adapter.invoke(value, resolved)
138
+ after = {p.interrupt_id: p for p in self.adapter.pending(thread_id)}
139
+
140
+ for interrupt_id, opened in after.items():
141
+ if interrupt_id not in before:
142
+ self._publish(thread_id, opened, "created")
143
+ for interrupt_id, closed in before.items():
144
+ if interrupt_id not in after:
145
+ self._publish(thread_id, closed, "resumed")
146
+
147
+ return result
148
+
149
+ def republish(self, thread_id: str) -> list[PendingInterrupt]:
150
+ """Announce `created` again for everything this thread is parked on.
151
+
152
+ The repair for an announce that was lost -- a crash before it went out, a broker
153
+ that was down, an adapter added after the question was asked. Safe to call at any
154
+ time and as often as you like: the interrupt ids are stable, so every republish
155
+ carries the same `dedupe_key` and a consumer that already has the question
156
+ discards it.
157
+
158
+ Runs the graph not at all.
159
+ """
160
+ parked = self.adapter.pending(thread_id)
161
+ for interrupt in parked:
162
+ self._publish(thread_id, interrupt, "created")
163
+ return parked
164
+
165
+ # ------------------------------------------------------------------ reading back
166
+ def pending(self, thread_id: str) -> list[PendingInterrupt]:
167
+ """What this thread is parked on. Read-only; publishes nothing.
168
+
169
+ This is the answer to "is this answer I just received still live, or did
170
+ somebody beat me to it?" -- which is the caller's question, and this is the tool
171
+ for it.
172
+ """
173
+ return self.adapter.pending(thread_id)
174
+
175
+ # ------------------------------------------------------------------ the envelope
176
+ def envelope_for(
177
+ self, thread_id: str, interrupt: PendingInterrupt, transition: Transition
178
+ ) -> WaitEnvelope:
179
+ policy = interrupt.policy
180
+ timeout = policy.timeout_seconds
181
+ # Anchored to the checkpoint, not to now -- see `PendingInterrupt.asked_at`.
182
+ asked_at = interrupt.asked_at if interrupt.asked_at is not None else self.clock.now()
183
+ return WaitEnvelope(
184
+ type=f"wait.{transition}",
185
+ event_id=new_ulid(self.clock),
186
+ thread_id=thread_id,
187
+ interrupt_id=interrupt.interrupt_id,
188
+ question=interrupt.question,
189
+ allowed_actions=policy.allowed_actions,
190
+ expires_at=iso(asked_at + timeout) if timeout else None,
191
+ reply_to=self.reply_to.to_dict() if self.reply_to is not None else None,
192
+ # A filled-in stub, not a description of one. The consumer replaces `answer`
193
+ # and posts this back to `reply_to`; the presence of `interrupt_id` is what
194
+ # makes it a resume rather than a start.
195
+ reply_with={"thread_id": thread_id, "interrupt_id": interrupt.interrupt_id, "answer": None},
196
+ default=policy.default,
197
+ correlation=policy.correlation,
198
+ tags=policy.tags,
199
+ )
200
+
201
+ def _publish(self, thread_id: str, interrupt: PendingInterrupt, transition: Transition) -> None:
202
+ envelope = self.envelope_for(thread_id, interrupt, transition)
203
+ _log.debug("publishing %s for %s", envelope.type, envelope.interrupt_id)
204
+ self.announce.announce(envelope, transition)
agent_wait/py.typed ADDED
File without changes
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-wait
3
+ Version: 0.2.0
4
+ Summary: Publish a LangGraph agent's interrupts to the outside world so a human can answer them -- from a queue, a Lambda, anywhere the process does not stick around.
5
+ Project-URL: Homepage, https://skamalj.github.io/agent-wait/
6
+ Project-URL: Documentation, https://skamalj.github.io/agent-wait/
7
+ Project-URL: Source, https://github.com/skamalj/agent-wait
8
+ Project-URL: Issues, https://github.com/skamalj/agent-wait/issues
9
+ Project-URL: Changelog, https://github.com/skamalj/agent-wait/blob/main/CHANGELOG.md
10
+ Author-email: Kamaljeet Singh <skamalj@gmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: agents,approval,durable,human-in-the-loop,interrupt,langgraph,serverless
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # agent-wait
26
+
27
+ [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
28
+ [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
30
+
31
+ **Publish a LangGraph agent's interrupts to the outside world, so a human can answer them.**
32
+
33
+ A LangGraph node calls `interrupt()` and the graph stops. If the agent runs in a Lambda,
34
+ a container, or anything else that doesn't stick around, the process exits and nobody
35
+ knows a question was asked or where to send the answer. agent-wait takes that pause and
36
+ puts it somewhere people can see it — a topic, a queue, a webhook, a database row — with
37
+ everything needed to answer it in one envelope.
38
+
39
+ It does not receive the answer. That part is yours, and it is about a dozen lines.
40
+
41
+ ```bash
42
+ pip install agent-wait langgraph-wait # core + LangGraph
43
+ pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
44
+ ```
45
+
46
+ ## The whole thing
47
+
48
+ **In the graph** — one line, where the decision belongs:
49
+
50
+ ```python
51
+ from agent_wait import WaitPolicy
52
+ from langgraph_wait import ask
53
+
54
+
55
+ def review(state):
56
+ if state["amount"] <= 5_000:
57
+ return {"decision": {"action": "approve", "by": "policy:auto"}}
58
+
59
+ decision = ask(
60
+ {"kind": "refund_approval", "order_id": state["order_id"], "amount": state["amount"]},
61
+ policy=WaitPolicy(
62
+ timeout="P3D",
63
+ default={"action": "reject", "reason": "no response in 3 days"},
64
+ allowed_actions=("approve", "reject"),
65
+ tags={"approver_group": "finance"},
66
+ ),
67
+ )
68
+ return {"decision": decision}
69
+ ```
70
+
71
+ `ask()` is a thin wrapper over `interrupt()`. The node pauses exactly as LangGraph pauses;
72
+ what `ask()` adds is the policy, which rides along and comes back out in the envelope. A
73
+ plain `interrupt(value)` works too, with default policy — a graph that already interrupts
74
+ gets published with no edit at all.
75
+
76
+ **In the host** — wire it once:
77
+
78
+ ```python
79
+ from agent_wait import WaitPublisher
80
+ from agent_wait_aws import SnsAnnounce
81
+ from langgraph_wait import LangGraphAdapter
82
+
83
+ agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
84
+ ```
85
+
86
+ **Then route each message.** Starts and answers arrive at the same place; `interrupt_id`
87
+ tells them apart:
88
+
89
+ ```python
90
+ from langgraph_wait import is_answer, resume_command
91
+
92
+
93
+ def route(message):
94
+ thread_id = message["thread_id"]
95
+ if is_answer(message):
96
+ if not is_still_open(thread_id, message["interrupt_id"]):
97
+ return # somebody already answered
98
+ return agent.invoke(resume_command(message), thread_id)
99
+ if agent.pending(thread_id):
100
+ return agent.republish(thread_id) # a redelivery; don't re-ask
101
+ return agent.invoke(message["input"], thread_id)
102
+
103
+
104
+ def is_still_open(thread_id, interrupt_id):
105
+ return any(p.interrupt_id == interrupt_id for p in agent.pending(thread_id))
106
+ ```
107
+
108
+ That is the complete integration. [`examples/refund_agent/`](https://github.com/skamalj/agent-wait/tree/main/examples/refund_agent)
109
+ is it, deployed to Lambda behind SQS.
110
+
111
+ ## What goes out
112
+
113
+ ```json
114
+ {
115
+ "type": "wait.created",
116
+ "thread_id": "order-4471",
117
+ "interrupt_id": "a1b2c3d4e5f60718",
118
+ "question": { "kind": "refund_approval", "amount": 41000 },
119
+ "allowed_actions": ["approve", "reject"],
120
+ "expires_at": "2026-09-12T09:00:00Z",
121
+ "default": { "action": "reject", "reason": "no response in 3 days" },
122
+ "reply_with": { "thread_id": "order-4471", "interrupt_id": "a1b2c3d4e5f60718", "answer": null }
123
+ }
124
+ ```
125
+
126
+ `reply_with` is a filled-in stub: the consumer copies it, sets `answer`, and posts it to
127
+ wherever your agent listens. Whatever goes in `answer` is what the `ask()` call returns —
128
+ verbatim, with nothing merged into it.
129
+
130
+ A second envelope, `wait.resumed`, goes out when the graph moves past the question, so a
131
+ UI knows to retract the button.
132
+
133
+ Full schema, including how to deduplicate:
134
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
135
+
136
+ ## Announcers
137
+
138
+ An announcer is the only thing you are expected to implement. Subclass `BaseAnnounce`
139
+ and write one method:
140
+
141
+ ```python
142
+ from agent_wait import BaseAnnounce
143
+
144
+
145
+ class RedisAnnounce(BaseAnnounce):
146
+ name = "redis"
147
+
148
+ def __init__(self, client, **kw):
149
+ super().__init__(**kw)
150
+ self.client = client
151
+
152
+ def deliver(self, envelope, transition):
153
+ self.client.set(envelope.dedupe_key, envelope.to_json())
154
+ ```
155
+
156
+ The contract — **an announcer must never raise into the run** — is enforced by the base
157
+ class: an exception from `deliver()` becomes a log line, and the graph that just parked
158
+ stays parked.
159
+
160
+ Because nothing reads state back through this library, "announce" doesn't have to mean
161
+ "publish an event". It means *put the question where whoever answers it will find it*:
162
+
163
+ | Adapter | Package | Where the question lands |
164
+ |---|---|---|
165
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature in the GitHub/Stripe shape. Stdlib only. |
166
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
167
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
168
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; policy `tags` become message attributes for subscription filters. |
169
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread and deduplicated on the stable key. |
170
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus, with the transition as detail-type. Notices partial failures behind a 200. |
171
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row.** `open` on `created`, `closed` on `resumed`. A GSI on `status` gives an approvals UI its query with no broker anywhere. |
172
+
173
+ Pass as many as you like; failures are contained per adapter.
174
+
175
+ ## What the library does *not* do
176
+
177
+ Deliberately — each of these is where teams' own opinions live:
178
+
179
+ - **Receive answers.** No inbound endpoint, no validation, no tokens. The router above is yours.
180
+ - **Enforce the timeout.** `expires_at` and `default` are published; a sweep of yours
181
+ sends the default when the deadline passes. There is a
182
+ [working one](https://github.com/skamalj/agent-wait/blob/main/examples/refund_agent/demo_scenarios.py)
183
+ in the example.
184
+ - **Decide a race.** `pending()` rejects an answer the graph has already moved past.
185
+ Two *different* answers in the same instant are your transport's problem — SQS FIFO
186
+ keyed by thread solves it; an HTTP endpoint with concurrent handlers needs a
187
+ conditional write.
188
+ - **Authenticate.** Whoever can write to your entry point can answer.
189
+ - **Store anything.** LangGraph's checkpoint is the only state.
190
+
191
+ ## Two LangGraph 1.2.x behaviours you should know about
192
+
193
+ Both verified against 1.2.11, both pinned by tests that fail if LangGraph changes them.
194
+
195
+ **`get_state().tasks[*].interrupts` over-reports** ([#4796](https://github.com/langchain-ai/langgraph/issues/4796),
196
+ [#6792](https://github.com/langchain-ai/langgraph/issues/6792)). Resume one of two parallel
197
+ interrupts and the finished task still lists its id. `pending()` filters on `task.result`,
198
+ which is `None` only while genuinely parked.
199
+
200
+ **Two interrupting tools in one `ToolNode` get the same id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626),
201
+ [#6624](https://github.com/langchain-ai/langgraph/issues/6624)). A different question under
202
+ an identical id defeats deduplication, and there is no filter for it. The rule is **one
203
+ `interrupt()` per node** — give each approval-requiring tool its own node, which is also
204
+ the fix for a node re-running its side effects on resume.
205
+
206
+ Details: [Architecture](https://skamalj.github.io/agent-wait/architecture/).
207
+
208
+ ## Layout
209
+
210
+ ```
211
+ packages/agent-wait core. No LangGraph, no AWS, no dependencies. pyright strict.
212
+ packages/langgraph-wait ask(), the adapter, resume_command(). The only LangGraph import.
213
+ packages/agent-wait-aws four announce adapters, and a CDK stack.
214
+ examples/refund_agent a graph, a router, and four scenarios against real AWS.
215
+ docs/ message contract, architecture, consumer guide.
216
+ ```
217
+
218
+ ```bash
219
+ uv sync
220
+ uv run pytest
221
+ uv run ruff check . && uv run pyright
222
+ ```
223
+
224
+ MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -0,0 +1,16 @@
1
+ agent_wait/__init__.py,sha256=scwphyvw1JsdLN4hO7KLIy2Fx7A4-0KBqKQf6OaneAc,2083
2
+ agent_wait/errors.py,sha256=WmVd41xAjEw_2DuAUWNzkCwqlS9lT2cBgCTlomuZy-Q,981
3
+ agent_wait/model.py,sha256=AWYJcJFRvjg098_maM1Syd1BvhpInl6ldZLH8eHEPdo,6419
4
+ agent_wait/policy.py,sha256=CH9hZMl4-Q8f_FXvCDh5Wohl22kBG9W02CZfIL1NZ2U,4736
5
+ agent_wait/publisher.py,sha256=SD1Kmnn06LbjN94kh3fDKO57XwlLQOSvxEltYhR6cu0,8726
6
+ agent_wait/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ agent_wait/announce/__init__.py,sha256=t_4Cl9KkswbYqnEZ9vD1Qsu6d9W23TRpnUBqPqeY1t8,439
8
+ agent_wait/announce/base.py,sha256=tqPaWOqn0rNPLqNqSiTuP87-8pVmy9phCY5MeCjBDps,3833
9
+ agent_wait/announce/composite.py,sha256=KdCTMkYG62ZwBqTMHDDSqSKIkVCEXp26A77enIIhfxo,1792
10
+ agent_wait/announce/log.py,sha256=GRqVd3R0AIa_xvqKmpMhdRQq8KLaW__doSiF-_YauFg,1663
11
+ agent_wait/announce/memory.py,sha256=0rqc4zmGSV3oTYQ-mtUcGs29vJf96cdFZNFcSbAAexE,1703
12
+ agent_wait/announce/webhook.py,sha256=GCgRh1WCKSvL3Dna3R5Yn1f1k_Irqhv-pidNp3vdQx8,3910
13
+ agent_wait-0.2.0.dist-info/METADATA,sha256=RwA8-i5gADmuwfcAiueEbqGz-DcuRHNDjBb7dkhDU84,9594
14
+ agent_wait-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ agent_wait-0.2.0.dist-info/licenses/LICENSE,sha256=TQBglRUWN92LKHbaB4t4LAIMXrGtBPV24oXNLMIIVlg,1072
16
+ agent_wait-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamaljeet Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.