agent-wait 0.2.1__tar.gz → 0.3.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,218 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-wait
3
+ Version: 0.3.0
4
+ Summary: Get a LangGraph interrupt out of the process -- to a topic, a queue, a webhook, a table -- so anyone can answer it, and the answer back in.
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
+ **Get a LangGraph interrupt out of the process, and the answer back in.**
32
+
33
+ When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
34
+ called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
35
+ anyone *else* that a question was asked, and no built-in way for anyone else to answer
36
+ it. The moment the question has to reach a person on Slack, an approvals dashboard, a
37
+ ticket queue or another service, you are writing that code yourself — whether your agent
38
+ is a server that runs for a year or a Lambda that is gone in seconds.
39
+
40
+ agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
41
+ a topic, a queue, a webhook, a database row — with everything needed to answer it in one
42
+ envelope, and documents the shape of the answer so the return leg is one `if`.
43
+
44
+ ```bash
45
+ pip install agent-wait langgraph-wait # core + LangGraph
46
+ pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
47
+ ```
48
+
49
+ ## The whole thing
50
+
51
+ **In the graph** — one line, where the decision belongs:
52
+
53
+ ```python
54
+ from agent_wait import WaitPolicy
55
+ from langgraph_wait import ask
56
+
57
+
58
+ def review(state):
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"},
64
+ allowed_actions=("approve", "reject"),
65
+ tags={"approver_group": "finance"},
66
+ ),
67
+ )
68
+ return {"decision": decision} # exactly what the approver sent
69
+ ```
70
+
71
+ `ask()` is a thin wrapper over `interrupt()`; the policy rides along inside the
72
+ interrupt value. A plain `interrupt(value)` works too, with the default policy.
73
+
74
+ **After the run** — one call:
75
+
76
+ ```python
77
+ from agent_wait_aws import SnsAnnounce
78
+ from langgraph_wait import publish_interrupts
79
+
80
+ result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
81
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
82
+ ```
83
+
84
+ It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
85
+ and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
86
+ no checkpointer. If nothing interrupted, it does nothing.
87
+
88
+ **When the answer comes back** — the one `if` you write:
89
+
90
+ ```python
91
+ from langgraph.types import Command
92
+
93
+ if "question_id" in message: # an answer
94
+ value = Command(resume={message["question_id"]: message["answer"]})
95
+ else: # a start
96
+ value = message["input"]
97
+
98
+ result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
99
+ publish_interrupts(result, message["thread_id"], announce)
100
+ ```
101
+
102
+ That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
103
+ resume for a question the thread has moved past — so there is nothing to check.
104
+
105
+ ## Or tag the tool
106
+
107
+ ```python
108
+ from langgraph_wait import hitl
109
+
110
+
111
+ @tool
112
+ @hitl(WaitPolicy(timeout="P1D", default={"action": "reject"}, tags={"approver_group": "finance"}))
113
+ def issue_refund(order_id: str, amount: int) -> str: ...
114
+ ```
115
+
116
+ `@hitl` calls `ask()` with `{tool, args}` before the tool runs. `{"action": "approve"}`
117
+ runs it (optionally with edited `args`); anything else is returned to the model as the
118
+ tool's result. It also registers the policy by tool name, so questions raised by
119
+ LangChain's `HumanInTheLoopMiddleware` — which batches several tool calls into one
120
+ interrupt — are published with it.
121
+
122
+ **Async mode — the thread does not park:**
123
+
124
+ ```python
125
+ @tool
126
+ @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
127
+ def issue_refund(order_id: str, amount: int) -> str: ...
128
+ ```
129
+
130
+ The decorator *is* the publisher: the tool call announces the question and returns
131
+ `{"status": "pending_approval", "question_id": ...}` without running. The graph carries
132
+ on; nothing is parked; nothing to call after the run. The decision arrives later as a new
133
+ message and your graph acts on it. This is the mode for a single-thread channel like
134
+ WhatsApp, where the approver is not the person on the thread.
135
+
136
+ ## What goes out
137
+
138
+ ```json
139
+ {
140
+ "type": "wait.created",
141
+ "thread_id": "order-4471",
142
+ "question_id": "a1b2c3d4e5f60718",
143
+ "question": { "kind": "refund_approval", "amount": 41000 },
144
+ "allowed_actions": ["approve", "reject"],
145
+ "expires_at": "2026-09-14T09:00:00Z",
146
+ "default": { "action": "reject" },
147
+ "source": { "tool": "issue_refund" },
148
+ "reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
149
+ }
150
+ ```
151
+
152
+ `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
153
+ `answer` is what `ask()` returns — verbatim.
154
+
155
+ Full schema, the recommended answer shape, and how to deduplicate:
156
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
157
+
158
+ ## Announcers
159
+
160
+ Subclass `BaseAnnounce`, write one method:
161
+
162
+ ```python
163
+ from agent_wait import BaseAnnounce
164
+
165
+
166
+ class RedisAnnounce(BaseAnnounce):
167
+ name = "redis"
168
+
169
+ def __init__(self, client, **kw):
170
+ super().__init__(**kw)
171
+ self.client = client
172
+
173
+ def deliver(self, envelope, transition):
174
+ self.client.set(envelope.dedupe_key, envelope.to_json())
175
+ ```
176
+
177
+ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
178
+
179
+ | Adapter | Package | Where the question lands |
180
+ |---|---|---|
181
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
182
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
183
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
184
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
185
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
186
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
187
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
188
+
189
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
190
+
191
+ ## What the library does *not* do
192
+
193
+ - **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
194
+ - **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
195
+ published so the consumer has the asker's intent. Acting on them is the consumer's.
196
+ - **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
197
+ async mode there is none unless you keep one.
198
+
199
+ ## One LangGraph 1.2.x behaviour to know
200
+
201
+ Two tools that each call `interrupt()`, dispatched by one `ToolNode`, get the **same
202
+ interrupt id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only
203
+ one surfaces per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)).
204
+ A different question under an identical id defeats deduplication. Use one interrupting
205
+ tool per node, or `HumanInTheLoopMiddleware`, which batches them into one interrupt.
206
+ Pinned by a test that fails if LangGraph changes it.
207
+
208
+ ## Layout
209
+
210
+ ```
211
+ packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
212
+ packages/langgraph-wait ask(), @hitl, publish_interrupts(). The only LangGraph import.
213
+ packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
214
+ examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
215
+ docs/ message contract, architecture, announcer guide, consumer guide.
216
+ ```
217
+
218
+ MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -0,0 +1,194 @@
1
+ # agent-wait
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/agent-wait.svg)](https://pypi.org/project/agent-wait/)
4
+ [![CI](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml/badge.svg)](https://github.com/skamalj/agent-wait/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/skamalj/agent-wait/blob/main/LICENSE)
6
+
7
+ **Get a LangGraph interrupt out of the process, and the answer back in.**
8
+
9
+ When a node calls `interrupt()`, the graph pauses and the interrupt is handed to whatever
10
+ called `invoke()` — and that is where LangGraph stops. There is no built-in way to tell
11
+ anyone *else* that a question was asked, and no built-in way for anyone else to answer
12
+ it. The moment the question has to reach a person on Slack, an approvals dashboard, a
13
+ ticket queue or another service, you are writing that code yourself — whether your agent
14
+ is a server that runs for a year or a Lambda that is gone in seconds.
15
+
16
+ agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
17
+ a topic, a queue, a webhook, a database row — with everything needed to answer it in one
18
+ envelope, and documents the shape of the answer so the return leg is one `if`.
19
+
20
+ ```bash
21
+ pip install agent-wait langgraph-wait # core + LangGraph
22
+ pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
23
+ ```
24
+
25
+ ## The whole thing
26
+
27
+ **In the graph** — one line, where the decision belongs:
28
+
29
+ ```python
30
+ from agent_wait import WaitPolicy
31
+ from langgraph_wait import ask
32
+
33
+
34
+ def review(state):
35
+ decision = ask(
36
+ {"kind": "refund_approval", "order_id": state["order_id"], "amount": state["amount"]},
37
+ policy=WaitPolicy(
38
+ timeout="P3D",
39
+ default={"action": "reject"},
40
+ allowed_actions=("approve", "reject"),
41
+ tags={"approver_group": "finance"},
42
+ ),
43
+ )
44
+ return {"decision": decision} # exactly what the approver sent
45
+ ```
46
+
47
+ `ask()` is a thin wrapper over `interrupt()`; the policy rides along inside the
48
+ interrupt value. A plain `interrupt(value)` works too, with the default policy.
49
+
50
+ **After the run** — one call:
51
+
52
+ ```python
53
+ from agent_wait_aws import SnsAnnounce
54
+ from langgraph_wait import publish_interrupts
55
+
56
+ result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
57
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
58
+ ```
59
+
60
+ It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
61
+ and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
62
+ no checkpointer. If nothing interrupted, it does nothing.
63
+
64
+ **When the answer comes back** — the one `if` you write:
65
+
66
+ ```python
67
+ from langgraph.types import Command
68
+
69
+ if "question_id" in message: # an answer
70
+ value = Command(resume={message["question_id"]: message["answer"]})
71
+ else: # a start
72
+ value = message["input"]
73
+
74
+ result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
75
+ publish_interrupts(result, message["thread_id"], announce)
76
+ ```
77
+
78
+ That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
79
+ resume for a question the thread has moved past — so there is nothing to check.
80
+
81
+ ## Or tag the tool
82
+
83
+ ```python
84
+ from langgraph_wait import hitl
85
+
86
+
87
+ @tool
88
+ @hitl(WaitPolicy(timeout="P1D", default={"action": "reject"}, tags={"approver_group": "finance"}))
89
+ def issue_refund(order_id: str, amount: int) -> str: ...
90
+ ```
91
+
92
+ `@hitl` calls `ask()` with `{tool, args}` before the tool runs. `{"action": "approve"}`
93
+ runs it (optionally with edited `args`); anything else is returned to the model as the
94
+ tool's result. It also registers the policy by tool name, so questions raised by
95
+ LangChain's `HumanInTheLoopMiddleware` — which batches several tool calls into one
96
+ interrupt — are published with it.
97
+
98
+ **Async mode — the thread does not park:**
99
+
100
+ ```python
101
+ @tool
102
+ @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
103
+ def issue_refund(order_id: str, amount: int) -> str: ...
104
+ ```
105
+
106
+ The decorator *is* the publisher: the tool call announces the question and returns
107
+ `{"status": "pending_approval", "question_id": ...}` without running. The graph carries
108
+ on; nothing is parked; nothing to call after the run. The decision arrives later as a new
109
+ message and your graph acts on it. This is the mode for a single-thread channel like
110
+ WhatsApp, where the approver is not the person on the thread.
111
+
112
+ ## What goes out
113
+
114
+ ```json
115
+ {
116
+ "type": "wait.created",
117
+ "thread_id": "order-4471",
118
+ "question_id": "a1b2c3d4e5f60718",
119
+ "question": { "kind": "refund_approval", "amount": 41000 },
120
+ "allowed_actions": ["approve", "reject"],
121
+ "expires_at": "2026-09-14T09:00:00Z",
122
+ "default": { "action": "reject" },
123
+ "source": { "tool": "issue_refund" },
124
+ "reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
125
+ }
126
+ ```
127
+
128
+ `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
129
+ `answer` is what `ask()` returns — verbatim.
130
+
131
+ Full schema, the recommended answer shape, and how to deduplicate:
132
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
133
+
134
+ ## Announcers
135
+
136
+ Subclass `BaseAnnounce`, write one method:
137
+
138
+ ```python
139
+ from agent_wait import BaseAnnounce
140
+
141
+
142
+ class RedisAnnounce(BaseAnnounce):
143
+ name = "redis"
144
+
145
+ def __init__(self, client, **kw):
146
+ super().__init__(**kw)
147
+ self.client = client
148
+
149
+ def deliver(self, envelope, transition):
150
+ self.client.set(envelope.dedupe_key, envelope.to_json())
151
+ ```
152
+
153
+ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
154
+
155
+ | Adapter | Package | Where the question lands |
156
+ |---|---|---|
157
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
158
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
159
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
160
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
161
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
162
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
163
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
164
+
165
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
166
+
167
+ ## What the library does *not* do
168
+
169
+ - **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
170
+ - **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
171
+ published so the consumer has the asker's intent. Acting on them is the consumer's.
172
+ - **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
173
+ async mode there is none unless you keep one.
174
+
175
+ ## One LangGraph 1.2.x behaviour to know
176
+
177
+ Two tools that each call `interrupt()`, dispatched by one `ToolNode`, get the **same
178
+ interrupt id** ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only
179
+ one surfaces per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)).
180
+ A different question under an identical id defeats deduplication. Use one interrupting
181
+ tool per node, or `HumanInTheLoopMiddleware`, which batches them into one interrupt.
182
+ Pinned by a test that fails if LangGraph changes it.
183
+
184
+ ## Layout
185
+
186
+ ```
187
+ packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
188
+ packages/langgraph-wait ask(), @hitl, publish_interrupts(). The only LangGraph import.
189
+ packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
190
+ examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
191
+ docs/ message contract, architecture, announcer guide, consumer guide.
192
+ ```
193
+
194
+ MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-wait"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Get a LangGraph interrupt out of the process -- to a topic, a queue, a webhook, a table -- so anyone can answer it, and the answer back in."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,4 +1,4 @@
1
- """agent-wait: publish an agent's interrupts to the outside world.
1
+ """agent-wait: announce an agent's interrupts to the outside world.
2
2
 
