langgraph-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.
- {langgraph_wait-0.2.1 → langgraph_wait-0.4.0}/PKG-INFO +13 -15
- langgraph_wait-0.4.0/README.md +26 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.4.0}/pyproject.toml +2 -2
- langgraph_wait-0.4.0/src/langgraph_wait/__init__.py +6 -0
- langgraph_wait-0.4.0/src/langgraph_wait/_interrupt.py +43 -0
- langgraph_wait-0.4.0/src/langgraph_wait/hitl.py +132 -0
- langgraph_wait-0.4.0/src/langgraph_wait/publish.py +57 -0
- langgraph_wait-0.2.1/README.md +0 -28
- langgraph_wait-0.2.1/src/langgraph_wait/__init__.py +0 -14
- langgraph_wait-0.2.1/src/langgraph_wait/adapter.py +0 -87
- langgraph_wait-0.2.1/src/langgraph_wait/ask.py +0 -52
- langgraph_wait-0.2.1/src/langgraph_wait/resume.py +0 -39
- {langgraph_wait-0.2.1 → langgraph_wait-0.4.0}/.gitignore +0 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.4.0}/LICENSE +0 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.4.0}/src/langgraph_wait/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: langgraph-wait
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
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
5
|
Project-URL: Homepage, https://skamalj.github.io/agent-wait/
|
|
6
6
|
Project-URL: Documentation, https://skamalj.github.io/agent-wait/
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
21
|
Requires-Python: >=3.12
|
|
22
|
-
Requires-Dist: agent-wait<0.
|
|
22
|
+
Requires-Dist: agent-wait<0.5,>=0.4
|
|
23
23
|
Requires-Dist: langgraph<2,>=1.2
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
@@ -33,21 +33,19 @@ pip install langgraph-wait # pulls in agent-wait
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
```python
|
|
36
|
-
from langgraph_wait import
|
|
36
|
+
from langgraph_wait import hitl, publish_interrupts
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
router. `interrupt_id` present means resume; the `Command` is keyed by interrupt id so
|
|
47
|
-
parallel interrupts resume independently.
|
|
39
|
+
* **`@hitl(policy)`** — on any function, under `@tool` if it is a tool. Calling it inside a
|
|
40
|
+
run parks the graph on `{"function", "args"}`. `{"action": "approve"}` runs the body;
|
|
41
|
+
anything else is returned in its place. A function with a `decision` parameter always
|
|
42
|
+
runs and receives the answer. `mode="async"` makes the decorator the publisher: it
|
|
43
|
+
announces and returns pending without parking.
|
|
44
|
+
* **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
|
|
45
|
+
`result["__interrupt__"]` and hands one envelope per question to the announcers.
|
|
48
46
|
|
|
49
|
-
Requires `langgraph >= 1.2`.
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
Requires `langgraph >= 1.2`. Not compatible with LangChain's `HumanInTheLoopMiddleware`
|
|
48
|
+
on the same tool (two interrupts for one approval). One `interrupt()` per node on 1.2.x
|
|
49
|
+
(langgraph #6626).
|
|
52
50
|
|
|
53
51
|
Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# langgraph-wait
|
|
2
|
+
|
|
3
|
+
The LangGraph side of [agent-wait](https://pypi.org/project/agent-wait/) — get a LangGraph
|
|
4
|
+
interrupt out of the process, and the answer back in.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install langgraph-wait # pulls in agent-wait
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from langgraph_wait import hitl, publish_interrupts
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
* **`@hitl(policy)`** — on any function, under `@tool` if it is a tool. Calling it inside a
|
|
15
|
+
run parks the graph on `{"function", "args"}`. `{"action": "approve"}` runs the body;
|
|
16
|
+
anything else is returned in its place. A function with a `decision` parameter always
|
|
17
|
+
runs and receives the answer. `mode="async"` makes the decorator the publisher: it
|
|
18
|
+
announces and returns pending without parking.
|
|
19
|
+
* **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
|
|
20
|
+
`result["__interrupt__"]` and hands one envelope per question to the announcers.
|
|
21
|
+
|
|
22
|
+
Requires `langgraph >= 1.2`. Not compatible with LangChain's `HumanInTheLoopMiddleware`
|
|
23
|
+
on the same tool (two interrupts for one approval). One `interrupt()` per node on 1.2.x
|
|
24
|
+
(langgraph #6626).
|
|
25
|
+
|
|
26
|
+
Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "langgraph-wait"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
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
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Topic :: Software Development :: Libraries",
|
|
19
19
|
"Typing :: Typed",
|
|
20
20
|
]
|
|
21
|
-
dependencies = ["agent-wait>=0.
|
|
21
|
+
dependencies = ["agent-wait>=0.4,<0.5", "langgraph>=1.2,<2"]
|
|
22
22
|
|
|
23
23
|
[project.urls]
|
|
24
24
|
Homepage = "https://skamalj.github.io/agent-wait/"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The interrupt value `@hitl` raises, and how `publish_interrupts` reads it back.
|
|
2
|
+
|
|
3
|
+
Private. An `Interrupt` has one payload and LangGraph offers no side channel, so the
|
|
4
|
+
policy and the source ride inside the value next to the question:
|
|
5
|
+
|
|
6
|
+
{"question": {...}, "__wait__": {policy}, "__source__": {...}}
|
|
7
|
+
|
|
8
|
+
`unwrap()` reverses it. A value without `__wait__` -- a bare `interrupt(x)` from a graph
|
|
9
|
+
that has never heard of this library -- is read as a question with the default policy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from agent_wait import WaitPolicy, check_question_size
|
|
18
|
+
from langgraph.types import interrupt
|
|
19
|
+
|
|
20
|
+
WAIT_KEY = "__wait__"
|
|
21
|
+
SOURCE_KEY = "__source__"
|
|
22
|
+
QUESTION_KEY = "question"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def raise_question(question: Any, policy: WaitPolicy, source: Mapping[str, Any] | None) -> Any:
|
|
26
|
+
"""Park the graph on `question`; return the answer, verbatim, on resume."""
|
|
27
|
+
check_question_size(question)
|
|
28
|
+
value: dict[str, Any] = {QUESTION_KEY: question, WAIT_KEY: policy.to_dict()}
|
|
29
|
+
if source:
|
|
30
|
+
value[SOURCE_KEY] = dict(source)
|
|
31
|
+
return interrupt(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def unwrap(value: Any) -> tuple[Any, WaitPolicy, Mapping[str, Any] | None]:
|
|
35
|
+
"""`(question, policy, source)` from an interrupt value of either shape."""
|
|
36
|
+
if isinstance(value, Mapping) and WAIT_KEY in value:
|
|
37
|
+
source = value.get(SOURCE_KEY)
|
|
38
|
+
return (
|
|
39
|
+
value.get(QUESTION_KEY),
|
|
40
|
+
WaitPolicy.from_dict(value.get(WAIT_KEY)),
|
|
41
|
+
dict(source) if isinstance(source, Mapping) else None, # pyright: ignore[reportUnknownArgumentType]
|
|
42
|
+
)
|
|
43
|
+
return value, WaitPolicy(), None
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""`@hitl` -- make a function interruptible.
|
|
2
|
+
|
|
3
|
+
@tool
|
|
4
|
+
@hitl(WaitPolicy(timeout="P1D", default={"action": "reject"}, tags={"approver_group": "finance"}))
|
|
5
|
+
def issue_refund(order_id: str, amount: int) -> str:
|
|
6
|
+
...
|
|
7
|
+
|
|
8
|
+
That is the whole declaration. Calling the decorated function inside a run raises a
|
|
9
|
+
LangGraph interrupt with `{"function": "issue_refund", "args": {...}}` as the question;
|
|
10
|
+
`publish_interrupts()` after the run announces it; the answer comes back as
|
|
11
|
+
`Command(resume={question_id: answer})`, and:
|
|
12
|
+
|
|
13
|
+
{"action": "approve"} -> the body runs with its original args
|
|
14
|
+
{"action": "approve", "args": {...}} -> the body runs with these args instead
|
|
15
|
+
anything else -> the body does NOT run; the answer is
|
|
16
|
+
returned in its place, so a model sees why
|
|
17
|
+
|
|
18
|
+
## Getting the answer itself: the `decision` parameter
|
|
19
|
+
|
|
20
|
+
A tool usually only needs to know whether to run. A node usually needs the answer. If
|
|
21
|
+
the decorated function has a parameter named `decision` (rename it with `decision=`),
|
|
22
|
+
it **always** runs and receives the answer there, approve or not:
|
|
23
|
+
|
|
24
|
+
@hitl(FINANCE)
|
|
25
|
+
def review(state, decision=None):
|
|
26
|
+
return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
|
|
27
|
+
|
|
28
|
+
## `mode="async"` -- the thread does not park
|
|
29
|
+
|
|
30
|
+
@tool
|
|
31
|
+
@hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic), DynamoDbAnnounce(table)])
|
|
32
|
+
def issue_refund(order_id: str, amount: int) -> str: ...
|
|
33
|
+
|
|
34
|
+
The decorator *is* the publisher. The call announces the question through the announcers
|
|
35
|
+
given here and returns `{"status": "pending_approval", "question_id": ...}` without
|
|
36
|
+
running the body. Nothing is parked; nothing is called after the run; the graph carries
|
|
37
|
+
on. The decision arrives later as a **new message** and the graph acts on it however you
|
|
38
|
+
designed it. This is the mode for a single-thread channel like WhatsApp, where the
|
|
39
|
+
approver is not the person on the thread. LangGraph remembers nothing about the
|
|
40
|
+
question; whatever record you keep (a `DynamoDbAnnounce` row is one) is the only one.
|
|
41
|
+
|
|
42
|
+
`question_id` is `sha256(thread | function | args)`, so the same call on the same thread
|
|
43
|
+
is the same question and a consumer sees one, not two.
|
|
44
|
+
|
|
45
|
+
## Not compatible with `HumanInTheLoopMiddleware`
|
|
46
|
+
|
|
47
|
+
LangChain's `create_agent` middleware interrupts *before* a tool is called, in its own
|
|
48
|
+
shape, with its own resume format. Putting `@hitl` on a tool the middleware also
|
|
49
|
+
intercepts interrupts twice -- once by the middleware, then again inside the tool when it
|
|
50
|
+
finally runs. Use one or the other. This library is the other.
|
|
51
|
+
|
|
52
|
+
## One `interrupt()` per node
|
|
53
|
+
|
|
54
|
+
Two `@hitl` functions dispatched by the same `ToolNode` share an interrupt id on
|
|
55
|
+
langgraph 1.2.x (#6626); only one surfaces per run (#6624). Give each interrupting tool
|
|
56
|
+
its own node.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import functools
|
|
62
|
+
import hashlib
|
|
63
|
+
import inspect
|
|
64
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
65
|
+
from typing import Any, Literal
|
|
66
|
+
|
|
67
|
+
from agent_wait import AnnounceAdapter, Question, WaitPolicy, canonical_json, publish
|
|
68
|
+
|
|
69
|
+
from ._interrupt import raise_question
|
|
70
|
+
|
|
71
|
+
Mode = Literal["interrupt", "async"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def question_id_for(thread_id: str, function: str, args: Mapping[str, Any]) -> str:
|
|
75
|
+
"""Deterministic: the same call on the same thread is the same question."""
|
|
76
|
+
return hashlib.sha256(f"{thread_id}|{function}|{canonical_json(dict(args))}".encode()).hexdigest()[:32]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _thread_id() -> str:
|
|
80
|
+
from langgraph.config import get_config
|
|
81
|
+
|
|
82
|
+
thread_id = get_config().get("configurable", {}).get("thread_id")
|
|
83
|
+
if not thread_id:
|
|
84
|
+
raise RuntimeError("@hitl(mode='async') needs a thread_id in the run config")
|
|
85
|
+
return str(thread_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def hitl(
|
|
89
|
+
policy: WaitPolicy | None = None,
|
|
90
|
+
*,
|
|
91
|
+
mode: Mode = "interrupt",
|
|
92
|
+
announce: Sequence[AnnounceAdapter] | None = None,
|
|
93
|
+
decision: str = "decision",
|
|
94
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
95
|
+
"""Make a function interruptible. See the module docstring."""
|
|
96
|
+
resolved = policy or WaitPolicy(allowed_actions=("approve", "reject"))
|
|
97
|
+
if mode == "async" and not announce:
|
|
98
|
+
raise ValueError("@hitl(mode='async') publishes from inside the function, so it needs announce=[...]")
|
|
99
|
+
announcers: Sequence[AnnounceAdapter] = list(announce or ())
|
|
100
|
+
|
|
101
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
102
|
+
name = fn.__name__
|
|
103
|
+
signature = inspect.signature(fn)
|
|
104
|
+
wants_decision = decision in signature.parameters
|
|
105
|
+
source = {"function": name}
|
|
106
|
+
|
|
107
|
+
@functools.wraps(fn)
|
|
108
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
109
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
110
|
+
bound.apply_defaults()
|
|
111
|
+
call_args: dict[str, Any] = {k: v for k, v in bound.arguments.items() if k != decision}
|
|
112
|
+
question = {"function": name, "args": call_args}
|
|
113
|
+
|
|
114
|
+
if mode == "async":
|
|
115
|
+
thread_id = _thread_id()
|
|
116
|
+
qid = question_id_for(thread_id, name, call_args)
|
|
117
|
+
publish([Question(qid, question, resolved, source=source)], thread_id, announcers)
|
|
118
|
+
return {"status": "pending_approval", "question_id": qid, "function": name}
|
|
119
|
+
|
|
120
|
+
answer = raise_question(question, resolved, source)
|
|
121
|
+
if wants_decision:
|
|
122
|
+
return fn(**call_args, **{decision: answer})
|
|
123
|
+
if isinstance(answer, Mapping) and answer.get("action") == "approve":
|
|
124
|
+
edited = answer.get("args")
|
|
125
|
+
merged = {**call_args, **dict(edited)} if isinstance(edited, Mapping) else call_args # pyright: ignore[reportUnknownArgumentType]
|
|
126
|
+
return fn(**merged)
|
|
127
|
+
return answer
|
|
128
|
+
|
|
129
|
+
wrapper.__hitl__ = {"mode": mode, "policy": resolved} # type: ignore[attr-defined]
|
|
130
|
+
return wrapper
|
|
131
|
+
|
|
132
|
+
return decorate
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""`publish_interrupts()` -- announce what `graph.invoke()` just parked on.
|
|
2
|
+
|
|
3
|
+
result = graph.invoke(value, config)
|
|
4
|
+
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
|
|
5
|
+
|
|
6
|
+
When a `@hitl` function is called (or any node calls `interrupt()`), LangGraph returns
|
|
7
|
+
the run's output with an `__interrupt__` entry: a sequence of `Interrupt` objects, each
|
|
8
|
+
with `.id` and `.value` and nothing else (`ns`, `when`, `resumable` were removed in
|
|
9
|
+
langgraph 0.6). This reads that entry and hands one envelope per question to the
|
|
10
|
+
announcers. No `__interrupt__`, nothing happens. `stream()` yields the same shape as a
|
|
11
|
+
chunk, so it works on a chunk.
|
|
12
|
+
|
|
13
|
+
A bare `interrupt(value)` from a node that never heard of this library is published too,
|
|
14
|
+
with `value` as the question and the default policy.
|
|
15
|
+
|
|
16
|
+
Nothing here runs the graph, reads its state, or resumes anything.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from agent_wait import AnnounceAdapter, Clock, EntryPoint, Question, WaitEnvelope, publish
|
|
25
|
+
|
|
26
|
+
from ._interrupt import unwrap
|
|
27
|
+
|
|
28
|
+
INTERRUPT_KEY = "__interrupt__"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def questions_in(result: Any) -> list[Question]:
|
|
32
|
+
"""The questions a run returned, from its `__interrupt__`. `[]` if it did not park."""
|
|
33
|
+
if not isinstance(result, Mapping):
|
|
34
|
+
return []
|
|
35
|
+
raw = result.get(INTERRUPT_KEY) # pyright: ignore[reportUnknownMemberType]
|
|
36
|
+
if not raw:
|
|
37
|
+
return []
|
|
38
|
+
out: list[Question] = []
|
|
39
|
+
for item in raw: # pyright: ignore[reportUnknownVariableType]
|
|
40
|
+
question, policy, source = unwrap(getattr(item, "value", None))
|
|
41
|
+
out.append(Question(str(getattr(item, "id", "")), question, policy, source=source))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def publish_interrupts(
|
|
46
|
+
result: Any,
|
|
47
|
+
thread_id: str,
|
|
48
|
+
announce: Sequence[AnnounceAdapter],
|
|
49
|
+
*,
|
|
50
|
+
reply_to: EntryPoint | None = None,
|
|
51
|
+
clock: Clock | None = None,
|
|
52
|
+
) -> list[WaitEnvelope]:
|
|
53
|
+
"""Announce every question in `result`. Returns the envelopes sent; `[]` if none."""
|
|
54
|
+
questions = questions_in(result)
|
|
55
|
+
if not questions:
|
|
56
|
+
return []
|
|
57
|
+
return publish(questions, thread_id, announce, reply_to=reply_to, clock=clock)
|
langgraph_wait-0.2.1/README.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# langgraph-wait
|
|
2
|
-
|
|
3
|
-
The LangGraph side of [agent-wait](https://pypi.org/project/agent-wait/) — get a LangGraph
|
|
4
|
-
interrupt out of the process, and the answer back in.
|
|
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/).
|
|
@@ -1,14 +0,0 @@
|
|
|
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
|
-
]
|
|
@@ -1,87 +0,0 @@
|
|
|
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
|
|
@@ -1,52 +0,0 @@
|
|
|
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()
|
|
@@ -1,39 +0,0 @@
|
|
|
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")})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|