diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index c7ff33fcf7..39ca8b59df 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -24,3 +24,13 @@ from agent_framework.github import GitHubCopilotAgent # or directly: from agent_framework_github_copilot import GitHubCopilotAgent ``` + +## Session Option Defaults + +`_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get +an explicit default. Besides `on_permission_request` (deny-all), the options listed in +`_WORKSPACE_CONFIG_DEFAULTS` — currently `enable_file_hooks` — default to `False` so a +session behaves the same way in every working directory. Callers opt in through +`default_options` or per-run options. Add to that dict rather than hard-coding a default +inline, and keep it to options the working directory controls: options that only shape +prompt context (for example `enable_host_git_operations`) are deliberately left alone. diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 4d07cabcb8..6bb24aad4a 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -59,3 +59,26 @@ model described above. When `on_function_approval` is set, it gates `always_requ the default ask-hook is not installed. It is **mutually exclusive** with `on_pre_tool_use` — setting both (whether at construction or per run) raises `ValueError`. +## Workspace-driven session options + +`on_permission_request` and `on_pre_tool_use` gate **tool calls**. They do not cover +configuration the CLI picks up from the working directory it runs in, which is a separate +mechanism with its own switches. + +So that a session behaves the same way in every checkout, `GitHubCopilotAgent` leaves the +following off by default: + +| Option | Default | Effect when enabled | +| --- | --- | --- | +| `enable_file_hooks` | `False` | The CLI loads file hooks from the working directory's `.github/hooks/` and runs the commands they define, independently of the tool-approval path. | + +Opt in per agent or per run when your workflow needs the checkout to drive the session: + +```python +agent = GitHubCopilotAgent( + default_options=GitHubCopilotOptions(enable_file_hooks=True), +) +``` + +Only enable these for a working directory whose contents you trust to act on the host. + diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 883810f9fc..f8958e3c53 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -140,6 +140,15 @@ def _deny_all_permissions( return PermissionDecisionUserNotAvailable() +# Session options that let the working directory's checked-in configuration influence what +# the CLI does on the host. The agent leaves them off so a session behaves the same way in +# every checkout; callers that want the checkout-driven behavior can turn each one back on +# through ``default_options`` or per-run options. +_WORKSPACE_CONFIG_DEFAULTS: dict[str, bool] = { + "enable_file_hooks": False, +} + + class GitHubCopilotSettings(TypedDict, total=False): """GitHub Copilot model settings. @@ -239,6 +248,14 @@ class GitHubCopilotOptions(TypedDict, total=False): base_directory: str """Directory where the CLI stores session state, configuration, and other persistent data.""" + enable_file_hooks: bool + """Whether the CLI loads file hooks from the working directory's ``.github/hooks/``. + + Defaults to ``False``: hook definitions checked into the working directory are ignored + unless you opt in, so a session behaves the same way regardless of which checkout it + runs in. Unrelated to the SDK callback hooks configured through ``on_pre_tool_use``. + """ + on_pre_tool_use: PreToolUseHandler """Pre-tool-use hook handler for the Copilot SDK. @@ -1200,8 +1217,9 @@ def _build_session_kwargs( ``runtime_options`` which override them. Every key is forwarded verbatim to the Copilot SDK, so any ``create_session`` parameter is supported without a dedicated mapping here (an unknown name surfaces as a ``TypeError`` from the - SDK). A few keys are handled specially because they need a secure default - (``on_permission_request`` defaults to denying all requests) or transforming: + SDK). A few keys are handled specially because they need a specific default + (``on_permission_request`` defaults to denying all requests, and the options in + ``_WORKSPACE_CONFIG_DEFAULTS`` default to off) or transforming: ``tools`` are merged with the agent's tools and converted to SDK tools, and approval callbacks are turned into ``hooks``. @@ -1230,6 +1248,10 @@ def _build_session_kwargs( kwargs["on_permission_request"] = ( opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions ) + # Workspace-driven session options stay off unless the caller opts in (either layer). + for option, value in _WORKSPACE_CONFIG_DEFAULTS.items(): + if kwargs.get(option) is None: + kwargs[option] = value kwargs["hooks"] = self._build_session_hooks(all_tools, kwargs) # Strip agent-internal and client-level keys that are consumed here or in the diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 5e4ff52e05..ceae360266 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1124,6 +1124,7 @@ async def test_session_resumed_for_same_session( streaming=unittest.mock.ANY, model=unittest.mock.ANY, on_permission_request=unittest.mock.ANY, + enable_file_hooks=unittest.mock.ANY, hooks=unittest.mock.ANY, ) @@ -1904,6 +1905,55 @@ def runtime_hook(_input: Any, _context: Any) -> Any: # on_pre_tool_use is still honored via the hooks parameter. assert config["hooks"]["on_pre_tool_use"] is runtime_hook + async def test_workspace_config_options_default_to_off( + self, + mock_client: MagicMock, + ) -> None: + """Workspace-driven options are disabled unless the caller opts in.""" + agent = GitHubCopilotAgent(client=mock_client) + await agent.start() + + await agent._get_or_create_session(AgentSession()) # type: ignore[reportPrivateUsage] + + config = mock_client.create_session.call_args.kwargs + assert config["enable_file_hooks"] is False + # Options that only shape prompt context are left untouched. + assert "enable_host_git_operations" not in config + + async def test_workspace_config_options_honor_default_options( + self, + mock_client: MagicMock, + ) -> None: + """A caller opting in through default_options is not overridden.""" + agent = GitHubCopilotAgent( + client=mock_client, + default_options=cast(Any, {"enable_file_hooks": True}), + ) + await agent.start() + + await agent._get_or_create_session(AgentSession()) # type: ignore[reportPrivateUsage] + + config = mock_client.create_session.call_args.kwargs + assert config["enable_file_hooks"] is True + + async def test_workspace_config_options_honor_runtime_options( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Per-run options override the agent-level value for workspace-driven options.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GitHubCopilotAgent( + client=mock_client, + default_options=cast(Any, {"enable_file_hooks": True}), + ) + await agent.run("hello", options=cast(Any, {"enable_file_hooks": False})) + + config = mock_client.create_session.call_args.kwargs + assert config["enable_file_hooks"] is False + class TestGitHubCopilotAgentToolConversion: """Test cases for tool conversion."""