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 +81 -0
- agent_wait/announce/__init__.py +16 -0
- agent_wait/announce/base.py +98 -0
- agent_wait/announce/composite.py +47 -0
- agent_wait/announce/log.py +47 -0
- agent_wait/announce/memory.py +51 -0
- agent_wait/announce/webhook.py +99 -0
- agent_wait/errors.py +26 -0
- agent_wait/model.py +179 -0
- agent_wait/policy.py +122 -0
- agent_wait/publisher.py +204 -0
- agent_wait/py.typed +0 -0
- agent_wait-0.2.0.dist-info/METADATA +224 -0
- agent_wait-0.2.0.dist-info/RECORD +16 -0
- agent_wait-0.2.0.dist-info/WHEEL +4 -0
- agent_wait-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|
agent_wait/publisher.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/agent-wait/)
|
|
28
|
+
[](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
|
|
29
|
+
[](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,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.
|