agentapprovalprotocol 0.1.0__tar.gz → 0.2.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.
Files changed (29) hide show
  1. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/.gitignore +1 -0
  2. agentapprovalprotocol-0.2.0/PKG-INFO +194 -0
  3. agentapprovalprotocol-0.2.0/README.md +167 -0
  4. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/pyproject.toml +16 -1
  5. agentapprovalprotocol-0.2.0/src/agentapprovalprotocol/adk.py +262 -0
  6. agentapprovalprotocol-0.2.0/src/agentapprovalprotocol/claude_agent_sdk.py +344 -0
  7. agentapprovalprotocol-0.2.0/src/agentapprovalprotocol/crewai.py +271 -0
  8. agentapprovalprotocol-0.2.0/src/agentapprovalprotocol/openai_agents.py +364 -0
  9. agentapprovalprotocol-0.2.0/tests/test_adk.py +346 -0
  10. agentapprovalprotocol-0.2.0/tests/test_claude_agent_sdk.py +385 -0
  11. agentapprovalprotocol-0.2.0/tests/test_crewai.py +353 -0
  12. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/tests/test_langgraph.py +2 -0
  13. agentapprovalprotocol-0.2.0/tests/test_openai_agents.py +359 -0
  14. agentapprovalprotocol-0.2.0/uv.lock +6752 -0
  15. agentapprovalprotocol-0.1.0/PKG-INFO +0 -96
  16. agentapprovalprotocol-0.1.0/README.md +0 -77
  17. agentapprovalprotocol-0.1.0/uv.lock +0 -2184
  18. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/__init__.py +0 -0
  19. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/_client.py +0 -0
  20. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/_gate.py +0 -0
  21. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/_wire.py +0 -0
  22. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/langgraph.py +0 -0
  23. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/py.typed +0 -0
  24. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/pydantic_ai.py +0 -0
  25. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/receiver.py +0 -0
  26. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/src/agentapprovalprotocol/testing.py +0 -0
  27. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/tests/test_gate.py +0 -0
  28. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/tests/test_pydantic_ai.py +0 -0
  29. {agentapprovalprotocol-0.1.0 → agentapprovalprotocol-0.2.0}/tests/test_wire_and_receiver.py +0 -0
@@ -14,5 +14,6 @@ playwright-report/
14
14
  /sdk/python/.venv/
15
15
  /sdk/python/dist/
16
16
  /sdk/typescript/dist/
17
+ /sdk/typescript-ai-sdk/dist/
17
18
  __pycache__/
