langgraph-wait 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,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: langgraph-wait
3
+ Version: 0.2.0
4
+ Summary: The LangGraph side of agent-wait: ask() to declare a question at the interrupt site, an adapter that reads what a thread is parked on, and helpers for the host's start/resume routing.
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,approval,human-in-the-loop,interrupt,langgraph
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: langgraph<2,>=1.2
24
+ Description-Content-Type: text/markdown
25
+
26
+ # langgraph-wait
27
+
28
+ The LangGraph side of [agent-wait](https://pypi.org/project/agent-wait/) — publish a
29
+ LangGraph agent's interrupts so a human can answer them from anywhere.
30
+
31
+ ```bash
32
+ pip install langgraph-wait # pulls in agent-wait
33
+ ```
34
+
35
+ ```python
36
+ from langgraph_wait import ask, LangGraphAdapter, is_answer, resume_command
37
+ ```
38
+
39
+ * **`ask(question, policy)`** — a thin wrapper over `interrupt()`. The node pauses exactly
40
+ as LangGraph pauses; the policy rides along inside the interrupt value and comes back
41
+ out in the published envelope. A plain `interrupt(value)` also works, with the default
42
+ policy.
43
+ * **`LangGraphAdapter(graph)`** — three methods. `pending()` reads what a thread is parked
44
+ on and filters LangGraph's `tasks[*].interrupts` over-report on `task.result`.
45
+ * **`is_answer(message)`** / **`resume_command(message)`** — pure functions for the host's
46
+ router. `interrupt_id` present means resume; the `Command` is keyed by interrupt id so
47
+ parallel interrupts resume independently.
48
+
49
+ Requires `langgraph >= 1.2`. One `interrupt()` per node — two interrupting tools in one
50
+ `ToolNode` share an id on 1.2.x (langgraph #6626), and this package documents rather
51
+ than hides that.
52
+
53
+ Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -0,0 +1,28 @@
1
+ # langgraph-wait
2
+
3
+ The LangGraph side of [agent-wait](https://pypi.org/project/agent-wait/) — publish a
4
+ LangGraph agent's interrupts so a human can answer them from anywhere.
5
+
6
+ ```bash
7
+ pip install langgraph-wait # pulls in agent-wait
8
+ ```
9
+
10
+ ```python
11
+ from langgraph_wait import ask, LangGraphAdapter, is_answer, resume_command
12
+ ```
13
+
14
+ * **`ask(question, policy)`** — a thin wrapper over `interrupt()`. The node pauses exactly
15
+ as LangGraph pauses; the policy rides along inside the interrupt value and comes back
16
+ out in the published envelope. A plain `interrupt(value)` also works, with the default
17
+ policy.
18
+ * **`LangGraphAdapter(graph)`** — three methods. `pending()` reads what a thread is parked
19
+ on and filters LangGraph's `tasks[*].interrupts` over-report on `task.result`.
20
+ * **`is_answer(message)`** / **`resume_command(message)`** — pure functions for the host's
21
+ router. `interrupt_id` present means resume; the `Command` is keyed by interrupt id so
22
+ parallel interrupts resume independently.
23
+
24
+ Requires `langgraph >= 1.2`. One `interrupt()` per node — two interrupting tools in one
25
+ `ToolNode` share an id on 1.2.x (langgraph #6626), and this package documents rather
26
+ than hides that.
27
+
28
+ Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "langgraph-wait"
3
+ version = "0.2.0"
4
+ description = "The LangGraph side of agent-wait: ask() to declare a question at the interrupt site, an adapter that reads what a thread is parked on, and helpers for the host's start/resume routing."
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", "interrupt", "approval"]
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", "langgraph>=1.2,<2"]
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/langgraph_wait"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src/langgraph_wait", "README.md", "LICENSE"]
38
+
39
+ [tool.uv.sources]
40
+ agent-wait = { workspace = true }
@@ -0,0 +1,14 @@
1
+ """langgraph-wait: the LangGraph side of agent-wait."""
2
+
3
+ from .adapter import LangGraphAdapter
4
+ from .ask import WAIT_KEY, ask, unwrap
5
+ from .resume import is_answer, resume_command
6
+
7
+ __all__ = [
8
+ "WAIT_KEY",
9
+ "LangGraphAdapter",
10
+ "ask",
11
+ "is_answer",
12
+ "resume_command",
13
+ "unwrap",
14
+ ]
@@ -0,0 +1,87 @@
1
+ """`LangGraphAdapter` -- three methods, and the one LangGraph bug that matters.
2
+
3
+ The core never imports LangGraph. Everything framework-specific lives here, which is also
4
+ where the framework's rough edges get handled.
5
+
6
+ ## `get_state().tasks[*].interrupts` over-reports
7
+
8
+ This is the finding the whole adapter is shaped around (langgraph #4796 / #6792,
9
+ reproduced against 1.2.11 in `tests/test_spike_langgraph.py`). With two parallel
10
+ interrupts, resume one, and the *finished* task still lists its interrupt id. Anyone
11
+ building "what is this thread waiting on?" from `tasks[*].interrupts` alone gets an
12
+ interrupt the graph has already moved past -- and the failure mode is republishing an
13
+ approval button for a node that already ran, or resuming it a second time.
14
+
15
+ The discriminator is `task.result`: it holds the finished task's return value and is
16
+ `None` while the task is genuinely parked. `pending()` filters on it, and the spike test
17
+ pins the behaviour so a future LangGraph fix shows up as a failing test rather than as
18
+ silence.
19
+
20
+ ## What else was verified empirically, against langgraph 1.2.11
21
+
22
+ * **`Interrupt.id` is stable** across `invoke(None, config)` re-entry and across
23
+ resume-from-checkpoint. This is what makes `dedupe_key` work: a republished envelope
24
+ carries the same id, so consumers can discard it.
25
+ * **Interrupts raised inside a subgraph** surface on the parent's state against the
26
+ subgraph node's task, with a stable id. `subgraphs=True` was not needed, and resuming
27
+ by id works through the parent.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from datetime import datetime
33
+ from typing import Any
34
+
35
+ from agent_wait import PendingInterrupt
36
+
37
+ from .ask import unwrap
38
+
39
+
40
+ class LangGraphAdapter:
41
+ """Wraps a compiled graph. Requires langgraph >= 1.2."""
42
+
43
+ name = "langgraph"
44
+
45
+ def __init__(self, graph: Any) -> None:
46
+ self.graph = graph
47
+
48
+ def config_for(self, thread_id: str) -> dict[str, Any]:
49
+ return {"configurable": {"thread_id": thread_id}}
50
+
51
+ def invoke(self, value: Any, config: Any) -> Any:
52
+ """Run the graph. `value` is the caller's -- fresh input, or a `Command`."""
53
+ return self.graph.invoke(value, config)
54
+
55
+ def pending(self, thread_id: str) -> list[PendingInterrupt]:
56
+ """What this thread is parked on right now. `[]` if it has never run."""
57
+ state = self.graph.get_state(self.config_for(thread_id))
58
+ asked_at = _epoch(getattr(state, "created_at", None))
59
+
60
+ out: list[PendingInterrupt] = []
61
+ for task in getattr(state, "tasks", ()) or ():
62
+ # See the module docstring. A task that has produced a result is finished,
63
+ # whatever its `interrupts` list still claims.
64
+ if getattr(task, "result", None) is not None:
65
+ continue
66
+ for raw in getattr(task, "interrupts", ()) or ():
67
+ question, policy = unwrap(getattr(raw, "value", None))
68
+ out.append(
69
+ PendingInterrupt(
70
+ interrupt_id=str(raw.id),
71
+ question=question,
72
+ policy=policy,
73
+ asked_at=asked_at,
74
+ )
75
+ )
76
+ return out
77
+
78
+
79
+ def _epoch(created_at: Any) -> float | None:
80
+ """LangGraph stamps checkpoints with an ISO-8601 string. Anchor `expires_at` to it
81
+ so a republished question keeps its original deadline."""
82
+ if not isinstance(created_at, str):
83
+ return None
84
+ try:
85
+ return datetime.fromisoformat(created_at).timestamp()
86
+ except ValueError:
87
+ return None
@@ -0,0 +1,52 @@
1
+ """`ask()` -- the one line a graph author writes.
2
+
3
+ decision = ask({"kind": "refund_approval", "amount": amount},
4
+ policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
5
+
6
+ It is a thin wrapper over `langgraph.types.interrupt()`, and thin is the point: the node
7
+ still pauses exactly the way LangGraph pauses, the checkpointer still does its job, and
8
+ nothing about the graph changes. What `ask()` adds is the *policy* -- how long this
9
+ question should stand, what to assume if nobody answers, which answers are meaningful --
10
+ carried inside the interrupt value under `__wait__`.
11
+
12
+ That key is the only channel available. An interrupt has one payload and LangGraph does
13
+ not offer a side channel for metadata, so the policy rides with the question and
14
+ `unwrap()` peels it back off.
15
+
16
+ A plain `interrupt(value)` with no `ask()` still works. It is read as a question with the
17
+ default policy, so a graph that already interrupts gets published without any edit at all.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from agent_wait import WaitPolicy, check_question_size
25
+ from langgraph.types import interrupt
26
+
27
+ WAIT_KEY = "__wait__"
28
+ QUESTION_KEY = "question"
29
+
30
+
31
+ def ask(question: Any, policy: WaitPolicy | None = None) -> Any:
32
+ """Park the graph on `question` and return the answer when it arrives.
33
+
34
+ On the first pass this raises through `interrupt()` and the node does not return. On
35
+ resume it returns the answer message's `answer` field, verbatim -- whatever the
36
+ consumer put there. The library does not wrap it, merge into it or add an action to
37
+ it; what was sent is what the node sees.
38
+ """
39
+ check_question_size(question)
40
+ resolved = policy or WaitPolicy()
41
+ return interrupt({QUESTION_KEY: question, WAIT_KEY: resolved.to_dict()})
42
+
43
+
44
+ def unwrap(value: Any) -> tuple[Any, WaitPolicy]:
45
+ """Split an interrupt value back into `(question, policy)`.
46
+
47
+ Handles both shapes: one raised by `ask()`, and a bare `interrupt(value)` from a
48
+ graph that has never heard of this library.
49
+ """
50
+ if isinstance(value, dict) and WAIT_KEY in value:
51
+ return value.get(QUESTION_KEY), WaitPolicy.from_dict(value.get(WAIT_KEY))
52
+ return value, WaitPolicy()
File without changes
@@ -0,0 +1,39 @@
1
+ """`resume_command()` -- turn an answer message into the thing you pass to `invoke()`.
2
+
3
+ A pure function. It makes no decisions, reads nothing and writes nothing; it exists so
4
+ that the one LangGraph-specific detail in the inbound message format -- that a resume is
5
+ keyed by interrupt id, so that parallel interrupts resume independently -- is written
6
+ down once instead of in every consumer.
7
+
8
+ def handle(message):
9
+ if is_answer(message):
10
+ return agent.invoke(resume_command(message), message["thread_id"])
11
+ return agent.invoke(message["input"], message["thread_id"])
12
+
13
+ `Command(resume=value)` -- the un-keyed form -- hands the same value to *every* parked
14
+ interrupt on the thread. With two approvals outstanding that is one click approving both,
15
+ which is why this builds the dict form even when there is only one.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+
23
+ from langgraph.types import Command
24
+
25
+
26
+ def is_answer(message: Mapping[str, Any]) -> bool:
27
+ """`interrupt_id` present means resume; absent means start. That is the whole rule.
28
+
29
+ It holds because the publisher ships a filled-in `reply_with` stub in every envelope
30
+ and the consumer echoes it back, so the key is there by construction rather than by
31
+ the consumer remembering to add it.
32
+ """
33
+ return bool(message.get("interrupt_id"))
34
+
35
+
36
+ def resume_command(message: Mapping[str, Any]) -> Command:
37
+ """Build the resume for one answer message. Raises `KeyError` if it is a start."""
38
+ interrupt_id = message["interrupt_id"]
39
+ return Command(resume={str(interrupt_id): message.get("answer")})