77import inspect
88import logging
99import sys
10+ import warnings
1011from collections .abc import AsyncIterable , Awaitable , Callable , Mapping , MutableMapping , Sequence
1112from typing import Any , ClassVar , Generic , Literal , TypedDict , overload
1213
3940try :
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
6574
6675
6776FunctionApprovalCallback = 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
7085The callback receives a ``FunctionCallContent`` describing the pending call
7186(``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
7287to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
7388return 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
217244OptionsT = 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