18
19
  /sdk/python/.pytest_cache/
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentapprovalprotocol
3
+ Version: 0.2.0
4
+ Summary: Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider.
5
+ Project-URL: Homepage, https://agentapprovalprotocol.io
6
+ Project-URL: Source, https://github.com/agentapprovalprotocol/agentapprovalprotocol
7
+ License-Expression: Apache-2.0
8
+ Keywords: aap,agents,ai,approval,human-in-the-loop
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: adk
14
+ Requires-Dist: google-adk>=2.11; extra == 'adk'
15
+ Provides-Extra: claude-agent-sdk
16
+ Requires-Dist: claude-agent-sdk>=0.2.165; extra == 'claude-agent-sdk'
17
+ Provides-Extra: crewai
18
+ Requires-Dist: crewai>=1.15; (python_full_version < '3.14') and extra == 'crewai'
19
+ Provides-Extra: langgraph
20
+ Requires-Dist: langchain>=1.0; extra == 'langgraph'
21
+ Requires-Dist: langgraph>=1.0; extra == 'langgraph'
22
+ Provides-Extra: openai-agents
23
+ Requires-Dist: openai-agents>=0.23; extra == 'openai-agents'
24
+ Provides-Extra: pydantic-ai
25
+ Requires-Dist: pydantic-ai-slim>=2.55; (python_full_version >= '3.11') and extra == 'pydantic-ai'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Agent Approval Protocol for Python
29
+
30
+ Put an AI agent's tool calls behind an [AAP](https://agentapprovalprotocol.io) approval provider. Each call waits for the provider's decision, runs only if approved, and runs at most once.
31
+
32
+ ```sh
33
+ pip install agentapprovalprotocol
34
+ ```
35
+
36
+ ## Check a tool call
37
+
38
+ ```python
39
+ from agentapprovalprotocol import Call, Client, Gate
40
+
41
+ gate = Gate(Client("https://approvals.example.com/api/aap", token))
42
+
43
+ with gate.guard(Call(key=f"{run_id}:{tool_call.id}", tool=tool_call.name, arguments=tool_call.args)) as verdict:
44
+ if verdict:
45
+ result = run_tool(tool_call)
46
+ else:
47
+ result = verdict.reason # return this to the model as the tool result
48
+ ```
49
+
50
+ `key` must identify the tool call within its run, and stay the same if the call is seen again after a retry or resume. `guard` reports whether the tool succeeded when the block exits.
51
+
52
+ `AsyncGate` and `AsyncClient` provide the same API for `asyncio`.
53
+
54
+ ## Pause instead of waiting
55
+
56
+ With `mode="async"`, `check` returns `Pending` when there is no decision yet, so the run can pause and the process can exit. Mount a `Receiver` where the provider can reach it. When a decision arrives it wakes the run, and the gate checks the decision again before anything runs.
57
+
58
+ Framework integrations handle pausing and resuming for you.
59
+
60
+ ## LangChain, LangGraph and Deep Agents
61
+
62
+ ```sh
63
+ pip install "agentapprovalprotocol[langgraph]"
64
+ ```
65
+
66
+ ```python
67
+ from agentapprovalprotocol.langgraph import ApprovalMiddleware
68
+
69
+ approvals = ApprovalMiddleware("https://approvals.example.com/api/aap", token)
70
+ agent = create_agent(model, tools=tools, middleware=[approvals])
71
+ ```
72
+
73
+ Every tool call the agent makes goes to the provider. A denied call returns the reason to the model as the tool result.
74
+
75
+ For Deep Agents, also add the middleware to every subagent:
76
+
77
+ ```python
78
+ from agentapprovalprotocol.langgraph import deep_agent_subagents
79
+
80
+ agent = create_deep_agent(model, tools=tools, middleware=[approvals], subagents=deep_agent_subagents(approvals, my_subagents))
81
+ ```
82
+
83
+ To pause instead of waiting, use `ApprovalMiddleware(..., mode="async")` with a checkpointer, and mount a `Receiver` with `resumers={"langgraph": resumer(agent)}`. On LangSmith Deployment, use `deployment_resumer(get_client())` in a custom route instead. Resuming a thread any other way cannot approve a call: the middleware asks the provider again.
84
+
85
+ ## Pydantic AI
86
+
87
+ ```sh
88
+ pip install "agentapprovalprotocol[pydantic-ai]"
89
+ ```
90
+
91
+ Needs Python 3.11 or later.
92
+
93
+ ```python
94
+ from agentapprovalprotocol.pydantic_ai import ApprovalCapability
95
+
96
+ approvals = ApprovalCapability("https://approvals.example.com/api/aap", token)
97
+ agent = Agent(model, tools=tools, capabilities=[approvals])
98
+ ```
99
+
100
+ Every tool call the agent makes goes to the provider, including MCP and other toolset tools. A denied call returns the reason to the model as a failed tool result. Add the same capability to every agent you run as a sub-agent.
101
+
102
+ Tools that run at the model provider, such as native web search or code execution, cannot be sent for approval. A run that includes one stops with an error. Use a local tool instead.
103
+
104
+ To pause instead of waiting, use `ApprovalCapability(..., mode="async")` and add `DeferredToolRequests` to the agent's `output_type`. When a run ends with `DeferredToolRequests`, save `result.conversation`. Mount a `Receiver` with `resumers={"pydantic-ai": resumer(agent, load=load, save=save)}`, where `load(conversation_id)` returns the saved conversation and `save(conversation)` stores the resumed one. Calls inside a sub-agent wait instead of pausing. Continuing a conversation any other way cannot approve a call: the capability asks the provider again, and an approval with edited arguments is refused.
105
+
106
+ ## Google Agent Development Kit
107
+
108
+ ```sh
109
+ pip install "agentapprovalprotocol[adk]"
110
+ ```
111
+
112
+ ```python
113
+ from agentapprovalprotocol.adk import ApprovalPlugin, check_coverage
114
+
115
+ approvals = ApprovalPlugin("https://approvals.example.com/api/aap", token)
116
+ app = App(name="support", root_agent=agent, plugins=[approvals])
117
+ check_coverage(app, approvals)
118
+ ```
119
+
120
+ Every tool call from every agent in the app goes to the provider, including sub-agents and agents used as tools. A denied call returns the reason to the model as the tool result. Register the plugin first, and don't change tool arguments in later plugins or `before_tool_callback`.
121
+
122
+ `check_coverage` raises an error for agents whose calls the plugin cannot see: code executors, remote agents, and agents used as tools with `include_plugins=False`. Gemini built-in tools such as `google_search` run on Google's servers, so the plugin stops the run when an agent uses one.
123
+
124
+ To pause instead of waiting, use `ApprovalPlugin(..., mode="async")`, set `resumability_config=ResumabilityConfig(is_resumable=True)` on the app, and mount a `Receiver` with `resumers={"adk": resumer(runner)}`. Calls inside an agent used as a tool, and calls in live (streaming audio or video) runs, wait instead of pausing. For a runner in another process, such as `adk api_server`, send `wake_message(session.events, request.id)` as the new message from your own resumer. Answering the confirmation any other way cannot approve a call: the plugin asks the provider again.
125
+
126
+ ## OpenAI Agents SDK
127
+
128
+ ```sh
129
+ pip install "agentapprovalprotocol[openai-agents]"
130
+ ```
131
+
132
+ ```python
133
+ from agentapprovalprotocol.openai_agents import Approvals
134
+
135
+ approvals = Approvals("https://approvals.example.com/api/aap", token)
136
+ agent = approvals.protect(Agent(name="Support", tools=tools, handoffs=handoffs))
137
+ result = await Runner.run(agent, "Refund the duplicate charge")
138
+ ```
139
+
140
+ `protect` covers the agent's tools, its local MCP servers, its handoff targets and agents it uses as tools. Call it after the agent is fully configured. A denied call returns the reason to the model as the tool result.
141
+
142
+ `protect` refuses tools that run inside OpenAI's response, such as web search, file search and code interpreter, because their calls cannot be stopped. It also refuses local shell, computer and apply patch tools: wrap the action in a function tool instead. Hosted MCP servers are supported, and their calls always wait for the decision.
143
+
144
+ To pause instead of waiting, use `Approvals(..., mode="async", save=save, load=load)`, where `save(run_id, state)` and `load(run_id)` keep each run's state on your server. Start runs with `await approvals.run(agent, input, run_id=run_id)` and mount a `Receiver` with `resumers={"openai-agents": approvals.resumer(agent)}`. Approving the run state any other way cannot run a call: only a decision from the provider can.
145
+
146
+ ## Claude Agent SDK
147
+
148
+ ```sh
149
+ pip install "agentapprovalprotocol[claude-agent-sdk]"
150
+ ```
151
+
152
+ ```python
153
+ from agentapprovalprotocol.claude_agent_sdk import ApprovalHooks
154
+
155
+ approvals = ApprovalHooks("https://approvals.example.com/api/aap", token)
156
+ options = approvals.options(ClaudeAgentOptions(mcp_servers=servers))
157
+ async for message in query(prompt=prompt, options=options):
158
+ ...
159
+ ```
160
+
161
+ Every tool call the agent makes goes to the provider, including built-in tools, MCP tools and calls made by subagents. A denied call returns the reason to the model.
162
+
163
+ `options()` does not load your user settings (`~/.claude/settings.json`) unless you set `setting_sources` yourself. If the Claude Code approval hook is installed there, loading them asks for approval twice for every call.
164
+
165
+ To pause instead of waiting, use `ApprovalHooks(..., mode="async")`. A call without a decision ends the run with `stop_reason="tool_deferred"`. Mount a `Receiver` with `resumers={"claude-agent-sdk": resumer(options)}` on the machine that stores the session, and it resumes the session when the decision arrives. Calls made by subagents, and a second call in the same turn, wait for their decision instead of pausing.
166
+
167
+ Tools that the Claude API runs itself, such as the advisor, never reach the hooks, so they are not sent for approval.
168
+
169
+ ## CrewAI
170
+
171
+ CrewAI and the OpenAI Agents SDK need different major versions of `openai`, so the `crewai` and `openai-agents` extras can't be installed together.
172
+
173
+ ```sh
174
+ pip install "agentapprovalprotocol[crewai]"
175
+ ```
176
+
177
+ CrewAI supports Python 3.10 to 3.13.
178
+
179
+ ```python
180
+ from agentapprovalprotocol.crewai import ApprovalHooks
181
+
182
+ ApprovalHooks("https://approvals.example.com/api/aap", token).install()
183
+ crew.kickoff()
184
+ ```
185
+
186
+ Every tool call any crew, agent or flow in the process makes goes to the provider, including delegation to coworkers. A denied call returns the reason to the model as the tool result.
187
+
188
+ CrewAI cannot pause a run at a tool call, so each call waits for the decision. In `akickoff` the wait blocks the event loop.
189
+
190
+ Limitations:
191
+
192
+ - Register any hook that changes tool arguments before `install()`. A hook registered later, including one from a `@CrewBase` crew, runs after the approval. If it changes the arguments, the call is reported to the provider as failed.
193
+ - Models with `builtin_tools` run those tools at the model provider, where they cannot be checked. Runs that use such a model stop with an error.
194
+ - Tools you run yourself with `llm.call(available_functions=...)` are not checked.
@@ -0,0 +1,167 @@
1
+ # Agent Approval Protocol for Python
2
+
3
+ Put an AI agent's tool calls behind an [AAP](https://agentapprovalprotocol.io) approval provider. Each call waits for the provider's decision, runs only if approved, and runs at most once.
4
+
5
+ ```sh
6
+ pip install agentapprovalprotocol
7
+ ```
8
+
9
+ ## Check a tool call
10
+
11
+ ```python
12
+ from agentapprovalprotocol import Call, Client, Gate
13
+
14
+ gate = Gate(Client("https://approvals.example.com/api/aap", token))
15
+
16
+ with gate.guard(Call(key=f"{run_id}:{tool_call.id}", tool=tool_call.name, arguments=tool_call.args)) as verdict:
17
+ if verdict:
18
+ result = run_tool(tool_call)
19
+ else:
20
+ result = verdict.reason # return this to the model as the tool result
21
+ ```
22
+
23
+ `key` must identify the tool call within its run, and stay the same if the call is seen again after a retry or resume. `guard` reports whether the tool succeeded when the block exits.
24
+
25
+ `AsyncGate` and `AsyncClient` provide the same API for `asyncio`.
26
+
27
+ ## Pause instead of waiting
28
+
29
+ With `mode="async"`, `check` returns `Pending` when there is no decision yet, so the run can pause and the process can exit. Mount a `Receiver` where the provider can reach it. When a decision arrives it wakes the run, and the gate checks the decision again before anything runs.
30
+
31
+ Framework integrations handle pausing and resuming for you.
32
+
33
+ ## LangChain, LangGraph and Deep Agents
34
+
35
+ ```sh
36
+ pip install "agentapprovalprotocol[langgraph]"
37
+ ```
38
+
39
+ ```python
40
+ from agentapprovalprotocol.langgraph import ApprovalMiddleware
41
+
42
+ approvals = ApprovalMiddleware("https://approvals.example.com/api/aap", token)
43
+ agent = create_agent(model, tools=tools, middleware=[approvals])
44
+ ```
45
+
46
+ Every tool call the agent makes goes to the provider. A denied call returns the reason to the model as the tool result.
47
+
48
+ For Deep Agents, also add the middleware to every subagent:
49
+
50
+ ```python
51
+ from agentapprovalprotocol.langgraph import deep_agent_subagents
52
+
53
+ agent = create_deep_agent(model, tools=tools, middleware=[approvals], subagents=deep_agent_subagents(approvals, my_subagents))
54
+ ```
55
+
56
+ To pause instead of waiting, use `ApprovalMiddleware(..., mode="async")` with a checkpointer, and mount a `Receiver` with `resumers={"langgraph": resumer(agent)}`. On LangSmith Deployment, use `deployment_resumer(get_client())` in a custom route instead. Resuming a thread any other way cannot approve a call: the middleware asks the provider again.
57
+
58
+ ## Pydantic AI
59
+
60
+ ```sh
61
+ pip install "agentapprovalprotocol[pydantic-ai]"
62
+ ```
63
+
64
+ Needs Python 3.11 or later.
65
+
66
+ ```python
67
+ from agentapprovalprotocol.pydantic_ai import ApprovalCapability
68
+
69
+ approvals = ApprovalCapability("https://approvals.example.com/api/aap", token)
70
+ agent = Agent(model, tools=tools, capabilities=[approvals])
71
+ ```
72
+
73
+ Every tool call the agent makes goes to the provider, including MCP and other toolset tools. A denied call returns the reason to the model as a failed tool result. Add the same capability to every agent you run as a sub-agent.
74
+
75
+ Tools that run at the model provider, such as native web search or code execution, cannot be sent for approval. A run that includes one stops with an error. Use a local tool instead.
76
+
77
+ To pause instead of waiting, use `ApprovalCapability(..., mode="async")` and add `DeferredToolRequests` to the agent's `output_type`. When a run ends with `DeferredToolRequests`, save `result.conversation`. Mount a `Receiver` with `resumers={"pydantic-ai": resumer(agent, load=load, save=save)}`, where `load(conversation_id)` returns the saved conversation and `save(conversation)` stores the resumed one. Calls inside a sub-agent wait instead of pausing. Continuing a conversation any other way cannot approve a call: the capability asks the provider again, and an approval with edited arguments is refused.
78
+
79
+ ## Google Agent Development Kit
80
+
81
+ ```sh
82
+ pip install "agentapprovalprotocol[adk]"
83
+ ```
84
+
85
+ ```python
86
+ from agentapprovalprotocol.adk import ApprovalPlugin, check_coverage
87
+
88
+ approvals = ApprovalPlugin("https://approvals.example.com/api/aap", token)
89
+ app = App(name="support", root_agent=agent, plugins=[approvals])
90
+ check_coverage(app, approvals)
91
+ ```
92
+
93
+ Every tool call from every agent in the app goes to the provider, including sub-agents and agents used as tools. A denied call returns the reason to the model as the tool result. Register the plugin first, and don't change tool arguments in later plugins or `before_tool_callback`.
94
+
95
+ `check_coverage` raises an error for agents whose calls the plugin cannot see: code executors, remote agents, and agents used as tools with `include_plugins=False`. Gemini built-in tools such as `google_search` run on Google's servers, so the plugin stops the run when an agent uses one.
96
+
97
+ To pause instead of waiting, use `ApprovalPlugin(..., mode="async")`, set `resumability_config=ResumabilityConfig(is_resumable=True)` on the app, and mount a `Receiver` with `resumers={"adk": resumer(runner)}`. Calls inside an agent used as a tool, and calls in live (streaming audio or video) runs, wait instead of pausing. For a runner in another process, such as `adk api_server`, send `wake_message(session.events, request.id)` as the new message from your own resumer. Answering the confirmation any other way cannot approve a call: the plugin asks the provider again.
98
+
99
+ ## OpenAI Agents SDK
100
+
101
+ ```sh
102
+ pip install "agentapprovalprotocol[openai-agents]"
103
+ ```
104
+
105
+ ```python
106
+ from agentapprovalprotocol.openai_agents import Approvals
107
+
108
+ approvals = Approvals("https://approvals.example.com/api/aap", token)
109
+ agent = approvals.protect(Agent(name="Support", tools=tools, handoffs=handoffs))
110
+ result = await Runner.run(agent, "Refund the duplicate charge")
111
+ ```
112
+
113
+ `protect` covers the agent's tools, its local MCP servers, its handoff targets and agents it uses as tools. Call it after the agent is fully configured. A denied call returns the reason to the model as the tool result.
114
+
115
+ `protect` refuses tools that run inside OpenAI's response, such as web search, file search and code interpreter, because their calls cannot be stopped. It also refuses local shell, computer and apply patch tools: wrap the action in a function tool instead. Hosted MCP servers are supported, and their calls always wait for the decision.
116
+
117
+ To pause instead of waiting, use `Approvals(..., mode="async", save=save, load=load)`, where `save(run_id, state)` and `load(run_id)` keep each run's state on your server. Start runs with `await approvals.run(agent, input, run_id=run_id)` and mount a `Receiver` with `resumers={"openai-agents": approvals.resumer(agent)}`. Approving the run state any other way cannot run a call: only a decision from the provider can.
118
+
119
+ ## Claude Agent SDK
120
+
121
+ ```sh
122
+ pip install "agentapprovalprotocol[claude-agent-sdk]"
123
+ ```
124
+
125
+ ```python
126
+ from agentapprovalprotocol.claude_agent_sdk import ApprovalHooks
127
+
128
+ approvals = ApprovalHooks("https://approvals.example.com/api/aap", token)
129
+ options = approvals.options(ClaudeAgentOptions(mcp_servers=servers))
130
+ async for message in query(prompt=prompt, options=options):
131
+ ...
132
+ ```
133
+
134
+ Every tool call the agent makes goes to the provider, including built-in tools, MCP tools and calls made by subagents. A denied call returns the reason to the model.
135
+
136
+ `options()` does not load your user settings (`~/.claude/settings.json`) unless you set `setting_sources` yourself. If the Claude Code approval hook is installed there, loading them asks for approval twice for every call.
137
+
138
+ To pause instead of waiting, use `ApprovalHooks(..., mode="async")`. A call without a decision ends the run with `stop_reason="tool_deferred"`. Mount a `Receiver` with `resumers={"claude-agent-sdk": resumer(options)}` on the machine that stores the session, and it resumes the session when the decision arrives. Calls made by subagents, and a second call in the same turn, wait for their decision instead of pausing.
139
+
140
+ Tools that the Claude API runs itself, such as the advisor, never reach the hooks, so they are not sent for approval.
141
+
142
+ ## CrewAI
143
+
144
+ CrewAI and the OpenAI Agents SDK need different major versions of `openai`, so the `crewai` and `openai-agents` extras can't be installed together.
145
+
146
+ ```sh
147
+ pip install "agentapprovalprotocol[crewai]"
148
+ ```
149
+
150
+ CrewAI supports Python 3.10 to 3.13.
151
+
152
+ ```python
153
+ from agentapprovalprotocol.crewai import ApprovalHooks
154
+
155
+ ApprovalHooks("https://approvals.example.com/api/aap", token).install()
156
+ crew.kickoff()
157
+ ```
158
+
159
+ Every tool call any crew, agent or flow in the process makes goes to the provider, including delegation to coworkers. A denied call returns the reason to the model as the tool result.
160
+
161
+ CrewAI cannot pause a run at a tool call, so each call waits for the decision. In `akickoff` the wait blocks the event loop.
162
+
163
+ Limitations:
164
+
165
+ - Register any hook that changes tool arguments before `install()`. A hook registered later, including one from a `@CrewBase` crew, runs after the approval. If it changes the arguments, the call is reported to the provider as failed.
166
+ - Models with `builtin_tools` run those tools at the model provider, where they cannot be checked. Runs that use such a model stop with an error.
167
+ - Tools you run yourself with `llm.call(available_functions=...)` are not checked.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentapprovalprotocol"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -17,6 +17,7 @@ Homepage = "https://agentapprovalprotocol.io"
17
17
  Source = "https://github.com/agentapprovalprotocol/agentapprovalprotocol"
