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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langgraph-wait
3
- Version: 0.3.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.4,>=0.3
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 ask, hitl, publish_interrupts
36
+ from langgraph_wait import hitl, publish_interrupts
37
37
  ```
38
38
 
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.
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`. One `interrupt()` per node — two interrupting tools in one
49
- `ToolNode` share an id on 1.2.x (langgraph #6626); use the middleware, which batches.
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.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.3,<0.4", "langgraph>=1.2,<2"]
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,6 @@
1
+ """langgraph-wait: `@hitl` in the graph, `publish_interrupts()` after the run."""
2
+
3
+ from .hitl import hitl
4
+ from .publish import publish_interrupts, questions_in
5
+
6
+ __all__ = ["hitl", "publish_interrupts", "questions_in"]
@@ -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)
@@ -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