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.
@@ -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