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.
- langgraph_wait-0.2.0/.gitignore +19 -0
- langgraph_wait-0.2.0/LICENSE +21 -0
- langgraph_wait-0.2.0/PKG-INFO +53 -0
- langgraph_wait-0.2.0/README.md +28 -0
- langgraph_wait-0.2.0/pyproject.toml +40 -0
- langgraph_wait-0.2.0/src/langgraph_wait/__init__.py +14 -0
- langgraph_wait-0.2.0/src/langgraph_wait/adapter.py +87 -0
- langgraph_wait-0.2.0/src/langgraph_wait/ask.py +52 -0
- langgraph_wait-0.2.0/src/langgraph_wait/py.typed +0 -0
- langgraph_wait-0.2.0/src/langgraph_wait/resume.py +39 -0
|
@@ -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")})
|