18
18
 
19
19
  [project.optional-dependencies]
20
+ claude-agent-sdk = ["claude-agent-sdk>=0.2.165"]
20
21
  langgraph = [
21
22
  "langchain>=1.0",
22
23
  "langgraph>=1.0",
@@ -24,6 +25,13 @@ langgraph = [
24
25
  pydantic-ai = [
25
26
  "pydantic-ai-slim>=2.55 ; python_full_version >= '3.11'",
26
27
  ]
28
+ adk = [
29
+ "google-adk>=2.11",
30
+ ]
31
+ openai-agents = [
32
+ "openai-agents>=0.23",
33
+ ]
34
+ crewai = ["crewai>=1.15 ; python_full_version < '3.14'"]
27
35
 
28
36
  [dependency-groups]
29
37
  dev = [
@@ -43,3 +51,10 @@ packages = ["src/agentapprovalprotocol"]
43
51
  [tool.pytest.ini_options]
44
52
  asyncio_mode = "auto"
45
53
  testpaths = ["tests"]
54
+
55
+ [tool.uv]
56
+ # CrewAI needs openai below 3 and the OpenAI Agents SDK needs openai 3, so
57
+ # the two extras resolve separately and cannot be installed together.
58
+ conflicts = [[{ extra = "crewai" }, { extra = "openai-agents" }]]
59
+ # chromadb (through crewai) allows onnxruntime releases without Python 3.10 wheels.
60
+ constraint-dependencies = ["onnxruntime<1.24 ; python_full_version < '3.11'"]
@@ -0,0 +1,262 @@
1
+ """Approvals for Google Agent Development Kit (ADK) agents.
2
+
3
+ ``ApprovalPlugin`` gates every tool call any agent in a runner makes,
4
+ including sub-agents and agents used as tools. In sync mode the call waits
5
+ for the decision. In async mode the run pauses with an ADK tool
6
+ confirmation, the process can exit, and a ``Receiver`` wakes the session
7
+ when the decision arrives. On resume the plugin asks the provider again and
8
+ ignores the confirmation's ``confirmed`` flag, so a confirmation sent by
9
+ anything else cannot approve a call.
10
+
11
+ Install with ``pip install "agentapprovalprotocol[adk]"``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextvars
18
+ from typing import Any, Awaitable, Callable, Iterable, Literal, Mapping
19
+
20
+ from google.adk.agents import BaseAgent, LlmAgent
21
+ from google.adk.apps import App
22
+ from google.adk.events import Event
23
+ from google.adk.plugins.base_plugin import BasePlugin
24
+ from google.adk.tools.agent_tool import AgentTool
25
+ from google.genai import types
26
+
27
+ from ._client import AsyncClient
28
+ from ._gate import Allow, AsyncGate, Call, Deny, Pending
29
+ from ._wire import Request
30
+
31
+ PAYLOAD_KEY = "aap_request_id"
32
+ CONFIRMATION = "adk_request_confirmation"
33
+ PENDING_TEXT = "This call is waiting for approval. It will run if it is approved."
34
+
35
+ # Tool fields Gemini executes itself, outside ADK's tool pipeline, are refused.
36
+ # Function declarations (and computer use, whose actions are function calls)
37
+ # come back to ADK as calls the plugin sees.
38
+ _CLIENT_RUN = {"function_declarations", "computer_use"}
39
+
40
+ # Set while an approved tool runs, so calls made inside it (an agent used as
41
+ # a tool runs a nested runner) know their run cannot pause.
42
+ _inside_tool: contextvars.ContextVar[bool] = contextvars.ContextVar("aap_adk_inside_tool", default=False)
43
+
44
+
45
+ class UnsupportedToolError(RuntimeError):
46
+ """An agent uses something whose calls cannot wait for approval."""
47
+
48
+
49
+ def _function_call(events: Iterable[Event], call_id: str | None) -> tuple[Event, types.FunctionCall] | None:
50
+ if not call_id:
51
+ return None
52
+ for event in reversed(list(events)):
53
+ for call in event.get_function_calls():
54
+ if call.id == call_id and call.name != CONFIRMATION:
55
+ return event, call
56
+ return None
57
+
58
+
59
+ def _reasoning(event: Event | None) -> str | None:
60
+ if event is None or event.content is None:
61
+ return None
62
+ text = "".join(part.text for part in event.content.parts or [] if part.text and not part.thought)
63
+ return text.strip() or None
64
+
65
+
66
+ def _failed(result: Any) -> bool:
67
+ return isinstance(result, Mapping) and "error" in result
68
+
69
+
70
+ class ApprovalPlugin(BasePlugin):
71
+ """Send every tool call to an AAP provider before it runs.
72
+
73
+ Register it first in ``App(plugins=[...])``, so no other plugin answers
74
+ or rewrites a call before the provider sees it, and keep tool arguments
75
+ unchanged in later plugins and agent ``before_tool_callback``s.
76
+
77
+ ``mode="async"`` pauses runs and needs ``App(resumability_config=
78
+ ResumabilityConfig(is_resumable=True))``. Calls that cannot pause wait
79
+ instead: calls in an app that is not resumable, inside an agent used as a
80
+ tool, in live (bidi) runs and in workflow tool nodes.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ base_url: str | None = None,
86
+ token: str | None = None,
87
+ *,
88
+ mode: Literal["sync", "async"] = "sync",
89
+ gate: AsyncGate | None = None,
90
+ context: Mapping[str, Any] | None = None,
91
+ name: str = "aap_approvals",
92
+ **gate_options: Any,
93
+ ):
94
+ super().__init__(name)
95
+ if gate is None and (base_url is None or token is None):
96
+ raise ValueError("pass base_url and token, or a gate")
97
+ self.gate = gate or AsyncGate(AsyncClient(base_url, token), mode=mode, **gate_options) # type: ignore[arg-type]
98
+ self.context = {"framework": "adk", **(context or {})}
99
+ self._allowed: dict[str, Allow] = {}
100
+
101
+ def _call(self, tool: Any, args: Mapping[str, Any], tool_context: Any) -> tuple[Call, bool]:
102
+ ic = tool_context.get_invocation_context()
103
+ session = ic.session
104
+ found = _function_call(session.events, tool_context.function_call_id)
105
+ resume = {"framework": "adk", "app_name": session.app_name, "user_id": session.user_id, "session_id": session.id}
106
+ call = Call(
107
+ key=f"{session.app_name}:{session.user_id}:{session.id}:{ic.invocation_id}:{tool_context.function_call_id}",
108
+ tool=tool.name,
109
+ arguments=dict(args),
110
+ reasoning=_reasoning(found[0] if found else None),
111
+ context={**self.context, "agent": tool_context.agent_name},
112
+ resume=resume,
113
+ )
114
+ # A pause needs a resumable app, a model function call ADK can resume,
115
+ # and a run that is not live or nested inside another tool.
116
+ can_pause = ic.is_resumable and found is not None and ic.live_request_queue is None and not _inside_tool.get()
117
+ return call, can_pause
118
+
119
+ async def before_tool_callback(self, *, tool: Any, tool_args: dict[str, Any], tool_context: Any) -> dict[str, Any] | None:
120
+ confirmation = tool_context.tool_confirmation
121
+ if (confirmation is None or not confirmation.confirmed) and await tool.check_require_confirmation(tool_args, tool_context) is True:
122
+ # The tool has its own ADK confirmation, which answers this call
123
+ # without running it. Ask the provider once the user confirms.
124
+ return None
125
+ call, can_pause = self._call(tool, tool_args, tool_context)
126
+ # A confirmation never approves a call: the provider is asked again.
127
+ verdict = await self.gate.check(call, can_pause=can_pause)
128
+ if isinstance(verdict, Pending):
129
+ tool_context.request_confirmation(hint=PENDING_TEXT, payload={PAYLOAD_KEY: verdict.request_id})
130
+ tool_context.actions.skip_summarization = True
131
+ return {"status": "pending", "message": PENDING_TEXT}
132
+ if isinstance(verdict, Deny):
133
+ return {"error": verdict.reason}
134
+ self._allowed[call.key] = verdict
135
+ _inside_tool.set(True)
136
+ return None
137
+
138
+ async def after_tool_callback(self, *, tool: Any, tool_args: dict[str, Any], tool_context: Any, result: Any) -> None:
139
+ await self._report(tool, tool_args, tool_context, "failed" if _failed(result) else "succeeded")
140
+ return None
141
+
142
+ async def on_tool_error_callback(self, *, tool: Any, tool_args: dict[str, Any], tool_context: Any, error: Exception) -> None:
143
+ await self._report(tool, tool_args, tool_context, "failed")
144
+ return None
145
+
146
+ async def _report(self, tool: Any, tool_args: Mapping[str, Any], tool_context: Any, status: Literal["succeeded", "failed"]) -> None:
147
+ call, _ = self._call(tool, tool_args, tool_context)
148
+ verdict = self._allowed.pop(call.key, None)
149
+ if verdict is not None and self.gate.report_outcomes:
150
+ await verdict.report(status)
151
+
152
+ async def before_model_callback(self, *, callback_context: Any, llm_request: Any) -> None:
153
+ agent = callback_context.get_invocation_context().agent
154
+ _refuse_code_executor(agent)
155
+ for tool in (llm_request.config.tools if llm_request.config else None) or []:
156
+ fields = set(tool.model_dump(exclude_none=True)) if isinstance(tool, types.Tool) else {type(tool).__name__}
157
+ if fields - _CLIENT_RUN:
158
+ names = ", ".join(sorted(fields - _CLIENT_RUN))
159
+ raise UnsupportedToolError(f"Agent {getattr(agent, 'name', '?')!r} uses {names}, which Gemini runs itself, so its calls cannot wait for approval. Remove it from the agent.")
160
+ return None
161
+
162
+
163
+ def _refuse_code_executor(agent: Any) -> None:
164
+ if getattr(agent, "code_executor", None) is not None:
165
+ raise UnsupportedToolError(f"Agent {agent.name!r} runs model-written code with a code executor, which cannot wait for approval. Remove the code executor.")
166
+
167
+
168
+ def check_coverage(app: App, plugin: ApprovalPlugin) -> None:
169
+ """Raise ``UnsupportedToolError`` where calls would not reach ``plugin``.
170
+
171
+ Checks that the plugin is the app's first plugin, and walks every agent,
172
+ sub-agent and agent used as a tool for code executors, agents used as
173
+ tools without the app's plugins, and remote agents. Gemini built-in tools
174
+ are refused when the model is called.
175
+ """
176
+ if not app.plugins or app.plugins[0] is not plugin:
177
+ raise UnsupportedToolError("Register the approval plugin first in App(plugins=[...]).")
178
+ seen: set[int] = set()
179
+
180
+ def walk(agent: BaseAgent) -> None:
181
+ if id(agent) in seen:
182
+ return
183
+ seen.add(id(agent))
184
+ if type(agent).__name__ == "RemoteA2aAgent":
185
+ raise UnsupportedToolError(f"Agent {agent.name!r} runs on another server. Add an approval plugin there.")
186
+ _refuse_code_executor(agent)
187
+ if isinstance(agent, LlmAgent):
188
+ for tool in agent.tools:
189
+ if isinstance(tool, AgentTool):
190
+ if not tool.include_plugins:
191
+ raise UnsupportedToolError(f"Agent tool {tool.name!r} runs without the app's plugins. Remove include_plugins=False.")
192
+ walk(tool.agent)
193
+ for sub in agent.sub_agents:
194
+ walk(sub)
195
+
196
+ if app.root_agent is not None:
197
+ walk(app.root_agent) # type: ignore[arg-type]
198
+
199
+
200
+ def waiting(events: Iterable[Event], request_id: str) -> list[str]:
201
+ """IDs of the confirmation calls to answer to resume calls waiting on one request.
202
+
203
+ A call is waiting when its latest response is a pause on this request.
204
+ ADK re-runs a call when its latest confirmation is answered, even again,
205
+ so a call paused a second time is resumed through the same confirmation.
206
+ """
207
+ events = list(events)
208
+ last: dict[str, Event] = {}
209
+ confirmations: dict[str, str] = {}
210
+ for event in events:
211
+ for response in event.get_function_responses():
212
+ if response.id and response.name != CONFIRMATION:
213
+ last[response.id] = event
214
+ for call in event.get_function_calls():
215
+ original = (call.args or {}).get("originalFunctionCall") if call.name == CONFIRMATION else None
216
+ if call.id and isinstance(original, Mapping) and isinstance(original.get("id"), str):
217
+ confirmations[original["id"]] = call.id
218
+ out = []
219
+ for call_id, event in last.items():
220
+ pause = (event.actions.requested_tool_confirmations or {}).get(call_id)
221
+ payload = getattr(pause, "payload", None)
222
+ if isinstance(payload, Mapping) and payload.get(PAYLOAD_KEY) == request_id and call_id in confirmations:
223
+ out.append(confirmations[call_id])
224
+ return out
225
+
226
+
227
+ def wake_message(events: Iterable[Event], request_id: str) -> types.Content | None:
228
+ """The message that resumes a session paused on one request, or None.
229
+
230
+ Send it as ``new_message`` to the runner, or to an ADK API server's
231
+ ``/run`` endpoint. The plugin asks the provider again on resume, so the
232
+ message itself approves nothing.
233
+ """
234
+ ids = waiting(events, request_id)
235
+ if not ids:
236
+ return None
237
+ parts = [types.Part(function_response=types.FunctionResponse(name=CONFIRMATION, id=i, response={"confirmed": True})) for i in ids]
238
+ return types.Content(role="user", parts=parts)
239
+
240
+
241
+ def resumer(runner: Any, *, run_config: Any = None) -> Callable[[Mapping[str, Any], Request], Awaitable[None]]:
242
+ """A ``Receiver`` resumer for a ``Runner`` in this process.
243
+
244
+ It resumes only the confirmations waiting on the decided request, one
245
+ resume per session at a time. The resumed run continues here, so read
246
+ results from the session, not from the run that started it.
247
+ """
248
+ locks: dict[tuple[str, str], asyncio.Lock] = {}
249
+
250
+ async def resume(handle: Mapping[str, Any], request: Request) -> None:
251
+ user, session_id = handle.get("user_id"), handle.get("session_id")
252
+ if handle.get("app_name") != runner.app_name or not isinstance(user, str) or not isinstance(session_id, str):
253
+ return
254
+ async with locks.setdefault((user, session_id), asyncio.Lock()):
255
+ session = await runner.session_service.get_session(app_name=runner.app_name, user_id=user, session_id=session_id)
256
+ message = wake_message(session.events, request.id) if session else None
257
+ if message is None:
258
+ return
259
+ async for _ in runner.run_async(user_id=user, session_id=session_id, new_message=message, run_config=run_config):
260
+ pass
261
+
262
+ return resume