langgraph-wait 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langgraph-wait
3
- Version: 0.2.1
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.3,>=0.2
22
+ Requires-Dist: agent-wait<0.5,>=0.4
23
23
  Requires-Dist: langgraph<2,>=1.2
24
24
  Description-Content-Type: text/markdown
25
25
 
@@ -33,21 +33,19 @@ pip install langgraph-wait # pulls in agent-wait
33
33
  ```
34
34
 
35
35
  ```python
36
- from langgraph_wait import ask, LangGraphAdapter, is_answer, resume_command
36
+ from langgraph_wait import hitl, publish_interrupts
37
37
  ```
38
38
 
39
- * **`ask(question, policy)`** — a thin wrapper over `interrupt()`. The node pauses exactly
40
- as LangGraph pauses; the policy rides along inside the interrupt value and comes back
41
- out in the published envelope. A plain `interrupt(value)` also works, with the default
42
- policy.
43
- * **`LangGraphAdapter(graph)`** — three methods. `pending()` reads what a thread is parked
44
- on and filters LangGraph's `tasks[*].interrupts` over-report on `task.result`.
45
- * **`is_answer(message)`** / **`resume_command(message)`** — pure functions for the host's
46
- router. `interrupt_id` present means resume; the `Command` is keyed by interrupt id so
47
- parallel interrupts resume independently.
39
+ * **`@hitl(policy)`** — on any function, under `@tool` if it is a tool. Calling it inside a
40
+ run parks the graph on `{"function", "args"}`. `{"action": "approve"}` runs the body;
41
+ anything else is returned in its place. A function with a `decision` parameter always
42
+ runs and receives the answer. `mode="async"` makes the decorator the publisher: it
43
+ announces and returns pending without parking.
44
+ * **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
45
+ `result["__interrupt__"]` and hands one envelope per question to the announcers.
48
46
 
49
- Requires `langgraph >= 1.2`. One `interrupt()` per node — two interrupting tools in one
50
- `ToolNode` share an id on 1.2.x (langgraph #6626), and this package documents rather
51
- than hides that.
47
+ Requires `langgraph >= 1.2`. Not compatible with LangChain's `HumanInTheLoopMiddleware`
48
+ on the same tool (two interrupts for one approval). One `interrupt()` per node on 1.2.x
49
+ (langgraph #6626).
52
50
 
53
51
  Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -0,0 +1,26 @@
1
+ # langgraph-wait
2
+
3
+ The LangGraph side of [agent-wait](https://pypi.org/project/agent-wait/) — get a LangGraph
4
+ interrupt out of the process, and the answer back in.
5
+
6
+ ```bash
7
+ pip install langgraph-wait # pulls in agent-wait
8
+ ```
9
+
10
+ ```python
11
+ from langgraph_wait import hitl, publish_interrupts
12
+ ```
13
+
14
+ * **`@hitl(policy)`** — on any function, under `@tool` if it is a tool. Calling it inside a
15
+ run parks the graph on `{"function", "args"}`. `{"action": "approve"}` runs the body;
16
+ anything else is returned in its place. A function with a `decision` parameter always
17
+ runs and receives the answer. `mode="async"` makes the decorator the publisher: it
18
+ announces and returns pending without parking.
19
+ * **`publish_interrupts(result, thread_id, announce)`** — after `graph.invoke()`. Reads
20
+ `result["__interrupt__"]` and hands one envelope per question to the announcers.
21
+
22
+ Requires `langgraph >= 1.2`. Not compatible with LangChain's `HumanInTheLoopMiddleware`
23
+ on the same tool (two interrupts for one approval). One `interrupt()` per node on 1.2.x
24
+ (langgraph #6626).
25
+
26
+ Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langgraph-wait"
3
- version = "0.2.1"
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.2,<0.3", "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,28 +0,0 @@
1
- # langgraph-wait
2
-
3
- The LangGraph side of [agent-wait](https://pypi.org/project/agent-wait/) — get a LangGraph
4
- interrupt out of the process, and the answer back in.
5
-
6
- ```bash
7
- pip install langgraph-wait # pulls in agent-wait
8
- ```
9
-
10
- ```python
11
- from langgraph_wait import ask, LangGraphAdapter, is_answer, resume_command
12
- ```
13
-
14
- * **`ask(question, policy)`** — a thin wrapper over `interrupt()`. The node pauses exactly
15
- as LangGraph pauses; the policy rides along inside the interrupt value and comes back
16
- out in the published envelope. A plain `interrupt(value)` also works, with the default
17
- policy.
18
- * **`LangGraphAdapter(graph)`** — three methods. `pending()` reads what a thread is parked
19
- on and filters LangGraph's `tasks[*].interrupts` over-report on `task.result`.
20
- * **`is_answer(message)`** / **`resume_command(message)`** — pure functions for the host's
21
- router. `interrupt_id` present means resume; the `Command` is keyed by interrupt id so
22
- parallel interrupts resume independently.
23
-
24
- Requires `langgraph >= 1.2`. One `interrupt()` per node — two interrupting tools in one
25
- `ToolNode` share an id on 1.2.x (langgraph #6626), and this package documents rather
26
- than hides that.
27
-
28
- Full documentation: [skamalj.github.io/agent-wait](https://skamalj.github.io/agent-wait/).
@@ -1,14 +0,0 @@
1
- """langgraph-wait: the LangGraph side of agent-wait."""
2
-
3
- from .adapter import LangGraphAdapter
4
- from .ask import WAIT_KEY, ask, unwrap
5
- from .resume import is_answer, resume_command
6
-
7
- __all__ = [
8
- "WAIT_KEY",
9
- "LangGraphAdapter",
10
- "ask",
11
- "is_answer",
12
- "resume_command",
13
- "unwrap",
14
- ]
@@ -1,87 +0,0 @@
1
- """`LangGraphAdapter` -- three methods, and the one LangGraph bug that matters.
2
-
3
- The core never imports LangGraph. Everything framework-specific lives here, which is also
4
- where the framework's rough edges get handled.
5
-
6
- ## `get_state().tasks[*].interrupts` over-reports
7
-
8
- This is the finding the whole adapter is shaped around (langgraph #4796 / #6792,
9
- reproduced against 1.2.11 in `tests/test_spike_langgraph.py`). With two parallel
10
- interrupts, resume one, and the *finished* task still lists its interrupt id. Anyone
11
- building "what is this thread waiting on?" from `tasks[*].interrupts` alone gets an
12
- interrupt the graph has already moved past -- and the failure mode is republishing an
13
- approval button for a node that already ran, or resuming it a second time.
14
-
15
- The discriminator is `task.result`: it holds the finished task's return value and is
16
- `None` while the task is genuinely parked. `pending()` filters on it, and the spike test
17
- pins the behaviour so a future LangGraph fix shows up as a failing test rather than as
18
- silence.
19
-
20
- ## What else was verified empirically, against langgraph 1.2.11
21
-
22
- * **`Interrupt.id` is stable** across `invoke(None, config)` re-entry and across
23
- resume-from-checkpoint. This is what makes `dedupe_key` work: a republished envelope
24
- carries the same id, so consumers can discard it.
25
- * **Interrupts raised inside a subgraph** surface on the parent's state against the
26
- subgraph node's task, with a stable id. `subgraphs=True` was not needed, and resuming
27
- by id works through the parent.
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- from datetime import datetime
33
- from typing import Any
34
-
35
- from agent_wait import PendingInterrupt
36
-
37
- from .ask import unwrap
38
-
39
-
40
- class LangGraphAdapter:
41
- """Wraps a compiled graph. Requires langgraph >= 1.2."""
42
-
43
- name = "langgraph"
44
-
45
- def __init__(self, graph: Any) -> None:
46
- self.graph = graph
47
-
48
- def config_for(self, thread_id: str) -> dict[str, Any]:
49
- return {"configurable": {"thread_id": thread_id}}
50
-
51
- def invoke(self, value: Any, config: Any) -> Any:
52
- """Run the graph. `value` is the caller's -- fresh input, or a `Command`."""
53
- return self.graph.invoke(value, config)
54
-
55
- def pending(self, thread_id: str) -> list[PendingInterrupt]:
56
- """What this thread is parked on right now. `[]` if it has never run."""
57
- state = self.graph.get_state(self.config_for(thread_id))
58
- asked_at = _epoch(getattr(state, "created_at", None))
59
-
60
- out: list[PendingInterrupt] = []
61
- for task in getattr(state, "tasks", ()) or ():
62
- # See the module docstring. A task that has produced a result is finished,
63
- # whatever its `interrupts` list still claims.
64
- if getattr(task, "result", None) is not None:
65
- continue
66
- for raw in getattr(task, "interrupts", ()) or ():
67
- question, policy = unwrap(getattr(raw, "value", None))
68
- out.append(
69
- PendingInterrupt(
70
- interrupt_id=str(raw.id),
71
- question=question,
72
- policy=policy,
73
- asked_at=asked_at,
74
- )
75
- )
76
- return out
77
-
78
-
79
- def _epoch(created_at: Any) -> float | None:
80
- """LangGraph stamps checkpoints with an ISO-8601 string. Anchor `expires_at` to it
81
- so a republished question keeps its original deadline."""
82
- if not isinstance(created_at, str):
83
- return None
84
- try:
85
- return datetime.fromisoformat(created_at).timestamp()
86
- except ValueError:
87
- return None
@@ -1,52 +0,0 @@
1
- """`ask()` -- the one line a graph author writes.
2
-
3
- decision = ask({"kind": "refund_approval", "amount": amount},
4
- policy=WaitPolicy(timeout="P3D", allowed_actions=("approve", "reject")))
5
-
6
- It is a thin wrapper over `langgraph.types.interrupt()`, and thin is the point: the node
7
- still pauses exactly the way LangGraph pauses, the checkpointer still does its job, and
8
- nothing about the graph changes. What `ask()` adds is the *policy* -- how long this
9
- question should stand, what to assume if nobody answers, which answers are meaningful --
10
- carried inside the interrupt value under `__wait__`.
11
-
12
- That key is the only channel available. An interrupt has one payload and LangGraph does
13
- not offer a side channel for metadata, so the policy rides with the question and
14
- `unwrap()` peels it back off.
15
-
16
- A plain `interrupt(value)` with no `ask()` still works. It is read as a question with the
17
- default policy, so a graph that already interrupts gets published without any edit at all.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- from typing import Any
23
-
24
- from agent_wait import WaitPolicy, check_question_size
25
- from langgraph.types import interrupt
26
-
27
- WAIT_KEY = "__wait__"
28
- QUESTION_KEY = "question"
29
-
30
-
31
- def ask(question: Any, policy: WaitPolicy | None = None) -> Any:
32
- """Park the graph on `question` and return the answer when it arrives.
33
-
34
- On the first pass this raises through `interrupt()` and the node does not return. On
35
- resume it returns the answer message's `answer` field, verbatim -- whatever the
36
- consumer put there. The library does not wrap it, merge into it or add an action to
37
- it; what was sent is what the node sees.
38
- """
39
- check_question_size(question)
40
- resolved = policy or WaitPolicy()
41
- return interrupt({QUESTION_KEY: question, WAIT_KEY: resolved.to_dict()})
42
-
43
-
44
- def unwrap(value: Any) -> tuple[Any, WaitPolicy]:
45
- """Split an interrupt value back into `(question, policy)`.
46
-
47
- Handles both shapes: one raised by `ask()`, and a bare `interrupt(value)` from a
48
- graph that has never heard of this library.
49
- """
50
- if isinstance(value, dict) and WAIT_KEY in value:
51
- return value.get(QUESTION_KEY), WaitPolicy.from_dict(value.get(WAIT_KEY))
52
- return value, WaitPolicy()
@@ -1,39 +0,0 @@
1
- """`resume_command()` -- turn an answer message into the thing you pass to `invoke()`.
2
-
3
- A pure function. It makes no decisions, reads nothing and writes nothing; it exists so
4
- that the one LangGraph-specific detail in the inbound message format -- that a resume is
5
- keyed by interrupt id, so that parallel interrupts resume independently -- is written
6
- down once instead of in every consumer.
7
-
8
- def handle(message):
9
- if is_answer(message):
10
- return agent.invoke(resume_command(message), message["thread_id"])
11
- return agent.invoke(message["input"], message["thread_id"])
12
-
13
- `Command(resume=value)` -- the un-keyed form -- hands the same value to *every* parked
14
- interrupt on the thread. With two approvals outstanding that is one click approving both,
15
- which is why this builds the dict form even when there is only one.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- from collections.abc import Mapping
21
- from typing import Any
22
-
23
- from langgraph.types import Command
24
-
25
-
26
- def is_answer(message: Mapping[str, Any]) -> bool:
27
- """`interrupt_id` present means resume; absent means start. That is the whole rule.
28
-
29
- It holds because the publisher ships a filled-in `reply_with` stub in every envelope
30
- and the consumer echoes it back, so the key is there by construction rather than by
31
- the consumer remembering to add it.
32
- """
33
- return bool(message.get("interrupt_id"))
34
-
35
-
36
- def resume_command(message: Mapping[str, Any]) -> Command:
37
- """Build the resume for one answer message. Raises `KeyError` if it is a start."""
38
- interrupt_id = message["interrupt_id"]
39
- return Command(resume={str(interrupt_id): message.get("answer")})
File without changes