Skip to content

Commit 4cb1a66

Browse files
giles17Copilot
andauthored
Python: Align GitHub Copilot provider function approval to use SDK on_pre_tool_use hook (microsoft#6750)
* Python: align GitHub Copilot approval to SDK on_pre_tool_use hook Replace the bespoke on_function_approval enforcement in the GitHub Copilot provider with the Copilot SDK's native on_pre_tool_use hook. When no caller hook is supplied, a default hook returns 'ask' for approval_mode='always_require' tools (routed to on_permission_request) and defers others; a caller-supplied on_pre_tool_use takes precedence and logs a warning for any unenforced approval tool. Fixes microsoft#6746 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix type-checker errors and restore load_dotenv in sample Use a complete PreToolUseHookInput in on_pre_tool_use hook tests so pyright/pyrefly/ty/zuban no longer report missing required TypedDict keys. Restore load_dotenv() in the function-approval sample for consistency with the other GitHub Copilot samples (PR review feedback). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Deprecate on_function_approval instead of removing it Per PR review feedback, keep the on_function_approval callback working (still enforced in the tool handler for approval_mode='always_require' tools) but emit a DeprecationWarning at construction, so existing users get a signal rather than a silent behavior change. The default on_pre_tool_use ask-hook is not installed when on_function_approval is set, avoiding double-gating. Precedence: user on_pre_tool_use > on_function_approval > default ask-hook. Adds tests for the deprecated path and documents it in the package README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make on_function_approval and on_pre_tool_use mutually exclusive Per automated review feedback, instead of a precedence ordering between the deprecated on_function_approval callback and the new on_pre_tool_use hook (which silently double-gated when both were set), raise ValueError if both are supplied - at construction (both in default_options) or per run (per-run on_pre_tool_use with a construction-time on_function_approval). This matches the repo convention for deprecated-vs-new params (see _workflows/_workflow.py) and removes the flag-threading. Updates tests and the package README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3cc511f commit 4cb1a66

4 files changed

Lines changed: 595 additions & 217 deletions

File tree

python/packages/github_copilot/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,53 @@ pip install agent-framework-github-copilot --pre
99
## GitHub Copilot Agent
1010

1111
The GitHub Copilot agent enables integration with GitHub Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework.
12+
13+
## Tool approval (`approval_mode="always_require"`)
14+
15+
The GitHub Copilot SDK owns the tool-calling loop for this provider, so approval for
16+
custom function tools is enforced through the SDK's native pre-execution hook rather
17+
than the standard Agent Framework approval round-trip.
18+
19+
When you register a `FunctionTool` declared with `approval_mode="always_require"` and you
20+
do **not** supply your own `on_pre_tool_use` hook, `GitHubCopilotAgent` installs a default
21+
`on_pre_tool_use` hook that returns `"ask"` for that tool and defers (`None`) for all other
22+
tools. The `"ask"` decision routes to your `on_permission_request` handler, where you
23+
approve or deny the call:
24+
25+
```python
26+
from agent_framework import tool
27+
from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions
28+
from copilot.session import PermissionHandler
29+
30+
31+
@tool(approval_mode="always_require")
32+
def delete_file(path: str) -> str:
33+
"""Delete a file."""
34+
...
35+
36+
37+
agent = GitHubCopilotAgent(
38+
tools=[delete_file],
39+
# The "ask" decision is routed here; approve or deny the call.
40+
default_options=GitHubCopilotOptions(on_permission_request=PermissionHandler.approve_all),
41+
)
42+
```
43+
44+
> **⚠️ If you provide your own `on_pre_tool_use` hook**, it takes precedence and the agent
45+
> does **not** install its default approval hook. In that case **you are fully responsible**
46+
> for enforcing approval — including for any `approval_mode="always_require"` tool (e.g. by
47+
> returning a `"deny"` or `"ask"` decision). The agent logs a warning naming any
48+
> approval-required tool that your hook must handle.
49+
>
50+
> Note: with the default (deny-all) permission handler, an `always_require` tool is denied
51+
> unless you wire an approving `on_permission_request`.
52+
53+
### Deprecated: `on_function_approval`
54+
55+
The `on_function_approval` callback is **deprecated**. It still works (and is still enforced
56+
inside the tool handler for backward compatibility), but it emits a `DeprecationWarning` and
57+
will be removed in a future version. Migrate to the `on_pre_tool_use` + `on_permission_request`
58+
model described above. When `on_function_approval` is set, it gates `always_require` tools and
59+
the default ask-hook is not installed. It is **mutually exclusive** with `on_pre_tool_use`
60+
setting both (whether at construction or per run) raises `ValueError`.
61+

python/packages/github_copilot/agent_framework_github_copilot/_agent.py

Lines changed: 173 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import inspect
88
import logging
99
import sys
10+
import warnings
1011
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
1112
from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
1213

@@ -39,7 +40,15 @@
3940
try:
4041
from copilot import CopilotClient, CopilotSession, RuntimeConnection
4142
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
42-
from copilot.session import MCPServerConfig, PermissionRequestResult, ProviderConfig, SystemMessageConfig
43+
from copilot.session import (
44+
MCPServerConfig,
45+
PermissionRequestResult,
46+
PreToolUseHandler,
47+
PreToolUseHookOutput,
48+
ProviderConfig,
49+
SessionHooks,
50+
SystemMessageConfig,
51+
)
4352
from copilot.session_events import PermissionRequest, SessionEvent, SessionEventType
4453
from copilot.tools import Tool as CopilotTool
4554
from copilot.tools import ToolInvocation, ToolResult
@@ -65,23 +74,18 @@
6574

6675

6776
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
68-
"""Callback invoked by the agent before executing a FunctionTool that requires approval.
77+
"""Deprecated approval callback for ``FunctionTool`` instances declared with
78+
``approval_mode="always_require"``.
79+
80+
.. deprecated::
81+
Use the SDK ``on_pre_tool_use`` hook together with ``on_permission_request``
82+
instead. The default ``on_pre_tool_use`` hook returns ``"ask"`` for
83+
``always_require`` tools and routes the decision to ``on_permission_request``.
6984
7085
The callback receives a ``FunctionCallContent`` describing the pending call
7186
(``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
7287
to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
7388
return values are supported.
74-
75-
The Copilot CLI manages its own tool-calling loop, so the framework cannot
76-
round-trip a ``FunctionApprovalRequestContent`` / ``FunctionApprovalResponseContent``
77-
pair the way the standard chat-client pipeline does. This callback is the
78-
agent-level enforcement point for tools declared with
79-
``approval_mode="always_require"``: when no callback is configured the agent
80-
denies these calls by default.
81-
82-
Note: this is independent of ``on_permission_request``, which gates the
83-
Copilot SDK's *built-in* shell/file actions; ``on_function_approval`` gates
84-
agent-framework ``FunctionTool`` calls.
8589
"""
8690

8791

@@ -90,7 +94,7 @@ async def _resolve_function_approval(
9094
func_tool: FunctionTool,
9195
arguments: Mapping[str, Any] | None,
9296
) -> bool:
93-
"""Run the agent-level approval callback for a pending tool call.
97+
"""Run the deprecated agent-level approval callback for a pending tool call.
9498
9599
Returns ``True`` only when ``callback`` is configured and explicitly returns
96100
a truthy value. A missing callback or any callback failure is treated as a
@@ -205,13 +209,36 @@ class GitHubCopilotOptions(TypedDict, total=False):
205209
base_directory: str
206210
"""Directory where the CLI stores session state, configuration, and other persistent data."""
207211

212+
on_pre_tool_use: PreToolUseHandler
213+
"""Pre-tool-use hook handler for the Copilot SDK.
214+
215+
Called by the Copilot SDK before any tool is executed. The handler receives a
216+
``PreToolUseHookInput`` and a context dict, and returns a ``PreToolUseHookOutput``
217+
(or ``None`` to defer). Returning ``{"permissionDecision": "ask"}`` routes the
218+
decision to ``on_permission_request``; ``"allow"`` / ``"deny"`` gate the call
219+
directly.
220+
221+
If you do **not** supply this hook, the agent installs a default ``on_pre_tool_use``
222+
hook that returns ``"ask"`` for ``FunctionTool`` instances declared with
223+
``approval_mode="always_require"`` (deferring all other tools), so those tools are
224+
gated through ``on_permission_request``. If you **do** supply your own hook, it
225+
takes precedence and **you** are responsible for enforcing approval for any
226+
``always_require`` tool; the agent logs a warning naming such tools."""
227+
208228
on_function_approval: FunctionApprovalCallback
209-
"""Approval callback for ``FunctionTool`` instances declared with
210-
``approval_mode="always_require"``. The callback is awaited (sync or async)
211-
inside the SDK tool-handler before the tool is executed; a falsy return
212-
value denies the call. If omitted, calls to such tools are denied with an
213-
explanatory message returned to the model. This is independent of
214-
``on_permission_request``, which gates the Copilot SDK's built-in actions."""
229+
"""Deprecated approval callback for ``FunctionTool`` instances declared with
230+
``approval_mode="always_require"``.
231+
232+
.. deprecated::
233+
Use ``on_pre_tool_use`` together with ``on_permission_request`` instead.
234+
When neither this callback nor ``on_pre_tool_use`` is set, the agent
235+
installs a default ``on_pre_tool_use`` hook that returns ``"ask"`` for
236+
``always_require`` tools and routes the decision to ``on_permission_request``.
237+
238+
When set, this callback is enforced inside the SDK tool-handler before the tool
239+
runs; a falsy return value denies the call. Setting it emits a
240+
``DeprecationWarning``. It is **mutually exclusive** with ``on_pre_tool_use`` —
241+
setting both raises ``ValueError``."""
215242

216243

217244
OptionsT = TypeVar(
@@ -319,9 +346,27 @@ def __init__(
319346
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
320347
provider: ProviderConfig | None = opts.pop("provider", None)
321348
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
349+
on_pre_tool_use: PreToolUseHandler | None = opts.pop("on_pre_tool_use", None)
322350
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
323351
base_directory = opts.pop("base_directory", None)
324352

353+
if on_function_approval is not None and on_pre_tool_use is not None:
354+
raise ValueError(
355+
"on_function_approval and on_pre_tool_use cannot both be set. "
356+
"on_function_approval is deprecated; use on_pre_tool_use together with "
357+
"on_permission_request instead."
358+
)
359+
360+
if on_function_approval is not None:
361+
warnings.warn(
362+
"on_function_approval is deprecated and will be removed in a future version. "
363+
"Use the SDK 'on_pre_tool_use' hook together with 'on_permission_request' instead: "
364+
"the default 'on_pre_tool_use' hook returns 'ask' for approval_mode='always_require' "
365+
"tools and routes the decision to 'on_permission_request'.",
366+
DeprecationWarning,
367+
stacklevel=2,
368+
)
369+
325370
self._settings = load_settings(
326371
GitHubCopilotSettings,
327372
env_prefix="GITHUB_COPILOT_",
@@ -336,6 +381,7 @@ def __init__(
336381

337382
self._tools = normalize_tools(tools)
338383
self._permission_handler = on_permission_request
384+
self._on_pre_tool_use: PreToolUseHandler | None = on_pre_tool_use
339385
self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
340386
self._mcp_servers = mcp_servers
341387
self._provider = provider
@@ -522,6 +568,12 @@ async def _run_impl(
522568
"via default_options at agent construction time. It cannot be overridden "
523569
"per run."
524570
)
571+
if "on_pre_tool_use" in opts and self._function_approval_handler is not None:
572+
raise ValueError(
573+
"on_pre_tool_use cannot be combined with the deprecated on_function_approval "
574+
"(set via default_options). Remove on_function_approval and use on_pre_tool_use "
575+
"together with on_permission_request instead."
576+
)
525577
timeout = opts.get("timeout") or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS
526578

527579
input_messages = normalize_messages(messages)
@@ -611,6 +663,12 @@ async def _stream_updates(
611663
"via default_options at agent construction time. It cannot be overridden "
612664
"per run."
613665
)
666+
if "on_pre_tool_use" in opts and self._function_approval_handler is not None:
667+
raise ValueError(
668+
"on_pre_tool_use cannot be combined with the deprecated on_function_approval "
669+
"(set via default_options). Remove on_function_approval and use on_pre_tool_use "
670+
"together with on_permission_request instead."
671+
)
614672

615673
input_messages = normalize_messages(messages)
616674

@@ -792,31 +850,32 @@ def _prepare_tools(
792850
return copilot_tools
793851

794852
def _tool_to_copilot_tool(self, ai_func: FunctionTool) -> CopilotTool:
795-
"""Convert an FunctionTool to a Copilot SDK tool."""
853+
"""Convert an FunctionTool to a Copilot SDK tool.
854+
855+
Approval for tools declared with ``approval_mode="always_require"`` is normally
856+
enforced by the Copilot SDK's native ``on_pre_tool_use`` hook (see
857+
:meth:`_build_session_hooks`). When the deprecated ``on_function_approval``
858+
callback is configured instead, approval is enforced inside this handler for
859+
backward compatibility. (``on_function_approval`` and ``on_pre_tool_use`` are
860+
mutually exclusive, so only one mechanism is ever active.)
861+
"""
796862
approval_handler = self._function_approval_handler
797-
requires_approval = ai_func.approval_mode == "always_require"
863+
enforce = approval_handler is not None and ai_func.approval_mode == "always_require"
798864

799865
async def handler(invocation: ToolInvocation) -> ToolResult:
800866
args: dict[str, Any] = invocation.arguments or {}
801867
try:
802-
if requires_approval and not await _resolve_function_approval(approval_handler, ai_func, args):
803-
deny_text = (
804-
f"Tool '{ai_func.name}' requires human approval "
805-
"(approval_mode='always_require') and the request was denied."
806-
if approval_handler is not None
807-
else (
808-
f"Tool '{ai_func.name}' requires human approval "
809-
"(approval_mode='always_require') but no on_function_approval "
810-
"callback is configured on the agent; the request was denied."
811-
)
812-
)
868+
if enforce and not await _resolve_function_approval(approval_handler, ai_func, args):
813869
logger.info(
814-
"Denying execution of tool '%s' (approval_mode='always_require', %s)",
870+
"Denying execution of tool '%s' (approval_mode='always_require', "
871+
"on_function_approval callback denied).",
815872
ai_func.name,
816-
"callback denied" if approval_handler is not None else "no callback configured",
817873
)
818874
return ToolResult(
819-
text_result_for_llm=deny_text,
875+
text_result_for_llm=(
876+
f"Tool '{ai_func.name}' requires human approval "
877+
"(approval_mode='always_require') and the request was denied."
878+
),
820879
result_type="failure",
821880
error="approval_denied",
822881
)
@@ -850,6 +909,80 @@ async def handler(invocation: ToolInvocation) -> ToolResult:
850909
parameters=ai_func.parameters(),
851910
)
852911

912+
def _build_session_hooks(
913+
self,
914+
all_tools: Sequence[ToolTypes | CopilotTool],
915+
opts: Mapping[str, Any],
916+
) -> SessionHooks | None:
917+
"""Build the ``SessionHooks`` to pass to the Copilot SDK for this session.
918+
919+
Approval enforcement for ``FunctionTool`` instances declared with
920+
``approval_mode="always_require"`` is delegated to the Copilot SDK's native
921+
``on_pre_tool_use`` hook:
922+
923+
- If the caller supplies their own ``on_pre_tool_use`` (via per-run ``options``
924+
or ``default_options``), it takes precedence and is returned unchanged. A
925+
warning is logged naming any approval-required tool that will therefore not
926+
be automatically gated, since the caller's hook is responsible for enforcing
927+
approval.
928+
- Otherwise, when any approval-required tool is present, a default hook is
929+
installed that returns ``"ask"`` for those tools (routing the decision to
930+
``on_permission_request``) and defers (``None``) for all other tools.
931+
- The default hook is **not** installed when the deprecated
932+
``on_function_approval`` callback is configured: in that case approval is
933+
enforced inside the tool handler (see :meth:`_tool_to_copilot_tool`) to
934+
preserve backward-compatible behavior.
935+
- When there are no approval-required tools and no caller hook, ``None`` is
936+
returned so no hooks are registered.
937+
938+
Args:
939+
all_tools: The full set of tools resolved for the session.
940+
opts: Runtime options that take precedence over ``default_options``.
941+
942+
Returns:
943+
The hooks to register for the session, or ``None`` if none are needed.
944+
"""
945+
user_hook: PreToolUseHandler | None = opts.get("on_pre_tool_use") or self._on_pre_tool_use
946+
947+
approval_required_names = {
948+
tool.name for tool in all_tools if isinstance(tool, FunctionTool) and tool.approval_mode == "always_require"
949+
}
950+
951+
if user_hook is not None:
952+
if approval_required_names:
953+
logger.warning(
954+
"A custom 'on_pre_tool_use' hook is configured, so %d approval-required tool(s) (%s) "
955+
"will not be automatically gated by GitHubCopilotAgent. The custom hook is responsible "
956+
"for enforcing approval (for example, by returning a 'deny' or 'ask' decision).",
957+
len(approval_required_names),
958+
", ".join(sorted(approval_required_names)),
959+
)
960+
return {"on_pre_tool_use": user_hook}
961+
962+
if not approval_required_names:
963+
return None
964+
965+
# The deprecated on_function_approval callback enforces approval in the tool
966+
# handler; don't also install the default ask-hook (which would double-gate).
967+
if self._function_approval_handler is not None:
968+
return None
969+
970+
def default_pre_tool_use(
971+
hook_input: Mapping[str, Any],
972+
_context: Mapping[str, str],
973+
) -> PreToolUseHookOutput | None:
974+
tool_name = hook_input.get("toolName")
975+
if tool_name in approval_required_names:
976+
return {
977+
"permissionDecision": "ask",
978+
"permissionDecisionReason": (
979+
f"Tool '{tool_name}' is marked as requiring approval (approval_mode='always_require')."
980+
),
981+
}
982+
return None
983+
984+
return {"on_pre_tool_use": default_pre_tool_use}
985+
853986
async def _get_or_create_session(
854987
self,
855988
agent_session: AgentSession,
@@ -907,6 +1040,7 @@ async def _create_session(
9071040
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
9081041
all_tools = list(self._tools or []) + list(opts.get("tools") or [])
9091042
tools = self._prepare_tools(all_tools) if all_tools else None
1043+
hooks = self._build_session_hooks(all_tools, opts)
9101044

9111045
return await self._client.create_session(
9121046
on_permission_request=permission_handler,
@@ -917,6 +1051,7 @@ async def _create_session(
9171051
mcp_servers=mcp_servers or None,
9181052
provider=provider or None,
9191053
instruction_directories=instruction_directories,
1054+
hooks=hooks,
9201055
)
9211056

9221057
async def _resume_session(
@@ -946,6 +1081,7 @@ async def _resume_session(
9461081
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
9471082
all_tools = list(self._tools or []) + list(opts.get("tools") or [])
9481083
tools = self._prepare_tools(all_tools) if all_tools else None
1084+
hooks = self._build_session_hooks(all_tools, opts)
9491085

9501086
return await self._client.resume_session(
9511087
session_id,
@@ -957,6 +1093,7 @@ async def _resume_session(
9571093
mcp_servers=mcp_servers or None,
9581094
provider=provider or None,
9591095
instruction_directories=instruction_directories,
1096+
hooks=hooks,
9601097
)
9611098

9621099

0 commit comments

Comments
 (0)