Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,18 @@ class GitHubCopilotSettings(TypedDict, total=False):
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
log_level: CLI log level.
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
copilot_home: Directory where the CLI stores session state, configuration,
and other persistent data. Can be set via environment variable
GITHUB_COPILOT_COPILOT_HOME. Defaults to ~/.copilot when not set.
Only applicable when the SDK spawns the CLI process (ignored when
connecting to an external server via a pre-configured client).
"""

cli_path: str | None
model: str | None
timeout: float | None
log_level: str | None
copilot_home: str | None


class GitHubCopilotOptions(TypedDict, total=False):
Expand All @@ -168,6 +174,12 @@ class GitHubCopilotOptions(TypedDict, total=False):
log_level: str
"""CLI log level. Defaults to GITHUB_COPILOT_LOG_LEVEL environment variable."""

copilot_home: str
"""Directory where the CLI stores session state, configuration, and other
persistent data. Defaults to ~/.copilot when not set. Only applicable when
the SDK spawns the CLI process (ignored when connecting to an external server
via a pre-configured client)."""

on_permission_request: PermissionHandlerType
"""Permission request handler.
Called when Copilot requests permission to perform an action (shell, read, write, etc.).
Expand All @@ -187,6 +199,12 @@ class GitHubCopilotOptions(TypedDict, total=False):
instead of the default GitHub Copilot backend.
"""

instruction_directories: list[str]
"""Additional directories to search for custom instruction files.
Lets applications point the CLI at project-specific or team-shared instruction
files beyond the default locations.
"""

on_function_approval: FunctionApprovalCallback
"""Approval callback for ``FunctionTool`` instances declared with
``approval_mode="always_require"``. The callback is awaited (sync or async)
Expand Down Expand Up @@ -300,7 +318,9 @@ def __init__(
on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
provider: ProviderConfig | None = opts.pop("provider", None)
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
copilot_home = opts.pop("copilot_home", None)

self._settings = load_settings(
GitHubCopilotSettings,
Expand All @@ -309,6 +329,7 @@ def __init__(
model=model,
timeout=timeout,
log_level=log_level,
copilot_home=copilot_home,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
Expand All @@ -318,6 +339,7 @@ def __init__(
self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
self._mcp_servers = mcp_servers
self._provider = provider
self._instruction_directories = instruction_directories
self._default_options = opts
self._started = False

Expand Down Expand Up @@ -346,10 +368,13 @@ async def start(self) -> None:
if self._client is None:
cli_path = self._settings.get("cli_path") or None
log_level = self._settings.get("log_level") or None
copilot_home = self._settings.get("copilot_home") or None

subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
if log_level:
subprocess_kwargs["log_level"] = log_level
if copilot_home:
subprocess_kwargs["copilot_home"] = copilot_home
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))

try:
Expand Down Expand Up @@ -523,13 +548,14 @@ async def _run_impl(
# send_and_wait returns only the final ASSISTANT_MESSAGE event;
# other events (deltas, tool calls) are handled internally by the SDK.
if response_event and response_event.type == SessionEventType.ASSISTANT_MESSAGE:
message_id = response_event.data.message_id
data: Any = response_event.data
message_id = data.message_id

if response_event.data.content:
if data.content:
response_messages.append(
Message(
role="assistant",
contents=[Content.from_text(response_event.data.content)],
contents=[Content.from_text(data.content)],
message_id=message_id,
raw_representation=response_event,
)
Expand Down Expand Up @@ -603,12 +629,13 @@ async def _stream_updates(

def event_handler(event: SessionEvent) -> None:
if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA:
if event.data.delta_content:
data: Any = event.data
if data.delta_content:
update = AgentResponseUpdate(
role="assistant",
contents=[Content.from_text(event.data.delta_content)],
response_id=event.data.message_id,
message_id=event.data.message_id,
contents=[Content.from_text(data.delta_content)],
response_id=data.message_id,
message_id=data.message_id,
raw_representation=event,
)
queue.put_nowait(update)
Expand Down Expand Up @@ -652,7 +679,8 @@ def event_handler(event: SessionEvent) -> None:
elif event.type == SessionEventType.SESSION_IDLE:
queue.put_nowait(None)
elif event.type == SessionEventType.SESSION_ERROR:
error_msg = event.data.message or "Unknown error"
error_data: Any = event.data
error_msg = error_data.message or "Unknown error"
queue.put_nowait(AgentException(f"GitHub Copilot session error: {error_msg}"))

unsubscribe = copilot_session.on(event_handler)
Expand Down Expand Up @@ -868,6 +896,7 @@ async def _create_session(
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
provider = opts.get("provider") or self._provider or None
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
tools = self._prepare_tools(self._tools) if self._tools else None

Comment thread
giles17 marked this conversation as resolved.
return await self._client.create_session(
Expand All @@ -878,6 +907,7 @@ async def _create_session(
tools=tools or None,
mcp_servers=mcp_servers or None,
provider=provider or None,
instruction_directories=instruction_directories,
)

async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession:
Expand Down
2 changes: 1 addition & 1 deletion python/packages/github_copilot/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.2.2,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
"github-copilot-sdk>=1.0.0b1,<=1.0.0b1; python_version >= '3.11'",
]

[tool.uv]
Expand Down
147 changes: 140 additions & 7 deletions python/packages/github_copilot/tests/test_github_copilot_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@
Message,
)
from agent_framework.exceptions import AgentException
from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType
from copilot.generated.session_events import (
Data,
SessionEvent,
SessionEventType,
ToolExecutionCompleteError,
ToolExecutionCompleteResult,
)
from copilot.tools import ToolInvocation, ToolResult

from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
Expand Down Expand Up @@ -212,6 +218,18 @@ def test_default_options_returns_independent_copy(self) -> None:
opts["model"] = "mutated"
assert agent._settings.get("model") == "gpt-5.1-mini"

def test_init_stores_instruction_directories(self) -> None:
"""Test that instruction_directories are stored on the agent instance."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"instruction_directories": ["/my/instructions"]}
)
assert agent._instruction_directories == ["/my/instructions"] # type: ignore

def test_init_without_instruction_directories(self) -> None:
"""Test that instruction_directories default to None when not provided."""
agent = GitHubCopilotAgent()
assert agent._instruction_directories is None # type: ignore


class TestGitHubCopilotAgentLifecycle:
"""Test cases for agent lifecycle management."""
Expand Down Expand Up @@ -294,6 +312,50 @@ async def test_start_creates_client_with_options(self) -> None:
assert call_args.cli_path == "/custom/path"
assert call_args.log_level == "debug"

async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
"""Test that copilot_home is passed through to SubprocessConfig."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client

agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"copilot_home": "/custom/copilot/home"}
)
await agent.start()

call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/custom/copilot/home"

async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client

agent = GitHubCopilotAgent()
await agent.start()

call_args = MockClient.call_args[0][0]
assert call_args.copilot_home is None

async def test_start_copilot_home_from_env_variable(self) -> None:
"""Test that copilot_home can be set via GITHUB_COPILOT_COPILOT_HOME env variable."""
with (
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
):
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client

agent = GitHubCopilotAgent()
await agent.start()

call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/env/copilot/home"


class TestGitHubCopilotAgentRun:
"""Test cases for run method."""
Expand Down Expand Up @@ -537,7 +599,7 @@ async def test_run_streaming_tool_execution_complete(
"""Test that TOOL_EXECUTION_COMPLETE events produce function_result content."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_abc123"
tool_event_data.result = Result(content="Sunny, 72°F")
tool_event_data.result = ToolExecutionCompleteResult(content="Sunny, 72°F")
tool_event_data.success = True
tool_event_data.error = None

Expand Down Expand Up @@ -652,9 +714,9 @@ async def test_run_streaming_tool_execution_failure(
"""Test that a failed tool result surfaces the error as exception."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_fail"
tool_event_data.result = Result(content="Error: connection timeout")
tool_event_data.result = ToolExecutionCompleteResult(content="Error: connection timeout")
tool_event_data.success = False
tool_event_data.error = ErrorClass(message="connection timeout")
tool_event_data.error = ToolExecutionCompleteError(message="connection timeout")

tool_event = SessionEvent(
data=tool_event_data,
Expand Down Expand Up @@ -691,7 +753,7 @@ async def test_run_streaming_tool_execution_failure_string_error(
"""Test that a failed tool result with a string error is surfaced."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_fail2"
tool_event_data.result = Result(content="")
tool_event_data.result = ToolExecutionCompleteResult(content="")
tool_event_data.success = False
tool_event_data.error = "something went wrong"

Expand Down Expand Up @@ -729,7 +791,7 @@ async def test_run_streaming_tool_execution_success_with_error_field(
"""Test that a successful tool result with error field does not propagate exception."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_ok"
tool_event_data.result = Result(content="partial result")
tool_event_data.result = ToolExecutionCompleteResult(content="partial result")
tool_event_data.success = True
tool_event_data.error = "some warning"

Expand Down Expand Up @@ -817,7 +879,7 @@ async def test_run_streaming_tool_call_and_result_sequence(
# Tool result event
result_data = MagicMock()
result_data.tool_call_id = "call_001"
result_data.result = Result(content="72°F and sunny")
result_data.result = ToolExecutionCompleteResult(content="72°F and sunny")
result_data.success = True
result_data.error = None
tool_result_event = SessionEvent(
Expand Down Expand Up @@ -1016,6 +1078,77 @@ def my_tool(arg: str) -> str:
assert "tools" in config
assert "on_permission_request" in config

async def test_instruction_directories_passed_to_create_session(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories are passed through to create_session."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/path/to/instructions", "/other/path"]},
)
await agent.start()

await agent._get_or_create_session(AgentSession()) # type: ignore

call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/path/to/instructions", "/other/path"]

async def test_instruction_directories_runtime_override(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that runtime instruction_directories take precedence over defaults."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()

runtime_options: GitHubCopilotOptions = {"instruction_directories": ["/runtime/path"]}
await agent._get_or_create_session(AgentSession(), runtime_options=runtime_options) # type: ignore

call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/runtime/path"]

async def test_instruction_directories_none_when_not_specified(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories is None when not specified."""
agent = GitHubCopilotAgent(client=mock_client)
await agent.start()

await agent._get_or_create_session(AgentSession()) # type: ignore

call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] is None

async def test_instruction_directories_empty_list_clears_defaults(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that an explicit empty list at runtime clears the agent-level defaults."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()

runtime_options: GitHubCopilotOptions = {"instruction_directories": []}
await agent._get_or_create_session(AgentSession(), runtime_options=runtime_options) # type: ignore

call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == []


class TestGitHubCopilotAgentMCPServers:
"""Test cases for MCP server configuration."""
Expand Down
2 changes: 2 additions & 0 deletions python/samples/02-agents/providers/github_copilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The following environment variables can be configured:
| `GITHUB_COPILOT_MODEL` | Model to use (e.g., "gpt-5", "claude-sonnet-4") | Server default |
| `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` |
| `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` |
| `GITHUB_COPILOT_COPILOT_HOME` | Directory for CLI session state and config | `~/.copilot` |

## Observability

Expand Down Expand Up @@ -50,4 +51,5 @@ See the [observability samples](../../../02-agents/observability/) for full exam
| [`github_copilot_with_file_operations.py`](github_copilot_with_file_operations.py) | Shows how to enable file read and write permissions. Demonstrates reading file contents and creating new files. |
| [`github_copilot_with_url.py`](github_copilot_with_url.py) | Shows how to enable URL fetching permissions. Demonstrates fetching and processing web content. |
| [`github_copilot_with_mcp.py`](github_copilot_with_mcp.py) | Shows how to configure MCP (Model Context Protocol) servers, including local (stdio) and remote (HTTP) servers. |
| [`github_copilot_with_instruction_directories.py`](github_copilot_with_instruction_directories.py) | Shows how to configure custom instruction directories for project-specific or team-shared guidelines. |
| [`github_copilot_with_multiple_permissions.py`](github_copilot_with_multiple_permissions.py) | Shows how to combine multiple permission types for complex tasks that require shell, read, and write access. |
Loading
Loading