langgraph-wait 0.2.1__tar.gz → 0.3.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.3.0}/PKG-INFO +12 -14
- langgraph_wait-0.3.0/README.md +26 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.3.0}/pyproject.toml +2 -2
- langgraph_wait-0.3.0/src/langgraph_wait/__init__.py +16 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.3.0}/src/langgraph_wait/ask.py +7 -2
- langgraph_wait-0.3.0/src/langgraph_wait/hitl.py +146 -0
- langgraph_wait-0.3.0/src/langgraph_wait/publish.py +91 -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/resume.py +0 -39
- {langgraph_wait-0.2.1 → langgraph_wait-0.3.0}/.gitignore +0 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.3.0}/LICENSE +0 -0
- {langgraph_wait-0.2.1 → langgraph_wait-0.3.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.3.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.4,>=0.3
|
|
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 ask,
|
|
36
|
+
from langgraph_wait import ask, hitl, publish_interrupts
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
* **`ask(question, policy)`** — a thin wrapper over `interrupt()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
parallel interrupts resume independently.
|
|
39
|
+
* **`ask(question, policy)`** — a thin wrapper over `interrupt()`; the policy rides along
|
|
40
|
+
inside the interrupt value. A plain `interrupt(value)` also works, with the default policy.
|
|
41
|
+
* **`@hitl(policy)`** — on a tool function, under `@tool`. Calls `ask()` with `{tool, args}`
|
|
42
|
+
before the tool runs. `mode="async"` makes the decorator the publisher: it announces and
|
|
43
|
+
returns pending without parking the thread.
|
|
44
|
+
* **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
|
|
45
|
+
`result["__interrupt__"]` and hands one envelope per question to the announcers.
|
|
46
|
+
Understands `ask()`, bare `interrupt()`, and `HumanInTheLoopMiddleware` batches.
|
|
48
47
|
|
|
49
48
|
Requires `langgraph >= 1.2`. One `interrupt()` per node — two interrupting tools in one
|
|
50
|
-
`ToolNode` share an id on 1.2.x (langgraph #6626)
|
|
51
|
-
than hides that.
|
|
49
|
+
`ToolNode` share an id on 1.2.x (langgraph #6626); use the middleware, which batches.
|
|
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 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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "langgraph-wait"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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.3,<0.4", "langgraph>=1.2,<2"]
|
|
22
22
|
|
|
23
23
|
[project.urls]
|
|
24
24
|
Homepage = "https://skamalj.github.io/agent-wait/"
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
]
|
|
@@ -19,16 +19,18 @@ default policy, so a graph that already interrupts gets published without any ed
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
+
from collections.abc import Mapping
|
|
22
23
|
from typing import Any
|
|
23
24
|
|
|
24
25
|
from agent_wait import WaitPolicy, check_question_size
|
|
25
26
|
from langgraph.types import interrupt
|
|
26
27
|
|
|
27
28
|
WAIT_KEY = "__wait__"
|
|
29
|
+
SOURCE_KEY = "__source__"
|
|
28
30
|
QUESTION_KEY = "question"
|
|
29
31
|
|
|
30
32
|
|
|
31
|
-
def ask(question: Any, policy: WaitPolicy | None = None) -> Any:
|
|
33
|
+
def ask(question: Any, policy: WaitPolicy | None = None, *, source: Mapping[str, Any] | None = None) -> Any:
|
|
32
34
|
"""Park the graph on `question` and return the answer when it arrives.
|
|
33
35
|
|
|
34
36
|
On the first pass this raises through `interrupt()` and the node does not return. On
|
|
@@ -38,7 +40,10 @@ def ask(question: Any, policy: WaitPolicy | None = None) -> Any:
|
|
|
38
40
|
"""
|
|
39
41
|
check_question_size(question)
|
|
40
42
|
resolved = policy or WaitPolicy()
|
|
41
|
-
|
|
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)
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
def unwrap(value: Any) -> tuple[Any, WaitPolicy]:
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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)
|
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,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
|