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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langgraph-wait
3
- Version: 0.2.1
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.3,>=0.2
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, LangGraphAdapter, is_answer, resume_command
36
+ from langgraph_wait import ask, 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
+ * **`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), and this package documents rather
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.2.1"
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.2,<0.3", "langgraph>=1.2,<2"]
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
- return interrupt({QUESTION_KEY: question, WAIT_KEY: resolved.to_dict()})
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)
@@ -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