agent-wait 0.2.1__tar.gz → 0.4.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,215 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-wait
3
+ Version: 0.4.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 decorator, on the function where the decision belongs:
52
+
53
+ ```python
54
+ from agent_wait import WaitPolicy
55
+ from langgraph_wait import hitl
56
+
57
+ FINANCE = WaitPolicy(
58
+ timeout="P3D",
59
+ default={"action": "reject"},
60
+ allowed_actions=("approve", "reject"),
61
+ tags={"approver_group": "finance"},
62
+ )
63
+
64
+
65
+ @tool
66
+ @hitl(FINANCE)
67
+ def issue_refund(order_id: str, amount: int) -> str:
68
+ payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
69
+ return "refunded"
70
+ ```
71
+
72
+ Calling `issue_refund(...)` inside a run parks the graph on `{"function": "issue_refund",
73
+ "args": {...}}`. On resume, `{"action": "approve"}` runs the body — optionally with edited
74
+ `args` — and anything else is returned in its place, so a model sees why it did not run.
75
+
76
+ A node that needs the answer itself declares a `decision` parameter and always runs:
77
+
78
+ ```python
79
+ @hitl(FINANCE)
80
+ def review(state, decision=None):
81
+ return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
82
+ ```
83
+
84
+ **After the run** — one call:
85
+
86
+ ```python
87
+ from agent_wait_aws import SnsAnnounce
88
+ from langgraph_wait import publish_interrupts
89
+
90
+ result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
91
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
92
+ ```
93
+
94
+ It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
95
+ and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
96
+ no checkpointer. If nothing interrupted, it does nothing. A plain `interrupt(value)` from
97
+ a node that never heard of this library is published too, with the default policy.
98
+
99
+ **When the answer comes back** — the one `if` you write:
100
+
101
+ ```python
102
+ from langgraph.types import Command
103
+
104
+ if "question_id" in message: # an answer
105
+ value = Command(resume={message["question_id"]: message["answer"]})
106
+ else: # a start
107
+ value = message["input"]
108
+
109
+ result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
110
+ publish_interrupts(result, message["thread_id"], announce)
111
+ ```
112
+
113
+ That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
114
+ resume for a question the thread has moved past — so there is nothing to check.
115
+
116
+ ## Async mode — the thread does not park
117
+
118
+ ```python
119
+ @tool
120
+ @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
121
+ def issue_refund(order_id: str, amount: int) -> str: ...
122
+ ```
123
+
124
+ The decorator *is* the publisher: the call announces the question and returns
125
+ `{"status": "pending_approval", "question_id": ...}` without running. The graph carries
126
+ on; nothing is parked; nothing to call after the run. The decision arrives later as a new
127
+ message and your graph acts on it. This is the mode for a single-thread channel like
128
+ WhatsApp, where the approver is not the person on the thread.
129
+
130
+ ## What goes out
131
+
132
+ ```json
133
+ {
134
+ "type": "wait.created",
135
+ "thread_id": "order-4471",
136
+ "question_id": "a1b2c3d4e5f60718",
137
+ "question": { "function": "issue_refund", "args": { "order_id": "order-4471", "amount": 41000 } },
138
+ "allowed_actions": ["approve", "reject"],
139
+ "expires_at": "2026-09-14T09:00:00Z",
140
+ "default": { "action": "reject" },
141
+ "source": { "function": "issue_refund" },
142
+ "reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
143
+ }
144
+ ```
145
+
146
+ `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
147
+ `answer` is what the `@hitl` function receives — verbatim.
148
+
149
+ Full schema, the recommended answer shape, and how to deduplicate:
150
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
151
+
152
+ ## Announcers
153
+
154
+ Subclass `BaseAnnounce`, write one method:
155
+
156
+ ```python
157
+ from agent_wait import BaseAnnounce
158
+
159
+
160
+ class RedisAnnounce(BaseAnnounce):
161
+ name = "redis"
162
+
163
+ def __init__(self, client, **kw):
164
+ super().__init__(**kw)
165
+ self.client = client
166
+
167
+ def deliver(self, envelope, transition):
168
+ self.client.set(envelope.dedupe_key, envelope.to_json())
169
+ ```
170
+
171
+ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
172
+
173
+ | Adapter | Package | Where the question lands |
174
+ |---|---|---|
175
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
176
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
177
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
178
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
179
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
180
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
181
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
182
+
183
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
184
+
185
+ ## What the library does *not* do
186
+
187
+ - **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
188
+ - **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
189
+ published so the consumer has the asker's intent. Acting on them is the consumer's.
190
+ - **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
191
+ async mode there is none unless you keep one.
192
+
193
+ ## Two things to know
194
+
195
+ **Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
196
+ tool is called, in its own shape; `@hitl` interrupts inside the call. Both on one tool
197
+ means two interrupts for one approval. Use one or the other.
198
+
199
+ **One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
200
+ one `ToolNode` get the **same interrupt id**
201
+ ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only one surfaces
202
+ per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)). Give each
203
+ interrupting tool its own node. Pinned by a test that fails if LangGraph changes it.
204
+
205
+ ## Layout
206
+
207
+ ```
208
+ packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
209
+ packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
210
+ packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
211
+ examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
212
+ docs/ message contract, architecture, announcer guide, consumer guide.
213
+ ```
214
+
215
+ MIT. Issues and PRs at [github.com/skamalj/agent-wait](https://github.com/skamalj/agent-wait).
@@ -0,0 +1,191 @@
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 decorator, on the function where the decision belongs:
28
+
29
+ ```python
30
+ from agent_wait import WaitPolicy
31
+ from langgraph_wait import hitl
32
+
33
+ FINANCE = WaitPolicy(
34
+ timeout="P3D",
35
+ default={"action": "reject"},
36
+ allowed_actions=("approve", "reject"),
37
+ tags={"approver_group": "finance"},
38
+ )
39
+
40
+
41
+ @tool
42
+ @hitl(FINANCE)
43
+ def issue_refund(order_id: str, amount: int) -> str:
44
+ payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
45
+ return "refunded"
46
+ ```
47
+
48
+ Calling `issue_refund(...)` inside a run parks the graph on `{"function": "issue_refund",
49
+ "args": {...}}`. On resume, `{"action": "approve"}` runs the body — optionally with edited
50
+ `args` — and anything else is returned in its place, so a model sees why it did not run.
51
+
52
+ A node that needs the answer itself declares a `decision` parameter and always runs:
53
+
54
+ ```python
55
+ @hitl(FINANCE)
56
+ def review(state, decision=None):
57
+ return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
58
+ ```
59
+
60
+ **After the run** — one call:
61
+
62
+ ```python
63
+ from agent_wait_aws import SnsAnnounce
64
+ from langgraph_wait import publish_interrupts
65
+
66
+ result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
67
+ publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
68
+ ```
69
+
70
+ It reads `result["__interrupt__"]` — `Interrupt.id` and `Interrupt.value`, nothing else —
71
+ and hands one envelope per question to the announcers. No graph handle, no `get_state()`,
72
+ no checkpointer. If nothing interrupted, it does nothing. A plain `interrupt(value)` from
73
+ a node that never heard of this library is published too, with the default policy.
74
+
75
+ **When the answer comes back** — the one `if` you write:
76
+
77
+ ```python
78
+ from langgraph.types import Command
79
+
80
+ if "question_id" in message: # an answer
81
+ value = Command(resume={message["question_id"]: message["answer"]})
82
+ else: # a start
83
+ value = message["input"]
84
+
85
+ result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
86
+ publish_interrupts(result, message["thread_id"], announce)
87
+ ```
88
+
89
+ That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a
90
+ resume for a question the thread has moved past — so there is nothing to check.
91
+
92
+ ## Async mode — the thread does not park
93
+
94
+ ```python
95
+ @tool
96
+ @hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
97
+ def issue_refund(order_id: str, amount: int) -> str: ...
98
+ ```
99
+
100
+ The decorator *is* the publisher: the call announces the question and returns
101
+ `{"status": "pending_approval", "question_id": ...}` without running. The graph carries
102
+ on; nothing is parked; nothing to call after the run. The decision arrives later as a new
103
+ message and your graph acts on it. This is the mode for a single-thread channel like
104
+ WhatsApp, where the approver is not the person on the thread.
105
+
106
+ ## What goes out
107
+
108
+ ```json
109
+ {
110
+ "type": "wait.created",
111
+ "thread_id": "order-4471",
112
+ "question_id": "a1b2c3d4e5f60718",
113
+ "question": { "function": "issue_refund", "args": { "order_id": "order-4471", "amount": 41000 } },
114
+ "allowed_actions": ["approve", "reject"],
115
+ "expires_at": "2026-09-14T09:00:00Z",
116
+ "default": { "action": "reject" },
117
+ "source": { "function": "issue_refund" },
118
+ "reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
119
+ }
120
+ ```
121
+
122
+ `reply_with` is a filled-in stub: copy it, set `answer`, send it back. Whatever goes in
123
+ `answer` is what the `@hitl` function receives — verbatim.
124
+
125
+ Full schema, the recommended answer shape, and how to deduplicate:
126
+ [Message formats](https://skamalj.github.io/agent-wait/message-formats/).
127
+
128
+ ## Announcers
129
+
130
+ Subclass `BaseAnnounce`, write one method:
131
+
132
+ ```python
133
+ from agent_wait import BaseAnnounce
134
+
135
+
136
+ class RedisAnnounce(BaseAnnounce):
137
+ name = "redis"
138
+
139
+ def __init__(self, client, **kw):
140
+ super().__init__(**kw)
141
+ self.client = client
142
+
143
+ def deliver(self, envelope, transition):
144
+ self.client.set(envelope.dedupe_key, envelope.to_json())
145
+ ```
146
+
147
+ A raise inside `deliver()` becomes a log line; the run completes. Shipped:
148
+
149
+ | Adapter | Package | Where the question lands |
150
+ |---|---|---|
151
+ | `WebhookAnnounce` | `agent-wait` | A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
152
+ | `LogAnnounce` | `agent-wait` | A structured log line. The question never reaches INFO. |
153
+ | `InMemoryAnnounce` | `agent-wait` | A list. For tests. |
154
+ | `SnsAnnounce` | `agent-wait-aws` | A topic; `tags` become message attributes for subscription filters. |
155
+ | `SqsAnnounce` | `agent-wait-aws` | A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
156
+ | `EventBridgeAnnounce` | `agent-wait-aws` | A bus. Notices partial failures behind a 200. |
157
+ | `DynamoDbAnnounce` | `agent-wait-aws` | **A row**, `status=open`, with a GSI an approvals UI can query. Write-only. |
158
+
159
+ [Writing an announcer](https://skamalj.github.io/agent-wait/writing-an-announcer/).
160
+
161
+ ## What the library does *not* do
162
+
163
+ - **Receive answers.** No endpoint, no validation, no ledger reads. The `if` above is yours.
164
+ - **Enforce anything.** `expires_at`, `default`, `answer_ttl`, `allowed_actions` are
165
+ published so the consumer has the asker's intent. Acting on them is the consumer's.
166
+ - **Store anything.** LangGraph's checkpoint is the only state in interrupt mode; in
167
+ async mode there is none unless you keep one.
168
+
169
+ ## Two things to know
170
+
171
+ **Not compatible with LangChain's `HumanInTheLoopMiddleware`.** It interrupts *before* a
172
+ tool is called, in its own shape; `@hitl` interrupts inside the call. Both on one tool
173
+ means two interrupts for one approval. Use one or the other.
174
+
175
+ **One `interrupt()` per node** on langgraph 1.2.x. Two interrupting tools dispatched by
176
+ one `ToolNode` get the **same interrupt id**
177
+ ([#6626](https://github.com/langchain-ai/langgraph/issues/6626)), and only one surfaces
178
+ per run ([#6624](https://github.com/langchain-ai/langgraph/issues/6624)). Give each
179
+ interrupting tool its own node. Pinned by a test that fails if LangGraph changes it.
180
+
181
+ ## Layout
182
+
183
+ ```
184
+ packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
185
+ packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
186
+ packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
187
+ examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
188
+ docs/ message contract, architecture, announcer guide, consumer guide.
189
+ ```
190
+
191
+ 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.4.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.4.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,