mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Upgrade github-copilot-sdk to v1.0.0b2 with new features (#5665)
* Upgrade github-copilot-sdk to v1.0.0b1 and implement new features - Bump github-copilot-sdk dependency from 0.2.1 to 1.0.0b1 - Fix breaking type renames: ErrorClass -> ToolExecutionCompleteError, Result -> ToolExecutionCompleteResult - Add instruction_directories support in GitHubCopilotOptions (session-level) - Add copilot_home support in GitHubCopilotSettings (client-level) - Add sample: github_copilot_with_instruction_directories.py - Update README with new env var and sample entry - Add 8 new unit tests covering the new features (103 total, 96% coverage) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * mypy fix * small fix * Address PR feedback: fix resume path, remove copilot_home from Options, bump to beta.2 - Forward runtime_options through _resume_session (fixes silent drop of instruction_directories/model/etc on resumed sessions) - Remove copilot_home from GitHubCopilotOptions (client-level setting only consumed at startup, not per-call) - Bump github-copilot-sdk from 1.0.0b1 to 1.0.0b2 - Add test for instruction_directories override on resumed sessions - Update existing resume test to match new _resume_session signature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
3c1e2c40b8
commit
57fb32efc8
@@ -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):
|
||||
@@ -187,6 +193,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)
|
||||
@@ -300,7 +312,9 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
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,
|
||||
@@ -309,6 +323,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
log_level=log_level,
|
||||
copilot_home=copilot_home,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
@@ -318,6 +333,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
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
|
||||
|
||||
@@ -346,10 +362,13 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
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:
|
||||
@@ -523,13 +542,14 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
# 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,
|
||||
)
|
||||
@@ -603,12 +623,13 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
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)
|
||||
@@ -652,7 +673,8 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
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)
|
||||
@@ -838,7 +860,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
try:
|
||||
if agent_session.service_session_id:
|
||||
return await self._resume_session(agent_session.service_session_id, streaming)
|
||||
return await self._resume_session(agent_session.service_session_id, streaming, runtime_options)
|
||||
|
||||
session = await self._create_session(streaming, runtime_options)
|
||||
agent_session.service_session_id = session.session_id
|
||||
@@ -868,6 +890,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
)
|
||||
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
|
||||
|
||||
return await self._client.create_session(
|
||||
@@ -878,23 +901,46 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
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:
|
||||
"""Resume an existing Copilot session by ID."""
|
||||
async def _resume_session(
|
||||
self,
|
||||
session_id: str,
|
||||
streaming: bool,
|
||||
runtime_options: dict[str, Any] | None = None,
|
||||
) -> CopilotSession:
|
||||
"""Resume an existing Copilot session by ID.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to resume.
|
||||
streaming: Whether to enable streaming for the session.
|
||||
runtime_options: Runtime options that take precedence over default_options.
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
|
||||
|
||||
permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions
|
||||
opts = runtime_options or {}
|
||||
model = opts.get("model") or self._settings.get("model") or None
|
||||
system_message = opts.get("system_message") or self._default_options.get("system_message") or None
|
||||
permission_handler: PermissionHandlerType = (
|
||||
opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
|
||||
)
|
||||
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
|
||||
|
||||
return await self._client.resume_session(
|
||||
session_id,
|
||||
on_permission_request=permission_handler,
|
||||
streaming=streaming,
|
||||
model=model or None,
|
||||
system_message=system_message or None,
|
||||
tools=tools or None,
|
||||
mcp_servers=self._mcp_servers or None,
|
||||
provider=self._provider or None,
|
||||
mcp_servers=mcp_servers or None,
|
||||
provider=provider or None,
|
||||
instruction_directories=instruction_directories,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.0b2,<=1.0.0b2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -22,7 +22,13 @@ from agent_framework import (
|
||||
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
|
||||
@@ -212,6 +218,18 @@ class TestGitHubCopilotAgentInit:
|
||||
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."""
|
||||
@@ -294,6 +312,50 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
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."""
|
||||
@@ -537,7 +599,7 @@ class TestGitHubCopilotAgentRunStreaming:
|
||||
"""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
|
||||
|
||||
@@ -652,9 +714,9 @@ class TestGitHubCopilotAgentRunStreaming:
|
||||
"""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,
|
||||
@@ -691,7 +753,7 @@ class TestGitHubCopilotAgentRunStreaming:
|
||||
"""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"
|
||||
|
||||
@@ -729,7 +791,7 @@ class TestGitHubCopilotAgentRunStreaming:
|
||||
"""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"
|
||||
|
||||
@@ -817,7 +879,7 @@ class TestGitHubCopilotAgentRunStreaming:
|
||||
# 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(
|
||||
@@ -882,9 +944,12 @@ class TestGitHubCopilotAgentSessionManagement:
|
||||
mock_session.session_id,
|
||||
on_permission_request=unittest.mock.ANY,
|
||||
streaming=unittest.mock.ANY,
|
||||
model=unittest.mock.ANY,
|
||||
system_message=unittest.mock.ANY,
|
||||
tools=unittest.mock.ANY,
|
||||
mcp_servers=unittest.mock.ANY,
|
||||
provider=unittest.mock.ANY,
|
||||
instruction_directories=unittest.mock.ANY,
|
||||
)
|
||||
|
||||
async def test_session_config_includes_model(
|
||||
@@ -1016,6 +1081,100 @@ class TestGitHubCopilotAgentSessionManagement:
|
||||
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"] == []
|
||||
|
||||
async def test_instruction_directories_override_on_resumed_session(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that instruction_directories override works on resumed sessions."""
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
default_options={"instruction_directories": ["/default/path"]},
|
||||
)
|
||||
await agent.start()
|
||||
|
||||
# Simulate a session that already has a service_session_id (resume path)
|
||||
session = AgentSession()
|
||||
session.service_session_id = "existing-session-id"
|
||||
|
||||
runtime_options: GitHubCopilotOptions = {"instruction_directories": ["/override/path"]}
|
||||
await agent._get_or_create_session(session, runtime_options=runtime_options) # type: ignore
|
||||
|
||||
call_args = mock_client.resume_session.call_args
|
||||
config = call_args.kwargs
|
||||
assert config["instruction_directories"] == ["/override/path"]
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentMCPServers:
|
||||
"""Test cases for MCP server configuration."""
|
||||
|
||||
Reference in New Issue
Block a user