Writing an announcer¶
An announcer puts the envelope somewhere a person — or the system that will nudge a person — can find it. It is the only thing agent-wait expects you to implement, and it is one method.
The minimum¶
from agent_wait import BaseAnnounce
class RedisAnnounce(BaseAnnounce):
name = "redis"
def __init__(self, client, **kw):
super().__init__(**kw)
self.client = client
def deliver(self, envelope, transition):
self.client.set(envelope.dedupe_key, envelope.to_json())
Pass it wherever announcers go — publish_interrupts(result, thread_id, [RedisAnnounce(r)])
or @wait(policy, mode="async", announce=[RedisAnnounce(r)]) — and you are done.
BaseAnnounce gives you three things so you do not have to think about them:
| You get | What it means |
|---|---|
A raise in deliver() becomes a log line |
The publish completes; the run completes. This is the one rule in the contract and the base class enforces it rather than trusting you to remember. |
only= |
A caller can restrict which transitions your adapter sees. Today there is one (created), so this is future-proofing at no cost. |
self._log |
A logger named agent_wait.announce.<name>, already set up. |
name is a class attribute. It appears in log lines; keep it short and lowercase.
What you are given¶
deliver(envelope, transition) receives a WaitEnvelope and "created". Everything
about the question is on the envelope — envelope.to_json() / .to_dict() serialise the
whole documented contract — and the field you will reach for first is
envelope.dedupe_key.
The one thing to get right: use dedupe_key¶
The same question will reach your adapter more than once. A start message redelivered
by the queue re-runs the thread and LangGraph hands back the same Interrupt.id; an
async tool called twice with the same arguments derives the same id. Each publish mints a
fresh event_id; dedupe_key ("wait.created:<question_id>") does not change.
So whatever your backend offers as a natural key — a Redis key, an item key, an SQS FIFO
MessageDeduplicationId, an idempotency header — put dedupe_key there. The shipped
adapters all do:
| Adapter | Where dedupe_key goes |
|---|---|
SqsAnnounce |
MessageDeduplicationId on a FIFO queue |
DynamoDbAnnounce |
the item key, so a republish overwrites its own row |
WebhookAnnounce |
the X-Agent-Wait-Dedupe-Key header, for the receiver |
If your backend has no natural key, say so in the docstring so the consumer knows deduplication is on them.
Patterns from the shipped adapters¶
A row, not an event — DynamoDbAnnounce. Writes status=open and every envelope
field, and never touches the row again. What the host does with the row afterwards is
the host's. Worth copying for Postgres, Mongo, or any table an approvals UI already reads.
Routing metadata — SnsAnnounce turns the policy's tags into message attributes so
subscription filter policies can route approver_group=finance one way and everything
else another. If your transport has headers or attributes, envelope.tags goes in them.
A backend that lies about failure — EventBridgeAnnounce checks FailedEntryCount,
because PutEvents reports per-entry failures inside an HTTP 200. Raise on it; the base
turns the raise into a log line, which is exactly right.
A short timeout — WebhookAnnounce defaults to five seconds. deliver() runs inside
the agent's own invocation; a slow backend must not turn a two-second run into thirty.
Do not retry¶
No shipped adapter retries and yours should not either. A failed delivery is logged and
the run completes; the question is published again — same dedupe_key — the next time
the message is redelivered. That is the retry, and it is idempotent by construction.
Testing yours¶
from agent_wait import Question, WaitPolicy, publish
def test_my_adapter(my_backend):
q = Question("q-1", {"kind": "approval"}, WaitPolicy())
publish([q], "thread-1", [MyAnnounce(my_backend)])
assert my_backend.received[-1] == "wait.created:q-1"
my_backend.fail_next()
publish([q], "thread-1", [MyAnnounce(my_backend)]) # must not raise
publish() is the core call; it takes Questions and needs no graph, so an adapter test
needs no LangGraph.
Ideas that are one method away¶
- Postgres / MySQL —
INSERT … ON CONFLICT (dedupe_key) DO UPDATE - Slack —
chat.postMessagewith buttons whose payload isenvelope.reply_with - Email — the question with a link
- A file — one JSON line per envelope; the cheapest audit log
- Kafka / Pub/Sub / RabbitMQ — the message key is
dedupe_key - Datadog / OpenTelemetry — an event per question, so open approvals show on a dashboard
If you write one others could use, the shipped adapters are the reference and a PR is welcome.