agent-wait-aws 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ *.egg-info/
2
+ *.pyc
3
+ .coverage
4
+ .env
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .scratch/
8
+ .venv/
9
+ __pycache__/
10
+ build/
11
+ cdk.out/
12
+ reports/*.log
13
+
14
+ # generated by the docs workflow from README.md / CHANGELOG.md
15
+ docs/index.md
16
+ docs/changelog.md
17
+ # build output
18
+ dist/
19
+ site/
@@ -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.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-wait-aws
3
+ Version: 0.2.0
4
+ Summary: AWS announce adapters for agent-wait: SNS, SQS, EventBridge, and DynamoDB (the question as a row), plus a CDK stack for the example.
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
+ Author-email: Kamaljeet Singh <skamalj@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,aws,dynamodb,eventbridge,human-in-the-loop,langgraph,sns,sqs
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: agent-wait<0.3,>=0.2
23
+ Requires-Dist: boto3>=1.35
24
+ Description-Content-Type: text/markdown
25
+
26
+ # agent-wait-aws
27
+
28
+ AWS announce adapters for [agent-wait](https://pypi.org/project/agent-wait/).
29
+
30
+ ```bash
31
+ pip install agent-wait-aws # pulls in agent-wait and boto3
32
+ ```
33
+
34
+ ```python
35
+ from agent_wait_aws import DynamoDbAnnounce, EventBridgeAnnounce, SnsAnnounce, SqsAnnounce
36
+ ```
37
+
38
+ | Adapter | Where the question lands |
39
+ |---|---|
40
+ | `SnsAnnounce(topic_arn)` | A topic. Policy `tags` become message attributes, so subscription filter policies can route on them. |
41
+ | `SqsAnnounce(queue_url)` | A queue. On FIFO: `MessageGroupId = thread_id`, and `MessageDeduplicationId` is the stable dedupe key, so a republished question is swallowed. |
42
+ | `EventBridgeAnnounce(bus)` | A bus, with the transition as detail-type. Notices partial failures, which `PutEvents` reports inside an HTTP 200. |
43
+ | `DynamoDbAnnounce(table)` | A row. `created` writes it `open`, `resumed` marks it `closed`; a GSI on `status` gives an approvals UI its query with no broker involved. |
44
+
45
+ Every one is a `BaseAnnounce` subclass with a single `deliver()` method, so a failure is a
46
+ log line rather than a failed run. They are also the reference for writing your own:
47
+ `sqs.py` is twenty lines.
48
+
49
+ The package also carries a CDK stack for the example agent — a FIFO queue, a topic, one
50
+ Lambda, the checkpointer's table and an approvals table.
51
+
52
+ Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -0,0 +1,27 @@
1
+ # agent-wait-aws
2
+
3
+ AWS announce adapters for [agent-wait](https://pypi.org/project/agent-wait/).
4
+
5
+ ```bash
6
+ pip install agent-wait-aws # pulls in agent-wait and boto3
7
+ ```
8
+
9
+ ```python
10
+ from agent_wait_aws import DynamoDbAnnounce, EventBridgeAnnounce, SnsAnnounce, SqsAnnounce
11
+ ```
12
+
13
+ | Adapter | Where the question lands |
14
+ |---|---|
15
+ | `SnsAnnounce(topic_arn)` | A topic. Policy `tags` become message attributes, so subscription filter policies can route on them. |
16
+ | `SqsAnnounce(queue_url)` | A queue. On FIFO: `MessageGroupId = thread_id`, and `MessageDeduplicationId` is the stable dedupe key, so a republished question is swallowed. |
17
+ | `EventBridgeAnnounce(bus)` | A bus, with the transition as detail-type. Notices partial failures, which `PutEvents` reports inside an HTTP 200. |
18
+ | `DynamoDbAnnounce(table)` | A row. `created` writes it `open`, `resumed` marks it `closed`; a GSI on `status` gives an approvals UI its query with no broker involved. |
19
+
20
+ Every one is a `BaseAnnounce` subclass with a single `deliver()` method, so a failure is a
21
+ log line rather than a failed run. They are also the reference for writing your own:
22
+ `sqs.py` is twenty lines.
23
+
24
+ The package also carries a CDK stack for the example agent — a FIFO queue, a topic, one
25
+ Lambda, the checkpointer's table and an approvals table.
26
+
27
+ Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "agent-wait-aws"
3
+ version = "0.2.0"
4
+ description = "AWS announce adapters for agent-wait: SNS, SQS, EventBridge, and DynamoDB (the question as a row), plus a CDK stack for the example."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Kamaljeet Singh", email = "skamalj@gmail.com" }]
10
+ keywords = ["agents", "langgraph", "human-in-the-loop", "aws", "sns", "sqs", "eventbridge", "dynamodb"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Libraries",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = ["agent-wait>=0.2,<0.3", "boto3>=1.35"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://skamalj.github.io/agent-wait/"
25
+ Documentation = "https://skamalj.github.io/agent-wait/"
26
+ Source = "https://github.com/skamalj/agent-wait"
27
+ Issues = "https://github.com/skamalj/agent-wait/issues"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/agent_wait_aws"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src/agent_wait_aws", "README.md", "LICENSE"]
38
+
39
+ [tool.uv.sources]
40
+ agent-wait = { workspace = true }
@@ -0,0 +1,17 @@
1
+ """agent-wait-aws: announce adapters for AWS.
2
+
3
+ Four places a question can land:
4
+
5
+ SnsAnnounce(topic_arn) # fan out; policy tags become filterable attributes
6
+ SqsAnnounce(queue_url) # one consumer, ordered per thread on FIFO
7
+ EventBridgeAnnounce(bus) # rules and targets decide who cares
8
+ DynamoDbAnnounce(table) # the question *is* a row; query it directly
9
+
10
+ This package contains nothing but adapters and the CDK stack for the example. There
11
+ is no store, no key provider and no run handler here, because the library keeps no state
12
+ and receives no answers.
13
+ """
14
+
15
+ from .announce import DynamoDbAnnounce, EventBridgeAnnounce, SnsAnnounce, SqsAnnounce
16
+
17
+ __all__ = ["DynamoDbAnnounce", "EventBridgeAnnounce", "SnsAnnounce", "SqsAnnounce"]
@@ -0,0 +1,6 @@
1
+ from .dynamodb import DynamoDbAnnounce
2
+ from .eventbridge import EventBridgeAnnounce
3
+ from .sns import SnsAnnounce
4
+ from .sqs import SqsAnnounce
5
+
6
+ __all__ = ["DynamoDbAnnounce", "EventBridgeAnnounce", "SnsAnnounce", "SqsAnnounce"]
@@ -0,0 +1,98 @@
1
+ """`DynamoDbAnnounce` -- the question *is* the row.
2
+
3
+ Here to make a point: since nothing reads state back through this library, an
4
+ "announce adapter" does not have to be a message broker. It is
5
+ just somewhere the question lands where whoever answers it will find it. A table is a
6
+ perfectly good somewhere -- and for an approval queue it is a better one, because a UI
7
+ can `Query` it for "everything still open" without anybody having to build a projection
8
+ off an event stream first.
9
+
10
+ announce=[DynamoDbAnnounce("approvals")]
11
+
12
+ pk = "THREAD#order-4471" sk = "WAIT#<interrupt_id>"
13
+ status = "open" | "closed"
14
+
15
+ `created` writes the row; `resumed` marks it closed rather than deleting it, so the row
16
+ is still there when someone asks why the button stopped working. Both are idempotent:
17
+ the same interrupt republished after a crash overwrites its own row with identical
18
+ content, which is the whole reason `dedupe_key` is stable.
19
+
20
+ A GSI on `status` gives you "every open approval", which is the query an approvals UI
21
+ actually wants. The CDK stack in this package creates it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ import boto3
29
+ from agent_wait.announce import BaseAnnounce
30
+ from agent_wait.model import Transition, WaitEnvelope
31
+
32
+
33
+ class DynamoDbAnnounce(BaseAnnounce):
34
+ name = "dynamodb"
35
+
36
+ def __init__(
37
+ self,
38
+ table_name: str,
39
+ *,
40
+ table: Any = None,
41
+ region_name: str | None = None,
42
+ ttl_seconds: int | None = None,
43
+ only: tuple[Transition, ...] | None = None,
44
+ ) -> None:
45
+ super().__init__(only=only)
46
+ self.table_name = table_name
47
+ self._table = table or boto3.resource("dynamodb", region_name=region_name).Table(table_name)
48
+ self._ttl_seconds = ttl_seconds
49
+
50
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
51
+ if transition == "resumed":
52
+ self._close(envelope)
53
+ else:
54
+ self._open(envelope)
55
+
56
+ def _open(self, envelope: WaitEnvelope) -> None:
57
+ item: dict[str, Any] = {
58
+ "pk": f"THREAD#{envelope.thread_id}",
59
+ "sk": f"WAIT#{envelope.interrupt_id}",
60
+ "status": "open",
61
+ "thread_id": envelope.thread_id,
62
+ "interrupt_id": envelope.interrupt_id,
63
+ "question": envelope.question,
64
+ "allowed_actions": list(envelope.allowed_actions),
65
+ "expires_at": envelope.expires_at,
66
+ "reply_with": dict(envelope.reply_with),
67
+ "tags": dict(envelope.tags),
68
+ "event_id": envelope.event_id,
69
+ }
70
+ if envelope.default is not None:
71
+ item["default"] = envelope.default
72
+ if envelope.reply_to:
73
+ item["reply_to"] = dict(envelope.reply_to)
74
+ if self._ttl_seconds:
75
+ item["ttl"] = int(_now()) + self._ttl_seconds
76
+ self._table.put_item(Item=item)
77
+
78
+ def _close(self, envelope: WaitEnvelope) -> None:
79
+ """Mark closed, and only if the row is there.
80
+
81
+ The condition matters: a `resumed` for an interrupt this table never saw
82
+ (announced by a different adapter, or written before this adapter was added)
83
+ should leave nothing behind. Creating a closed row for a question nobody was
84
+ ever asked would put a phantom in the approvals UI's history.
85
+ """
86
+ self._table.update_item(
87
+ Key={"pk": f"THREAD#{envelope.thread_id}", "sk": f"WAIT#{envelope.interrupt_id}"},
88
+ UpdateExpression="SET #s = :closed",
89
+ ExpressionAttributeNames={"#s": "status"},
90
+ ExpressionAttributeValues={":closed": "closed"},
91
+ ConditionExpression="attribute_exists(pk)",
92
+ )
93
+
94
+
95
+ def _now() -> float:
96
+ import time
97
+
98
+ return time.time()
@@ -0,0 +1,53 @@
1
+ """`EventBridgeAnnounce` -- `PutEvents` onto a bus, `DetailType = wait.<transition>`.
2
+
3
+ The most useful of the four for a large organisation: rules on the bus can fan a
4
+ `wait.created` out to Slack, a ticketing system and an audit log without the agent, or
5
+ this library, knowing that any of them exist.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ import boto3
14
+ from agent_wait.announce import BaseAnnounce
15
+ from agent_wait.model import Transition, WaitEnvelope
16
+
17
+
18
+ class EventBridgeAnnounce(BaseAnnounce):
19
+ name = "eventbridge"
20
+
21
+ def __init__(
22
+ self,
23
+ bus_name: str,
24
+ *,
25
+ source: str = "agent-wait",
26
+ client: Any = None,
27
+ region_name: str | None = None,
28
+ only: tuple[Transition, ...] | None = None,
29
+ ) -> None:
30
+ super().__init__(only=only)
31
+ self.bus_name = bus_name
32
+ self.source = source
33
+ self._client = client or boto3.client("events", region_name=region_name)
34
+
35
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
36
+ response = self._client.put_events(
37
+ Entries=[
38
+ {
39
+ "EventBusName": self.bus_name,
40
+ "Source": self.source,
41
+ "DetailType": envelope.type,
42
+ "Detail": envelope.to_json(),
43
+ }
44
+ ]
45
+ )
46
+ # PutEvents reports per-entry failures in the body with a 200 status, so a naive
47
+ # call looks successful even when nothing was delivered.
48
+ if response.get("FailedEntryCount"):
49
+ self._log.error(
50
+ "EventBridge rejected the envelope for interrupt %s: %s",
51
+ envelope.interrupt_id,
52
+ json.dumps(response.get("Entries", []), default=str),
53
+ )
@@ -0,0 +1,47 @@
1
+ """`SnsAnnounce` -- publish the envelope, with the policy's tags as message attributes.
2
+
3
+ The tags are the useful part. `WaitPolicy(tags={"approver_group": "finance"})` becomes an
4
+ SNS message attribute, so a subscription filter policy can route finance approvals to one
5
+ place and everything else somewhere else -- without the agent knowing any of those places
6
+ exist.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import boto3
14
+ from agent_wait.announce import BaseAnnounce
15
+ from agent_wait.model import Transition, WaitEnvelope
16
+
17
+
18
+ class SnsAnnounce(BaseAnnounce):
19
+ name = "sns"
20
+
21
+ def __init__(
22
+ self,
23
+ topic_arn: str,
24
+ *,
25
+ client: Any = None,
26
+ region_name: str | None = None,
27
+ only: tuple[Transition, ...] | None = None,
28
+ ) -> None:
29
+ super().__init__(only=only)
30
+ self.topic_arn = topic_arn
31
+ self._client = client or boto3.client("sns", region_name=region_name)
32
+
33
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
34
+ attributes: dict[str, dict[str, str]] = {
35
+ "transition": {"DataType": "String", "StringValue": transition},
36
+ "thread_id": {"DataType": "String", "StringValue": envelope.thread_id},
37
+ }
38
+ for key, value in envelope.tags.items():
39
+ # SNS rejects an empty StringValue, and a filter policy cannot use one.
40
+ if value:
41
+ attributes[key] = {"DataType": "String", "StringValue": str(value)}
42
+ self._client.publish(
43
+ TopicArn=self.topic_arn,
44
+ Subject=envelope.type,
45
+ Message=envelope.to_json(),
46
+ MessageAttributes=attributes,
47
+ )
@@ -0,0 +1,42 @@
1
+ """`SqsAnnounce` -- put the envelope on a queue.
2
+
3
+ On a FIFO queue, `MessageGroupId` is the thread id (one in-flight question per
4
+ conversation, so a consumer never sees two from the same graph out of order) and
5
+ `MessageDeduplicationId` is the envelope's `dedupe_key` -- not its `event_id`, which is
6
+ fresh on every publish and would let a republished question through as new.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import boto3
15
+ from agent_wait.announce import BaseAnnounce
16
+ from agent_wait.model import Transition, WaitEnvelope
17
+
18
+
19
+ class SqsAnnounce(BaseAnnounce):
20
+ name = "sqs"
21
+
22
+ def __init__(
23
+ self,
24
+ queue_url: str,
25
+ *,
26
+ group_id: Callable[[WaitEnvelope], str] | None = None,
27
+ client: Any = None,
28
+ region_name: str | None = None,
29
+ only: tuple[Transition, ...] | None = None,
30
+ ) -> None:
31
+ super().__init__(only=only)
32
+ self.queue_url = queue_url
33
+ self.group_id = group_id or (lambda envelope: envelope.thread_id)
34
+ self._client = client or boto3.client("sqs", region_name=region_name)
35
+ self.is_fifo = queue_url.endswith(".fifo")
36
+
37
+ def deliver(self, envelope: WaitEnvelope, transition: Transition) -> None:
38
+ kwargs: dict[str, Any] = {"QueueUrl": self.queue_url, "MessageBody": envelope.to_json()}
39
+ if self.is_fifo:
40
+ kwargs["MessageGroupId"] = self.group_id(envelope)
41
+ kwargs["MessageDeduplicationId"] = envelope.dedupe_key
42
+ self._client.send_message(**kwargs)
File without changes