3
3
  A graph node asks a question and pauses:
4
4
 
@@ -7,16 +7,13 @@ A graph node asks a question and pauses:
7
7
  decision = ask({"kind": "refund_approval", "amount": amount},
8
8
  policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
9
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:
10
+ After the run, the host announces whatever it parked on:
12
11
 
13
- agent = WaitPublisher(LangGraphAdapter(graph), announce=[SnsAnnounce(topic_arn)])
12
+ result = graph.invoke(value, config)
13
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
14
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.
15
+ That is the whole library. It builds one envelope per interrupt and hands it to the
16
+ announcers. It does not run the graph, receive answers, hold state or run timers.
20
17
  """
21
18
 
22
19
  from .announce import (
@@ -35,7 +32,7 @@ from .model import (
35
32
  Clock,
36
33
  EntryPoint,
37
34
  FakeClock,
38
- PendingInterrupt,
35
+ Question,
39
36
  SystemClock,
40
37
  Transition,
41
38
  WaitEnvelope,
@@ -45,9 +42,9 @@ from .model import (
45
42
  new_ulid,
46
43
  )
47
44
  from .policy import WaitPolicy, parse_duration
48
- from .publisher import FrameworkAdapter, WaitPublisher
45
+ from .publish import build_envelope, publish
49
46
 
50
- __version__ = "0.2.1"
47
+ __version__ = "0.3.0"
51
48
 
52
49
  __all__ = [
53
50
  "MAX_QUESTION_BYTES",
@@ -58,24 +55,24 @@ __all__ = [
58
55
  "EntryPoint",
59
56
  "FailingAnnounce",
60
57
  "FakeClock",
61
- "FrameworkAdapter",
62
58
  "InMemoryAnnounce",
63
59
  "LogAnnounce",
64
- "PendingInterrupt",
65
60
  "PolicyError",
61
+ "Question",
66
62
  "QuestionTooLarge",
67
63
  "SystemClock",
68
64
  "Transition",
69
65
  "WaitEnvelope",
70
66
  "WaitError",
71
67
  "WaitPolicy",
72
- "WaitPublisher",
73
68
  "WebhookAnnounce",
74
69
  "__version__",
70
+ "build_envelope",
75
71
  "canonical_json",
76
72
  "check_question_size",
77
73
  "iso",
78
74
  "new_ulid",
79
75
  "parse_duration",
76
+ "publish",
80
77
  "verify_signature",
81
78
  ]
@@ -89,7 +89,7 @@ class BaseAnnounce(ABC):
89
89
  except Exception:
90
90
  # A backend being unreachable must not fail a run that has already parked.
91
91
  self._log.exception(
92
- "%s failed for interrupt %s (%s)", type(self).__name__, envelope.interrupt_id, transition
92
+ "%s failed for interrupt %s (%s)", type(self).__name__, envelope.question_id, transition
93
93
  )
94
94
 
95
95
  @abstractmethod
@@ -43,5 +43,5 @@ class CompositeAnnounce:
43
43
  "announce adapter %s raised on %s for interrupt %s",
44
44
  name,
45
45
  transition,
46
- envelope.interrupt_id,
46
+ envelope.question_id,
47
47
  )
@@ -33,7 +33,7 @@ class LogAnnounce(BaseAnnounce):
33
33
  "event": envelope.type,
34
34
  "event_id": envelope.event_id,
35
35
  "thread_id": envelope.thread_id,
36
- "interrupt_id": envelope.interrupt_id,
36
+ "question_id": envelope.question_id,
37
37
  "allowed_actions": list(envelope.allowed_actions),
38
38
  "expires_at": envelope.expires_at,
39
39
  "tags": dict(envelope.tags),
@@ -42,6 +42,6 @@ class LogAnnounce(BaseAnnounce):
42
42
  if self._out.isEnabledFor(logging.DEBUG):
43
43
  self._out.debug(
44
44
  "agent-wait question for %s: %s",
45
- envelope.interrupt_id,
45
+ envelope.question_id,
46
46
  json.dumps(envelope.question, default=str),
47
47
  )
@@ -8,7 +8,7 @@ transition, JSON body, three headers a receiver can route or verify on:
8
8
 
9
9
  Content-Type: application/json
10
10
  X-Agent-Wait-Event: wait.created | wait.resumed
11
- X-Agent-Wait-Dedupe-Key: wait.created:<interrupt_id>
11
+ X-Agent-Wait-Dedupe-Key: wait.created:<question_id>
12
12
  X-Agent-Wait-Signature: sha256=<hex> (only when `secret` is given)
13
13
 
14
14
  ## Signing
@@ -18,15 +18,11 @@ from typing import Any, Literal, Protocol
18
18
  from .errors import QuestionTooLarge
19
19
  from .policy import WaitPolicy
20
20
 
21
- Transition = Literal["created", "resumed"]
22
- """The two things that can be said about a wait.
21
+ Transition = Literal["created"]
22
+ """The one thing said about a wait: the graph is parked on this question.
23
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.
24
+ Announce adapters receive it as a second argument so an adapter can be written once for
25
+ any future transition without changing its signature; today only `created` is emitted.
30
26
  """
31
27
 
32
28
  # 256 KB is the smallest of the caps on the way out (SNS, SQS and EventBridge all sit
@@ -113,21 +109,24 @@ class EntryPoint:
113
109
 
114
110
 
115
111
  @dataclass(frozen=True)
116
- class PendingInterrupt:
117
- """One question a thread is parked on. What a `FrameworkAdapter` hands back."""
112
+ class Question:
113
+ """One question for a human. What `publish()` takes.
114
+
115
+ `question_id` is whatever the answer must carry back. In interrupt mode it is
116
+ LangGraph's `Interrupt.id`; in async mode it is an id you minted -- derive it from the
117
+ inputs (`sha256(thread | tool | args)`) so the same trigger produces the same question
118
+ and the consumer sees one, not two.
119
+ """
118
120
 
119
- interrupt_id: str
121
+ question_id: str
120
122
  question: Any
121
123
  policy: WaitPolicy
122
124
  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
- """
125
+ """POSIX timestamp of when the question was asked, if known. `expires_at` is measured
126
+ from it when set and from publish time otherwise."""
127
+ source: Mapping[str, Any] | None = None
128
+ """Where it came from -- `{"tool": "issue_refund"}`, `{"node": "review"}`. The first
129
+ thing an approver reads, so worth filling in when you can."""
131
130
 
132
131
 
133
132
  @dataclass(frozen=True)
@@ -137,13 +136,15 @@ class WaitEnvelope:
137
136
  type: str
138
137
  event_id: str
139
138
  thread_id: str
140
- interrupt_id: str
139
+ question_id: str
141
140
  question: Any
142
141
  allowed_actions: tuple[str, ...]
143
142
  expires_at: str | None
144
143
  reply_with: Mapping[str, Any]
145
144
  reply_to: Mapping[str, str] | None = None
146
145
  default: Any = None
146
+ answer_ttl: str | int | None = None
147
+ source: Mapping[str, Any] | None = None
147
148
  correlation: Mapping[str, str] | None = None
148
149
  tags: Mapping[str, str] = field(default_factory=_no_str_map)
149
150
 
@@ -153,22 +154,24 @@ class WaitEnvelope:
153
154
 
154
155
  Not `event_id`: that is fresh per publish, so a redelivered start message
155
156
  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
+ guarantees `question_id` is stable across re-entry and resume-from-checkpoint
157
158
  (verified in `test_spike_langgraph.py`), which makes this key stable for exactly
158
159
  as long as the question is.
159
160
  """
160
- return f"{self.type}:{self.interrupt_id}"
161
+ return f"{self.type}:{self.question_id}"
161
162
 
162
163
  def to_dict(self) -> dict[str, Any]:
163
164
  return {
164
165
  "type": self.type,
165
166
  "event_id": self.event_id,
166
167
  "thread_id": self.thread_id,
167
- "interrupt_id": self.interrupt_id,
168
+ "question_id": self.question_id,
168
169
  "question": self.question,
169
170
  "allowed_actions": list(self.allowed_actions),
170
171
  "expires_at": self.expires_at,
171
172
  "default": self.default,
173
+ "answer_ttl": self.answer_ttl,
174
+ "source": dict(self.source) if self.source else None,
172
175
  "reply_to": dict(self.reply_to) if self.reply_to else None,
173
176
  "reply_with": dict(self.reply_with),
174
177
  "correlation": dict(self.correlation) if self.correlation else None,