agentapprovalprotocol 0.1.0__py3-none-any.whl
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.
- agentapprovalprotocol/__init__.py +33 -0
- agentapprovalprotocol/_client.py +201 -0
- agentapprovalprotocol/_gate.py +310 -0
- agentapprovalprotocol/_wire.py +229 -0
- agentapprovalprotocol/langgraph.py +255 -0
- agentapprovalprotocol/py.typed +0 -0
- agentapprovalprotocol/pydantic_ai.py +255 -0
- agentapprovalprotocol/receiver.py +168 -0
- agentapprovalprotocol/testing.py +173 -0
- agentapprovalprotocol-0.1.0.dist-info/METADATA +96 -0
- agentapprovalprotocol-0.1.0.dist-info/RECORD +12 -0
- agentapprovalprotocol-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Strict parsing of AAP wire objects.
|
|
2
|
+
|
|
3
|
+
Protocol objects reject unknown fields, nulls and duplicate keys. Tool
|
|
4
|
+
arguments and runtime context keep arbitrary JSON. Numbers are parsed as
|
|
5
|
+
Decimal so a response can be compared exactly with the submission.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from typing import Any, Iterable, Mapping
|
|
17
|
+
|
|
18
|
+
PENDING = "pending"
|
|
19
|
+
APPROVED = "approved"
|
|
20
|
+
DENIED = "denied"
|
|
21
|
+
EXPIRED = "expired"
|
|
22
|
+
CANCELLED = "cancelled"
|
|
23
|
+
TERMINAL = frozenset({APPROVED, DENIED, EXPIRED, CANCELLED})
|
|
24
|
+
OUTCOMES = frozenset({"succeeded", "failed"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProtocolError(Exception):
|
|
28
|
+
"""The provider sent a response that does not satisfy the AAP contract."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Outcome:
|
|
33
|
+
status: str
|
|
34
|
+
reported_at: datetime
|
|
35
|
+
note: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Consumption:
|
|
40
|
+
consumed_at: datetime
|
|
41
|
+
outcome: Outcome | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Decision:
|
|
46
|
+
status: str
|
|
47
|
+
decided_at: datetime
|
|
48
|
+
note: str | None = None
|
|
49
|
+
expires_at: datetime | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Request:
|
|
54
|
+
id: str
|
|
55
|
+
tool: str
|
|
56
|
+
arguments: Mapping[str, Any]
|
|
57
|
+
timeout: str
|
|
58
|
+
status: str
|
|
59
|
+
created_at: datetime
|
|
60
|
+
deadline_at: datetime
|
|
61
|
+
server: str | None = None
|
|
62
|
+
agent_reasoning: str | None = None
|
|
63
|
+
context: Mapping[str, Any] | None = None
|
|
64
|
+
decision: Decision | None = None
|
|
65
|
+
consumption: Consumption | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def loads(raw: bytes | str) -> Any:
|
|
69
|
+
"""Parse JSON, rejecting duplicate keys and keeping numbers exact."""
|
|
70
|
+
|
|
71
|
+
def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
72
|
+
out: dict[str, Any] = {}
|
|
73
|
+
for key, value in items:
|
|
74
|
+
if key in out:
|
|
75
|
+
raise ProtocolError("duplicate JSON key")
|
|
76
|
+
out[key] = value
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(raw, object_pairs_hook=pairs, parse_float=Decimal)
|
|
81
|
+
except (ValueError, TypeError) as err:
|
|
82
|
+
raise ProtocolError("invalid JSON") from err
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def dumps(value: Any) -> bytes:
|
|
86
|
+
"""Serialize a submission. Key order is stable so retries send the same bytes."""
|
|
87
|
+
|
|
88
|
+
def default(item: Any) -> Any:
|
|
89
|
+
if isinstance(item, Decimal):
|
|
90
|
+
return float(item) if item != item.to_integral_value() else int(item)
|
|
91
|
+
raise TypeError(f"{type(item).__name__} is not JSON serializable")
|
|
92
|
+
|
|
93
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True, ensure_ascii=False, default=default, allow_nan=False).encode()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def same(a: Any, b: Any) -> bool:
|
|
97
|
+
"""JSON equality that keeps booleans, numbers and strings distinct."""
|
|
98
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
99
|
+
return type(a) is type(b) and a == b
|
|
100
|
+
if isinstance(a, (int, Decimal)) and isinstance(b, (int, Decimal)):
|
|
101
|
+
return Decimal(a) == Decimal(b)
|
|
102
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
103
|
+
return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a)
|
|
104
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
105
|
+
return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b))
|
|
106
|
+
return type(a) is type(b) and a == b
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_TIMESTAMP = re.compile(r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(\.\d+)?Z$")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def timestamp(value: Any) -> datetime:
|
|
113
|
+
match = _TIMESTAMP.match(value) if isinstance(value, str) else None
|
|
114
|
+
if not match:
|
|
115
|
+
raise ProtocolError("invalid timestamp")
|
|
116
|
+
fraction = (match.group(2) or ".0")[1:7].ljust(6, "0")
|
|
117
|
+
try:
|
|
118
|
+
return datetime.fromisoformat(f"{match.group(1)}.{fraction}").replace(tzinfo=timezone.utc)
|
|
119
|
+
except ValueError as err:
|
|
120
|
+
raise ProtocolError("invalid timestamp") from err
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _object(value: Any, allowed: Iterable[str], required: Iterable[str]) -> dict[str, Any]:
|
|
124
|
+
if not isinstance(value, dict):
|
|
125
|
+
raise ProtocolError("invalid AAP object")
|
|
126
|
+
allowed = set(allowed)
|
|
127
|
+
for key, item in value.items():
|
|
128
|
+
if key not in allowed or item is None:
|
|
129
|
+
raise ProtocolError("invalid AAP field")
|
|
130
|
+
for key in required:
|
|
131
|
+
if key not in value:
|
|
132
|
+
raise ProtocolError("missing AAP field")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _text(value: Any) -> str:
|
|
137
|
+
if not isinstance(value, str) or not value:
|
|
138
|
+
raise ProtocolError("invalid AAP field")
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_request(value: Any) -> Request:
|
|
143
|
+
obj = _object(
|
|
144
|
+
value,
|
|
145
|
+
["id", "tool", "server", "arguments", "timeout", "agent_reasoning", "context", "status", "created_at", "deadline_at", "decision", "consumption"],
|
|
146
|
+
["id", "tool", "arguments", "timeout", "status", "created_at", "deadline_at"],
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
uuid.UUID(obj["id"])
|
|
150
|
+
except (ValueError, AttributeError, TypeError) as err:
|
|
151
|
+
raise ProtocolError("invalid request ID") from err
|
|
152
|
+
if not isinstance(obj["arguments"], dict) or ("context" in obj and not isinstance(obj["context"], dict)):
|
|
153
|
+
raise ProtocolError("invalid AAP field")
|
|
154
|
+
status = obj["status"]
|
|
155
|
+
if status != PENDING and status not in TERMINAL:
|
|
156
|
+
raise ProtocolError("invalid status")
|
|
157
|
+
created, deadline = timestamp(obj["created_at"]), timestamp(obj["deadline_at"])
|
|
158
|
+
if deadline <= created:
|
|
159
|
+
raise ProtocolError("deadline is not after creation")
|
|
160
|
+
|
|
161
|
+
decision = None
|
|
162
|
+
if status == PENDING:
|
|
163
|
+
if "decision" in obj or "consumption" in obj:
|
|
164
|
+
raise ProtocolError("pending request has a decision")
|
|
165
|
+
else:
|
|
166
|
+
if "decision" not in obj:
|
|
167
|
+
raise ProtocolError("terminal request has no decision")
|
|
168
|
+
d = _object(obj["decision"], ["status", "note", "decided_at", "expires_at"], ["status", "decided_at"])
|
|
169
|
+
decided = timestamp(d["decided_at"])
|
|
170
|
+
if d["status"] != status or decided < created:
|
|
171
|
+
raise ProtocolError("decision does not match the request")
|
|
172
|
+
expires = None
|
|
173
|
+
if status == APPROVED:
|
|
174
|
+
if "expires_at" not in d:
|
|
175
|
+
raise ProtocolError("approval has no expiry")
|
|
176
|
+
expires = timestamp(d["expires_at"])
|
|
177
|
+
if expires <= decided:
|
|
178
|
+
raise ProtocolError("approval expires before it was decided")
|
|
179
|
+
elif "expires_at" in d:
|
|
180
|
+
raise ProtocolError("only approvals expire")
|
|
181
|
+
decision = Decision(status=status, decided_at=decided, note=_text(d["note"]) if "note" in d else None, expires_at=expires)
|
|
182
|
+
|
|
183
|
+
consumption = None
|
|
184
|
+
if "consumption" in obj:
|
|
185
|
+
if status != APPROVED:
|
|
186
|
+
raise ProtocolError("only approvals are consumed")
|
|
187
|
+
c = _object(obj["consumption"], ["consumed_at", "outcome"], ["consumed_at"])
|
|
188
|
+
consumed = timestamp(c["consumed_at"])
|
|
189
|
+
if decision is None or consumed < decision.decided_at:
|
|
190
|
+
raise ProtocolError("consumed before the decision")
|
|
191
|
+
outcome = None
|
|
192
|
+
if "outcome" in c:
|
|
193
|
+
o = _object(c["outcome"], ["status", "note", "reported_at"], ["status", "reported_at"])
|
|
194
|
+
reported = timestamp(o["reported_at"])
|
|
195
|
+
if o["status"] not in OUTCOMES or reported < consumed:
|
|
196
|
+
raise ProtocolError("invalid outcome")
|
|
197
|
+
outcome = Outcome(status=o["status"], reported_at=reported, note=_text(o["note"]) if "note" in o else None)
|
|
198
|
+
consumption = Consumption(consumed_at=consumed, outcome=outcome)
|
|
199
|
+
|
|
200
|
+
return Request(
|
|
201
|
+
id=obj["id"],
|
|
202
|
+
tool=_text(obj["tool"]),
|
|
203
|
+
server=_text(obj["server"]) if "server" in obj else None,
|
|
204
|
+
arguments=obj["arguments"],
|
|
205
|
+
timeout=_text(obj["timeout"]),
|
|
206
|
+
agent_reasoning=obj.get("agent_reasoning"),
|
|
207
|
+
context=obj.get("context"),
|
|
208
|
+
status=status,
|
|
209
|
+
created_at=created,
|
|
210
|
+
deadline_at=deadline,
|
|
211
|
+
decision=decision,
|
|
212
|
+
consumption=consumption,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def matches(request: Request, submission: Mapping[str, Any], previous: Request | None = None) -> bool:
|
|
217
|
+
"""Whether a returned request is the one submitted, unchanged since it was last seen."""
|
|
218
|
+
if (
|
|
219
|
+
request.tool != submission["tool"]
|
|
220
|
+
or request.server != submission.get("server")
|
|
221
|
+
or request.timeout != submission["timeout"]
|
|
222
|
+
or request.agent_reasoning != submission.get("agent_reasoning")
|
|
223
|
+
or not same(request.arguments, submission["arguments"])
|
|
224
|
+
or not same(request.context or {}, submission.get("context") or {})
|
|
225
|
+
):
|
|
226
|
+
return False
|
|
227
|
+
if previous is not None and (request.id, request.created_at, request.deadline_at) != (previous.id, previous.created_at, previous.deadline_at):
|
|
228
|
+
return False
|
|
229
|
+
return True
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Approvals for LangChain agents, LangGraph tool nodes and Deep Agents.
|
|
2
|
+
|
|
3
|
+
``ApprovalMiddleware`` gates every tool call an agent makes, whatever tools
|
|
4
|
+
it was given. In sync mode the call waits for the decision. In async mode
|
|
5
|
+
the run pauses with a LangGraph interrupt, the process can exit, and a
|
|
6
|
+
``Receiver`` wakes the thread when the decision arrives. On resume the
|
|
7
|
+
middleware asks the provider again and ignores the resume value, so a
|
|
8
|
+
thread resumed by anything else cannot approve a call.
|
|
9
|
+
|
|
10
|
+
Install with ``pip install "agentapprovalprotocol[langgraph]"``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from typing import Any, Awaitable, Callable, Iterable, Literal, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
19
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
20
|
+
from langchain_core.runnables.config import var_child_runnable_config
|
|
21
|
+
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
22
|
+
from langgraph.types import Command, interrupt
|
|
23
|
+
|
|
24
|
+
from ._client import AsyncClient, Client
|
|
25
|
+
from ._gate import Allow, AsyncGate, Call, Deny, Gate, Pending, Verdict
|
|
26
|
+
from ._wire import Request
|
|
27
|
+
|
|
28
|
+
INTERRUPT_KEY = "aap_request_id"
|
|
29
|
+
|
|
30
|
+
Handler = Callable[[ToolCallRequest], "ToolMessage | Command"]
|
|
31
|
+
AsyncHandler = Callable[[ToolCallRequest], Awaitable["ToolMessage | Command"]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _thread(request: ToolCallRequest) -> tuple[str | None, str, str | None]:
|
|
35
|
+
runtime = request.runtime
|
|
36
|
+
info = getattr(runtime, "execution_info", None)
|
|
37
|
+
server = getattr(runtime, "server_info", None)
|
|
38
|
+
return getattr(info, "thread_id", None), getattr(info, "checkpoint_ns", "") or "", getattr(server, "assistant_id", None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _reasoning(state: Any) -> str | None:
|
|
42
|
+
messages = state.get("messages", []) if isinstance(state, Mapping) else getattr(state, "messages", [])
|
|
43
|
+
for message in reversed(messages):
|
|
44
|
+
if isinstance(message, AIMessage):
|
|
45
|
+
text = message.text if isinstance(message.text, str) else message.text()
|
|
46
|
+
return text.strip() or None
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pause(request: ToolCallRequest, request_id: str, tool: str) -> None:
|
|
51
|
+
"""Interrupt the run. Returns only on resume, and the value is never read:
|
|
52
|
+
the provider is asked again, so a resume cannot approve a call."""
|
|
53
|
+
value = {INTERRUPT_KEY: request_id, "tool": tool, "tool_call_id": request.tool_call["id"]}
|
|
54
|
+
try:
|
|
55
|
+
interrupt(value)
|
|
56
|
+
except RuntimeError:
|
|
57
|
+
# Python 3.10 can lose the run's config in tasks LangGraph gathers for
|
|
58
|
+
# parallel tool calls; the tool runtime still carries it.
|
|
59
|
+
config = getattr(request.runtime, "config", None)
|
|
60
|
+
if config is None:
|
|
61
|
+
raise
|
|
62
|
+
token = var_child_runnable_config.set(config)
|
|
63
|
+
try:
|
|
64
|
+
interrupt(value)
|
|
65
|
+
finally:
|
|
66
|
+
var_child_runnable_config.reset(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _denied(request: ToolCallRequest, verdict: Deny) -> ToolMessage:
|
|
70
|
+
return ToolMessage(content=verdict.reason, name=request.tool_call["name"], tool_call_id=request.tool_call["id"], status="error")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _failed(result: Any) -> bool:
|
|
74
|
+
return isinstance(result, ToolMessage) and result.status == "error"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ApprovalMiddleware(AgentMiddleware):
|
|
78
|
+
"""Send every tool call to an AAP provider before it runs.
|
|
79
|
+
|
|
80
|
+
Pass the same instance to the agent and to every subagent. Place it last
|
|
81
|
+
among middleware that change tool arguments, so the provider approves the
|
|
82
|
+
arguments that will run.
|
|
83
|
+
|
|
84
|
+
``mode="async"`` needs a checkpointer; without one, calls wait instead.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
base_url: str | None = None,
|
|
90
|
+
token: str | None = None,
|
|
91
|
+
*,
|
|
92
|
+
mode: Literal["sync", "async"] = "sync",
|
|
93
|
+
gate: Gate | None = None,
|
|
94
|
+
async_gate: AsyncGate | None = None,
|
|
95
|
+
context: Mapping[str, Any] | None = None,
|
|
96
|
+
**gate_options: Any,
|
|
97
|
+
):
|
|
98
|
+
super().__init__()
|
|
99
|
+
self.context = {"framework": "langgraph", **(context or {})}
|
|
100
|
+
if gate is None and async_gate is None and (base_url is None or token is None):
|
|
101
|
+
raise ValueError("pass base_url and token, or gates")
|
|
102
|
+
self._gate = gate
|
|
103
|
+
self._async_gate = async_gate
|
|
104
|
+
self._make = lambda client_cls, gate_cls: gate_cls(client_cls(base_url, token), mode=mode, **gate_options)
|
|
105
|
+
self.mode = mode
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def gate(self) -> Gate:
|
|
109
|
+
if self._gate is None:
|
|
110
|
+
self._gate = self._make(Client, Gate)
|
|
111
|
+
return self._gate
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def async_gate(self) -> AsyncGate:
|
|
115
|
+
if self._async_gate is None:
|
|
116
|
+
self._async_gate = self._make(AsyncClient, AsyncGate)
|
|
117
|
+
return self._async_gate
|
|
118
|
+
|
|
119
|
+
def _call(self, request: ToolCallRequest) -> tuple[Call, bool]:
|
|
120
|
+
thread, namespace, assistant = _thread(request)
|
|
121
|
+
tool_call = request.tool_call
|
|
122
|
+
resume = None
|
|
123
|
+
if thread is not None:
|
|
124
|
+
resume = {"framework": "langgraph", "thread_id": thread}
|
|
125
|
+
if assistant:
|
|
126
|
+
resume["assistant_id"] = assistant
|
|
127
|
+
call = Call(
|
|
128
|
+
key=f"{thread or '-'}:{namespace}:{tool_call['id']}",
|
|
129
|
+
tool=tool_call["name"],
|
|
130
|
+
arguments=tool_call.get("args") or {},
|
|
131
|
+
reasoning=_reasoning(request.state),
|
|
132
|
+
context=self.context,
|
|
133
|
+
resume=resume,
|
|
134
|
+
)
|
|
135
|
+
return call, thread is not None
|
|
136
|
+
|
|
137
|
+
def wrap_tool_call(self, request: ToolCallRequest, handler: Handler) -> ToolMessage | Command:
|
|
138
|
+
call, can_pause = self._call(request)
|
|
139
|
+
verdict: Verdict = self.gate.check(call, can_pause=can_pause)
|
|
140
|
+
while isinstance(verdict, Pending):
|
|
141
|
+
_pause(request, verdict.request_id, call.tool)
|
|
142
|
+
verdict = self.gate.check(call, can_pause=can_pause)
|
|
143
|
+
if isinstance(verdict, Deny):
|
|
144
|
+
return _denied(request, verdict)
|
|
145
|
+
try:
|
|
146
|
+
result = handler(request)
|
|
147
|
+
except BaseException:
|
|
148
|
+
if self.gate.report_outcomes:
|
|
149
|
+
verdict.report("failed")
|
|
150
|
+
raise
|
|
151
|
+
if self.gate.report_outcomes:
|
|
152
|
+
verdict.report("failed" if _failed(result) else "succeeded")
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
async def awrap_tool_call(self, request: ToolCallRequest, handler: AsyncHandler) -> ToolMessage | Command:
|
|
156
|
+
call, can_pause = self._call(request)
|
|
157
|
+
verdict: Verdict = await self.async_gate.check(call, can_pause=can_pause)
|
|
158
|
+
while isinstance(verdict, Pending):
|
|
159
|
+
_pause(request, verdict.request_id, call.tool)
|
|
160
|
+
verdict = await self.async_gate.check(call, can_pause=can_pause)
|
|
161
|
+
if isinstance(verdict, Deny):
|
|
162
|
+
return _denied(request, verdict)
|
|
163
|
+
assert isinstance(verdict, Allow)
|
|
164
|
+
try:
|
|
165
|
+
result = await handler(request)
|
|
166
|
+
except BaseException:
|
|
167
|
+
if self.async_gate.report_outcomes:
|
|
168
|
+
await verdict.report("failed")
|
|
169
|
+
raise
|
|
170
|
+
if self.async_gate.report_outcomes:
|
|
171
|
+
await verdict.report("failed" if _failed(result) else "succeeded")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _waiting(interrupts: Iterable[Any], request_id: str) -> dict[str, str]:
|
|
176
|
+
"""Resume values for the interrupts waiting on one request."""
|
|
177
|
+
out = {}
|
|
178
|
+
for item in interrupts:
|
|
179
|
+
value = item.get("value") if isinstance(item, Mapping) else getattr(item, "value", None)
|
|
180
|
+
identifier = item.get("id") if isinstance(item, Mapping) else getattr(item, "id", None)
|
|
181
|
+
if isinstance(value, Mapping) and value.get(INTERRUPT_KEY) == request_id and identifier:
|
|
182
|
+
out[identifier] = "wake"
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resumer(graph: Any, *, config: Mapping[str, Any] | None = None) -> Callable[[Mapping[str, Any], Request], Awaitable[None]]:
|
|
187
|
+
"""A ``Receiver`` resumer for a graph running in this process.
|
|
188
|
+
|
|
189
|
+
It resumes only the interrupts waiting on the decided request, one
|
|
190
|
+
resume per thread at a time. The resumed run continues here, so read
|
|
191
|
+
results from the thread's state, not from the run that started it.
|
|
192
|
+
"""
|
|
193
|
+
locks: dict[str, asyncio.Lock] = {}
|
|
194
|
+
|
|
195
|
+
async def resume(handle: Mapping[str, Any], request: Request) -> None:
|
|
196
|
+
thread = handle.get("thread_id")
|
|
197
|
+
if not isinstance(thread, str):
|
|
198
|
+
return
|
|
199
|
+
base = dict(config or {})
|
|
200
|
+
settings = {**base, "configurable": {**base.get("configurable", {}), "thread_id": thread}}
|
|
201
|
+
async with locks.setdefault(thread, asyncio.Lock()):
|
|
202
|
+
try:
|
|
203
|
+
state = await graph.aget_state(settings)
|
|
204
|
+
except NotImplementedError:
|
|
205
|
+
state = await asyncio.to_thread(graph.get_state, settings)
|
|
206
|
+
values = _waiting(state.interrupts, request.id)
|
|
207
|
+
if not values:
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
await graph.ainvoke(Command(resume=values), settings)
|
|
211
|
+
except NotImplementedError:
|
|
212
|
+
await asyncio.to_thread(graph.invoke, Command(resume=values), settings)
|
|
213
|
+
|
|
214
|
+
return resume
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def deployment_resumer(client: Any, *, assistant_id: str | None = None) -> Callable[[Mapping[str, Any], Request], Awaitable[None]]:
|
|
218
|
+
"""A ``Receiver`` resumer for graphs on LangSmith Deployment.
|
|
219
|
+
|
|
220
|
+
``client`` is a ``langgraph_sdk`` async client. The resume is queued on
|
|
221
|
+
the deployment behind any run already active on the thread.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
async def resume(handle: Mapping[str, Any], request: Request) -> None:
|
|
225
|
+
thread = handle.get("thread_id")
|
|
226
|
+
assistant = handle.get("assistant_id") or assistant_id
|
|
227
|
+
if not isinstance(thread, str) or not assistant:
|
|
228
|
+
return
|
|
229
|
+
state = await client.threads.get_state(thread)
|
|
230
|
+
values = _waiting(state.get("interrupts") or [], request.id)
|
|
231
|
+
if values:
|
|
232
|
+
await client.runs.create(thread, assistant, command={"resume": values}, multitask_strategy="enqueue")
|
|
233
|
+
|
|
234
|
+
return resume
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def deep_agent_subagents(middleware: ApprovalMiddleware, subagents: Sequence[Mapping[str, Any]] = ()) -> list[dict[str, Any]]:
|
|
238
|
+
"""Subagent specs for ``create_deep_agent`` with approvals on every one.
|
|
239
|
+
|
|
240
|
+
Deep Agents gives subagents the parent's ``interrupt_on`` but not its
|
|
241
|
+
custom middleware, so pass the result as ``subagents``. It also replaces
|
|
242
|
+
the default general-purpose subagent with the same spec plus approvals.
|
|
243
|
+
Compiled and async subagents run their own graphs and are refused: add
|
|
244
|
+
the middleware to those graphs yourself.
|
|
245
|
+
"""
|
|
246
|
+
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
|
|
247
|
+
|
|
248
|
+
out: list[dict[str, Any]] = []
|
|
249
|
+
for spec in subagents:
|
|
250
|
+
if "runnable" in spec or "graph_id" in spec or "url" in spec:
|
|
251
|
+
raise ValueError(f"subagent {spec.get('name')!r} runs its own graph; add ApprovalMiddleware to it directly")
|
|
252
|
+
out.append({**spec, "middleware": [*spec.get("middleware", []), middleware]})
|
|
253
|
+
if not any(spec.get("name") == GENERAL_PURPOSE_SUBAGENT["name"] for spec in out):
|
|
254
|
+
out.append({**GENERAL_PURPOSE_SUBAGENT, "middleware": [middleware]})
|
|
255
|
+
return out
|
|
File without changes
|