langgraph-wait 0.3.0__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.3.0 → langgraph_wait-0.4.0}/PKG-INFO +11 -11
- langgraph_wait-0.4.0/README.md +26 -0
- {langgraph_wait-0.3.0 → 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.3.0/README.md +0 -26
- langgraph_wait-0.3.0/src/langgraph_wait/__init__.py +0 -16
- langgraph_wait-0.3.0/src/langgraph_wait/ask.py +0 -57
- langgraph_wait-0.3.0/src/langgraph_wait/hitl.py +0 -146
- langgraph_wait-0.3.0/src/langgraph_wait/publish.py +0 -91
- {langgraph_wait-0.3.0 → langgraph_wait-0.4.0}/.gitignore +0 -0
- {langgraph_wait-0.3.0 → langgraph_wait-0.4.0}/LICENSE +0 -0
- {langgraph_wait-0.3.0 → 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,19 +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
|
-
returns pending without parking
|
|
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
44
|
* **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
|
|
45
45
|
`result["__interrupt__"]` and hands one envelope per question to the announcers.
|
|
46
|
-
Understands `ask()`, bare `interrupt()`, and `HumanInTheLoopMiddleware` batches.
|
|
47
46
|
|
|
48
|
-
Requires `langgraph >= 1.2`.
|
|
49
|
-
|
|
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).
|
|
50
50
|
|
|
51
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.3.0/README.md
DELETED
|
@@ -1,26 +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, hitl, publish_interrupts
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
* **`ask(question, policy)`** — a thin wrapper over `interrupt()`; the policy rides along
|
|
15
|
-
inside the interrupt value. A plain `interrupt(value)` also works, with the default policy.
|
|
16
|
-
* **`@hitl(policy)`** — on a tool function, under `@tool`. Calls `ask()` with `{tool, args}`
|
|
17
|
-
before the tool runs. `mode="async"` makes the decorator the publisher: it announces and
|
|
18
|
-
returns pending without parking the thread.
|
|
19
|
-
* **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
|
|
20
|
-
`result["__interrupt__"]` and hands one envelope per question to the announcers.
|
|
21
|
-
Understands `ask()`, bare `interrupt()`, and `HumanInTheLoopMiddleware` batches.
|
|
22
|
-
|
|
23
|
-
Requires `langgraph >= 1.2`. One `interrupt()` per node — two interrupting tools in one
|
|
24
|
-
`ToolNode` share an id on 1.2.x (langgraph #6626); use the middleware, which batches.
|
|
25
|
-
|
|
26
|
-
Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
"""langgraph-wait: `ask()` or `@hitl` in the graph, `publish_interrupts()` after the run."""
|
|
2
|
-
|
|
3
|
-
from .ask import WAIT_KEY, ask, unwrap
|
|
4
|
-
from .hitl import hitl, policy_for, question_id_for
|
|
5
|
-
from .publish import publish_interrupts, questions_in
|
|
6
|
-
|
|
7
|
-
__all__ = [
|
|
8
|
-
"WAIT_KEY",
|
|
9
|
-
"ask",
|
|
10
|
-
"hitl",
|
|
11
|
-
"policy_for",
|
|
12
|
-
"publish_interrupts",
|
|
13
|
-
"question_id_for",
|
|
14
|
-
"questions_in",
|
|
15
|
-
"unwrap",
|
|
16
|
-
]
|
|
@@ -1,57 +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 collections.abc import Mapping
|
|
23
|
-
from typing import Any
|
|
24
|
-
|
|
25
|
-
from agent_wait import WaitPolicy, check_question_size
|
|
26
|
-
from langgraph.types import interrupt
|
|
27
|
-
|
|
28
|
-
WAIT_KEY = "__wait__"
|
|
29
|
-
SOURCE_KEY = "__source__"
|
|
30
|
-
QUESTION_KEY = "question"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def ask(question: Any, policy: WaitPolicy | None = None, *, source: Mapping[str, Any] | None = None) -> Any:
|
|
34
|
-
"""Park the graph on `question` and return the answer when it arrives.
|
|
35
|
-
|
|
36
|
-
On the first pass this raises through `interrupt()` and the node does not return. On
|
|
37
|
-
resume it returns the answer message's `answer` field, verbatim -- whatever the
|
|
38
|
-
consumer put there. The library does not wrap it, merge into it or add an action to
|
|
39
|
-
it; what was sent is what the node sees.
|
|
40
|
-
"""
|
|
41
|
-
check_question_size(question)
|
|
42
|
-
resolved = policy or WaitPolicy()
|
|
43
|
-
value: dict[str, Any] = {QUESTION_KEY: question, WAIT_KEY: resolved.to_dict()}
|
|
44
|
-
if source:
|
|
45
|
-
value[SOURCE_KEY] = dict(source)
|
|
46
|
-
return interrupt(value)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def unwrap(value: Any) -> tuple[Any, WaitPolicy]:
|
|
50
|
-
"""Split an interrupt value back into `(question, policy)`.
|
|
51
|
-
|
|
52
|
-
Handles both shapes: one raised by `ask()`, and a bare `interrupt(value)` from a
|
|
53
|
-
graph that has never heard of this library.
|
|
54
|
-
"""
|
|
55
|
-
if isinstance(value, dict) and WAIT_KEY in value:
|
|
56
|
-
return value.get(QUESTION_KEY), WaitPolicy.from_dict(value.get(WAIT_KEY))
|
|
57
|
-
return value, WaitPolicy()
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
"""`@hitl` -- mark a tool as needing a human, in one of two ways.
|
|
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
|
-
Apply it *under* `@tool`, on the function. It registers the policy against the tool's
|
|
9
|
-
name (so `publish_interrupts` can find it for middleware-batched interrupts too) and
|
|
10
|
-
wraps the call in one of two behaviours.
|
|
11
|
-
|
|
12
|
-
## `mode="interrupt"` (default) -- the thread parks
|
|
13
|
-
|
|
14
|
-
The wrapper calls `ask()` with `{"tool": name, "args": {...}}` before running the tool.
|
|
15
|
-
The graph stops; `publish_interrupts()` after the run announces it; the answer comes back
|
|
16
|
-
as `Command(resume={question_id: decision})` and the tool runs, or does not:
|
|
17
|
-
|
|
18
|
-
{"action": "approve"} -> the tool runs with its original args
|
|
19
|
-
{"action": "approve", "args": {...}} -> the tool runs with these args instead
|
|
20
|
-
anything else -> the tool does NOT run; the decision is
|
|
21
|
-
returned as the tool's result, so the
|
|
22
|
-
model sees why
|
|
23
|
-
|
|
24
|
-
One `interrupt()` per node still applies (langgraph #6626): two `@hitl` tools dispatched
|
|
25
|
-
by the same `ToolNode` share an interrupt id. Use `HumanInTheLoopMiddleware`, which
|
|
26
|
-
batches them into one interrupt, or give each its own node.
|
|
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. When the tool is called it announces the question
|
|
35
|
-
through the announcers given here and returns `{"status": "pending_approval",
|
|
36
|
-
"question_id": ...}` without running the tool body. The graph carries on; the model sees
|
|
37
|
-
that the action is pending; there is no `__interrupt__` and nothing to call after the run.
|
|
38
|
-
The decision arrives later as a **new message** to the agent, carrying the `question_id`
|
|
39
|
-
-- how, and what the graph does with it, is yours.
|
|
40
|
-
|
|
41
|
-
Nothing is parked, so nothing in LangGraph remembers the question. Whatever record you
|
|
42
|
-
keep (a `DynamoDbAnnounce` table is one) is the only one. This is the right mode when the
|
|
43
|
-
person answering is not the person on the thread -- a customer chatting on WhatsApp while
|
|
44
|
-
finance approves in a dashboard -- and it should be a visible choice, not a default.
|
|
45
|
-
|
|
46
|
-
`question_id` is derived from the thread, the tool and its arguments, so the same call
|
|
47
|
-
on the same thread produces the same id: a consumer sees one question, not two. Pass
|
|
48
|
-
`question_id=` to override. The thread id comes from the run config, where LangGraph
|
|
49
|
-
keeps it.
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
from __future__ import annotations
|
|
53
|
-
|
|
54
|
-
import functools
|
|
55
|
-
import hashlib
|
|
56
|
-
import inspect
|
|
57
|
-
import json
|
|
58
|
-
from collections.abc import Callable, Mapping, Sequence
|
|
59
|
-
from typing import Any, Literal
|
|
60
|
-
|
|
61
|
-
from agent_wait import AnnounceAdapter, Question, WaitPolicy, canonical_json, publish
|
|
62
|
-
|
|
63
|
-
from .ask import ask
|
|
64
|
-
|
|
65
|
-
Mode = Literal["interrupt", "async"]
|
|
66
|
-
|
|
67
|
-
_REGISTRY: dict[str, WaitPolicy] = {}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def policy_for(tool_name: str) -> WaitPolicy | None:
|
|
71
|
-
"""The policy `@hitl` registered for a tool, or None."""
|
|
72
|
-
return _REGISTRY.get(tool_name)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def question_id_for(thread_id: str, tool: str, args: Mapping[str, Any]) -> str:
|
|
76
|
-
"""Deterministic: the same call on the same thread is the same question."""
|
|
77
|
-
return hashlib.sha256(f"{thread_id}|{tool}|{canonical_json(dict(args))}".encode()).hexdigest()[:32]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _thread_id() -> str:
|
|
81
|
-
from langgraph.config import get_config
|
|
82
|
-
|
|
83
|
-
configurable = get_config().get("configurable", {})
|
|
84
|
-
thread_id = configurable.get("thread_id")
|
|
85
|
-
if not thread_id:
|
|
86
|
-
raise RuntimeError("@hitl(mode='async') needs a thread_id in the run config")
|
|
87
|
-
return str(thread_id)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def hitl(
|
|
91
|
-
policy: WaitPolicy | None = None,
|
|
92
|
-
*,
|
|
93
|
-
mode: Mode = "interrupt",
|
|
94
|
-
announce: Sequence[AnnounceAdapter] | None = None,
|
|
95
|
-
question_id: Callable[[str, str, Mapping[str, Any]], str] | None = None,
|
|
96
|
-
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
97
|
-
resolved = policy or WaitPolicy(allowed_actions=("approve", "reject"))
|
|
98
|
-
if mode == "async" and not announce:
|
|
99
|
-
raise ValueError("@hitl(mode='async') publishes from inside the tool, so it needs announce=[...]")
|
|
100
|
-
announcers: Sequence[AnnounceAdapter] = list(announce or ())
|
|
101
|
-
|
|
102
|
-
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
103
|
-
name = fn.__name__
|
|
104
|
-
_REGISTRY[name] = resolved
|
|
105
|
-
signature = inspect.signature(fn)
|
|
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] = dict(bound.arguments)
|
|
112
|
-
question = {"tool": name, "args": call_args}
|
|
113
|
-
|
|
114
|
-
if mode == "interrupt":
|
|
115
|
-
decision = ask(question, resolved, source={"tool": name})
|
|
116
|
-
return _apply(decision, fn, call_args)
|
|
117
|
-
|
|
118
|
-
thread_id = _thread_id()
|
|
119
|
-
qid = (question_id or question_id_for)(thread_id, name, call_args)
|
|
120
|
-
publish(
|
|
121
|
-
[Question(question_id=qid, question=question, policy=resolved, source={"tool": name})],
|
|
122
|
-
thread_id,
|
|
123
|
-
announcers,
|
|
124
|
-
)
|
|
125
|
-
return {"status": "pending_approval", "question_id": qid, "tool": name}
|
|
126
|
-
|
|
127
|
-
wrapper.__hitl__ = {"mode": mode, "policy": resolved} # type: ignore[attr-defined]
|
|
128
|
-
return wrapper
|
|
129
|
-
|
|
130
|
-
return decorate
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _apply(decision: Any, fn: Callable[..., Any], call_args: Mapping[str, Any]) -> Any:
|
|
134
|
-
"""Run the tool if the decision says so; otherwise hand the decision back as the result."""
|
|
135
|
-
if isinstance(decision, Mapping) and decision.get("action") == "approve":
|
|
136
|
-
edited = decision.get("args")
|
|
137
|
-
merged = {**call_args, **dict(edited)} if isinstance(edited, Mapping) else dict(call_args) # pyright: ignore[reportUnknownArgumentType]
|
|
138
|
-
return fn(**merged)
|
|
139
|
-
if isinstance(decision, Mapping):
|
|
140
|
-
return {"status": "not_executed", **dict(decision)} # pyright: ignore[reportUnknownArgumentType]
|
|
141
|
-
return {
|
|
142
|
-
"status": "not_executed",
|
|
143
|
-
"decision": decision
|
|
144
|
-
if isinstance(decision, str | int | float | bool | type(None))
|
|
145
|
-
else json.loads(json.dumps(decision, default=str)),
|
|
146
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
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 node calls `interrupt()`, LangGraph returns the run's output with an
|
|
7
|
-
`__interrupt__` entry: a sequence of `Interrupt` objects, each with `.id` and `.value`
|
|
8
|
-
(nothing else -- `ns`, `when`, `resumable` were removed in langgraph 0.6). This reads
|
|
9
|
-
that entry and hands one envelope per question to the announcers. No `__interrupt__`,
|
|
10
|
-
nothing happens. `stream()` yields the same shape as a chunk, so it works on a chunk.
|
|
11
|
-
|
|
12
|
-
## Three interrupt shapes are understood
|
|
13
|
-
|
|
14
|
-
1. **`ask()`** -- `.value` is `{"question": ..., "__wait__": policy}`. One question.
|
|
15
|
-
2. **A bare `interrupt(value)`** -- one question, `value` verbatim, default policy.
|
|
16
|
-
3. **`HumanInTheLoopMiddleware`** (`langchain.agents`) -- `.value` is an `HITLRequest`:
|
|
17
|
-
`{"action_requests": [{name, args, description}], "review_configs": [...]}`. The
|
|
18
|
-
middleware batches every tool call needing review into ONE interrupt, so this is one
|
|
19
|
-
question whose payload is the whole batch, and whose answer is the middleware's
|
|
20
|
-
`HITLResponse`: `{"decisions": [{type: approve|reject|edit|respond, ...}, ...]}` in
|
|
21
|
-
batch order. The policy comes from `@hitl` on the first tool in the batch, if any.
|
|
22
|
-
|
|
23
|
-
Nothing here runs the graph, reads its state, or resumes anything. The answer's shape
|
|
24
|
-
and its return path are yours; the envelope's `reply_with` stub carries `question_id`,
|
|
25
|
-
and `Command(resume={question_id: answer})` is how LangGraph takes it.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
from collections.abc import Mapping, Sequence
|
|
31
|
-
from typing import Any
|
|
32
|
-
|
|
33
|
-
from agent_wait import AnnounceAdapter, Clock, EntryPoint, Question, WaitEnvelope, WaitPolicy, publish
|
|
34
|
-
|
|
35
|
-
from .ask import unwrap
|
|
36
|
-
from .hitl import policy_for
|
|
37
|
-
|
|
38
|
-
INTERRUPT_KEY = "__interrupt__"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _is_hitl_request(value: Any) -> bool:
|
|
42
|
-
return isinstance(value, Mapping) and "action_requests" in value and "review_configs" in value
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _from_hitl_request(interrupt_id: str, value: Mapping[str, Any]) -> Question:
|
|
46
|
-
actions: list[Mapping[str, Any]] = list(value.get("action_requests") or [])
|
|
47
|
-
names = [str(a.get("name", "")) for a in actions]
|
|
48
|
-
policy = policy_for(names[0]) if names else None
|
|
49
|
-
return Question(
|
|
50
|
-
question_id=interrupt_id,
|
|
51
|
-
question={"actions": actions, "review": list(value.get("review_configs") or [])},
|
|
52
|
-
policy=policy or WaitPolicy(allowed_actions=("approve", "reject", "edit", "respond")),
|
|
53
|
-
source={"tools": names, "via": "HumanInTheLoopMiddleware"},
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def questions_in(result: Any) -> list[Question]:
|
|
58
|
-
"""The questions a run returned, from its `__interrupt__`. `[]` if it did not park."""
|
|
59
|
-
if not isinstance(result, Mapping):
|
|
60
|
-
return []
|
|
61
|
-
raw = result.get(INTERRUPT_KEY) # pyright: ignore[reportUnknownMemberType]
|
|
62
|
-
if not raw:
|
|
63
|
-
return []
|
|
64
|
-
out: list[Question] = []
|
|
65
|
-
for item in raw: # pyright: ignore[reportUnknownVariableType]
|
|
66
|
-
interrupt_id = str(getattr(item, "id", ""))
|
|
67
|
-
value = getattr(item, "value", None)
|
|
68
|
-
if _is_hitl_request(value):
|
|
69
|
-
out.append(_from_hitl_request(interrupt_id, value))
|
|
70
|
-
continue
|
|
71
|
-
question, policy = unwrap(value)
|
|
72
|
-
source = None
|
|
73
|
-
if isinstance(value, Mapping) and isinstance(value.get("__source__"), Mapping):
|
|
74
|
-
source = dict(value["__source__"]) # pyright: ignore[reportUnknownArgumentType]
|
|
75
|
-
out.append(Question(question_id=interrupt_id, question=question, policy=policy, source=source))
|
|
76
|
-
return out
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def publish_interrupts(
|
|
80
|
-
result: Any,
|
|
81
|
-
thread_id: str,
|
|
82
|
-
announce: Sequence[AnnounceAdapter],
|
|
83
|
-
*,
|
|
84
|
-
reply_to: EntryPoint | None = None,
|
|
85
|
-
clock: Clock | None = None,
|
|
86
|
-
) -> list[WaitEnvelope]:
|
|
87
|
-
"""Announce every question in `result`. Returns the envelopes sent; `[]` if none."""
|
|
88
|
-
questions = questions_in(result)
|
|
89
|
-
if not questions:
|
|
90
|
-
return []
|
|
91
|
-
return publish(questions, thread_id, announce, reply_to=reply_to, clock=clock)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|