From 690ed6d11b1f3d9513604b8ede715e1982334790 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:15:09 -0800 Subject: [PATCH 01/14] Added GithubCopilotAgent --- .../github_copilot/__init__.py | 28 + .../github_copilot/__init__.pyi | 15 + python/packages/core/pyproject.toml | 1 + python/packages/github_copilot/LICENSE | 21 + python/packages/github_copilot/README.md | 135 +++++ .../__init__.py | 18 + .../agent_framework_github_copilot/_agent.py | 543 ++++++++++++++++++ .../_settings.py | 49 ++ python/packages/github_copilot/pyproject.toml | 89 +++ .../packages/github_copilot/tests/__init__.py | 1 + .../packages/github_copilot/tests/conftest.py | 87 +++ .../tests/test_github_copilot_agent.py | 381 ++++++++++++ python/pyproject.toml | 1 + .../agents/github_copilot/README.md | 104 ++++ .../github_copilot/github_copilot_basic.py | 71 +++ .../github_copilot_with_file_operations.py | 56 ++ ...ithub_copilot_with_multiple_permissions.py | 42 ++ .../github_copilot_with_shell.py | 40 ++ python/uv.lock | 32 ++ 19 files changed, 1714 insertions(+) create mode 100644 python/packages/core/agent_framework/github_copilot/__init__.py create mode 100644 python/packages/core/agent_framework/github_copilot/__init__.pyi create mode 100644 python/packages/github_copilot/LICENSE create mode 100644 python/packages/github_copilot/README.md create mode 100644 python/packages/github_copilot/agent_framework_github_copilot/__init__.py create mode 100644 python/packages/github_copilot/agent_framework_github_copilot/_agent.py create mode 100644 python/packages/github_copilot/agent_framework_github_copilot/_settings.py create mode 100644 python/packages/github_copilot/pyproject.toml create mode 100644 python/packages/github_copilot/tests/__init__.py create mode 100644 python/packages/github_copilot/tests/conftest.py create mode 100644 python/packages/github_copilot/tests/test_github_copilot_agent.py create mode 100644 python/samples/getting_started/agents/github_copilot/README.md create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_basic.py create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py diff --git a/python/packages/core/agent_framework/github_copilot/__init__.py b/python/packages/core/agent_framework/github_copilot/__init__.py new file mode 100644 index 0000000000..b5e3aacce1 --- /dev/null +++ b/python/packages/core/agent_framework/github_copilot/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "GithubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"), + "GithubCopilotOptions": ("agent_framework_github_copilot", "agent-framework-github-copilot"), + "GithubCopilotSettings": ("agent_framework_github_copilot", "agent-framework-github-copilot"), + "__version__": ("agent_framework_github_copilot", "agent-framework-github-copilot"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `agent_framework.github_copilot` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/github_copilot/__init__.pyi b/python/packages/core/agent_framework/github_copilot/__init__.pyi new file mode 100644 index 0000000000..e7861de9de --- /dev/null +++ b/python/packages/core/agent_framework/github_copilot/__init__.pyi @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_github_copilot import ( + GithubCopilotAgent, + GithubCopilotOptions, + GithubCopilotSettings, + __version__, +) + +__all__ = [ + "GithubCopilotAgent", + "GithubCopilotOptions", + "GithubCopilotSettings", + "__version__", +] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 160cc99d5d..de8c0ad164 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -50,6 +50,7 @@ all = [ "agent-framework-copilotstudio", "agent-framework-declarative", "agent-framework-devui", + "agent-framework-github-copilot", "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", diff --git a/python/packages/github_copilot/LICENSE b/python/packages/github_copilot/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/github_copilot/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md new file mode 100644 index 0000000000..5f051ed818 --- /dev/null +++ b/python/packages/github_copilot/README.md @@ -0,0 +1,135 @@ +# Get Started with Microsoft Agent Framework Github Copilot + +Please install this package via pip: + +```bash +pip install agent-framework-github-copilot --pre +``` + +## Github Copilot Agent + +The Github Copilot agent enables integration with Github Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework. + +### Prerequisites + +Before using the Github Copilot agent, you need: + +1. **Github Copilot CLI**: The Copilot CLI must be installed and authenticated +2. **Github Copilot Subscription**: An active Github Copilot subscription + +### Environment Variables + +The following environment variables can be used for configuration: + +- `GITHUB_COPILOT_CLI_PATH` - Path to the Copilot CLI executable (default: "copilot") +- `GITHUB_COPILOT_MODEL` - Model to use (e.g., "gpt-5", "claude-sonnet-4") +- `GITHUB_COPILOT_TIMEOUT` - Request timeout in seconds (default: 60) +- `GITHUB_COPILOT_LOG_LEVEL` - CLI log level (default: "info") + +### Basic Usage Example + +```python +import asyncio +from agent_framework.github_copilot import GithubCopilotAgent + +async def main(): + # Create agent using environment variables or defaults + async with GithubCopilotAgent() as agent: + # Run a simple query + result = await agent.run("What is the capital of France?") + print(result) + +asyncio.run(main()) +``` + +### Streaming Example + +```python +import asyncio +from agent_framework.github_copilot import GithubCopilotAgent + +async def main(): + async with GithubCopilotAgent() as agent: + print("Agent: ", end="", flush=True) + async for chunk in agent.run_stream("Explain Python decorators"): + if chunk.text: + print(chunk.text, end="", flush=True) + print() + +asyncio.run(main()) +``` + +### Using Typed Options + +```python +import asyncio +from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions + +async def main(): + # Create agent with typed options for IDE autocomplete + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"model": "claude-sonnet-4", "timeout": 120} + ) + + async with agent: + result = await agent.run("Hello!") + print(result) + +asyncio.run(main()) +``` + +### Using Tools + +Tools are defined using standard Agent Framework patterns (functions, callables, or ToolProtocol instances): + +```python +import asyncio +from agent_framework.github_copilot import GithubCopilotAgent + +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"The weather in {city} is sunny and 72F" + +async def main(): + async with GithubCopilotAgent(tools=[get_weather]) as agent: + result = await agent.run("What's the weather in Seattle?") + print(result) + +asyncio.run(main()) +``` + +### Using Permissions + +Enable Copilot to perform system operations by specifying allowed permissions: + +```python +import asyncio +from agent_framework.github_copilot import GithubCopilotAgent + +async def main(): + # Enable read and shell permissions + async with GithubCopilotAgent( + default_options={"allowed_permissions": ["read", "shell"]} + ) as agent: + result = await agent.run("List all Python files and show their line counts") + print(result) + +asyncio.run(main()) +``` + +Available permission kinds: +- `shell` - Execute shell commands +- `read` - Read files from the filesystem +- `write` - Write files to the filesystem +- `mcp` - Use MCP servers +- `url` - Fetch content from URLs + +### Examples + +For more comprehensive examples, see the [Github Copilot examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/github_copilot/) which demonstrate: + +- Basic non-streaming and streaming execution +- Custom tool integration using Agent Framework patterns +- Shell command execution with permissions +- File read and write operations +- Combining multiple permissions for complex tasks diff --git a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py new file mode 100644 index 0000000000..de6d79da55 --- /dev/null +++ b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._agent import GithubCopilotAgent, GithubCopilotOptions +from ._settings import GithubCopilotSettings + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "GithubCopilotAgent", + "GithubCopilotOptions", + "GithubCopilotSettings", + "__version__", +] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py new file mode 100644 index 0000000000..da1e60ef4d --- /dev/null +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -0,0 +1,543 @@ +# Copyright (c) Microsoft. All rights reserved. + +import contextlib +import sys +from collections.abc import AsyncIterable, Callable, MutableMapping, Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AgentMiddlewareTypes, + AgentResponse, + AgentResponseUpdate, + AgentThread, + BaseAgent, + ChatMessage, + Content, + ContextProvider, + Role, + normalize_messages, +) +from agent_framework._tools import AIFunction, ToolProtocol +from agent_framework._types import normalize_tools +from agent_framework.exceptions import ServiceException, ServiceInitializationError +from copilot import CopilotClient, CopilotSession +from copilot.generated.session_events import SessionEvent, SessionEventType +from copilot.types import Tool as CopilotTool +from copilot.types import ToolInvocation, ToolResult +from pydantic import ValidationError + +from ._settings import GithubCopilotSettings + +if sys.version_info >= (3, 13): + from typing import TypeVar +else: + from typing_extensions import TypeVar + + +DEFAULT_TIMEOUT_SECONDS: float = 60.0 +"""Default timeout in seconds for Copilot requests.""" + +PermissionHandlerType = Callable[[dict[str, Any], dict[str, str]], dict[str, Any]] +"""Type for permission request handlers.""" + + +class GithubCopilotOptions(TypedDict, total=False): + """Github Copilot-specific options.""" + + cli_path: str + """Path to the Copilot CLI executable.""" + + model: str + """Model to use (e.g., "gpt-5", "claude-sonnet-4").""" + + timeout: float + """Request timeout in seconds.""" + + log_level: str + """CLI log level.""" + + allowed_permissions: Sequence[str] + """List of permission kinds to approve automatically. + Copilot may request permissions for operations like shell commands, + file writes, etc. Only permissions in this list will be approved. + Valid values: "shell", "write", "read", "mcp", "url". + If not provided, all permission requests will be denied by default. + """ + + +TOptions = TypeVar( + "TOptions", + bound=TypedDict, # type: ignore[valid-type] + default="GithubCopilotOptions", + covariant=True, +) + + +class GithubCopilotAgent(BaseAgent, Generic[TOptions]): + """A Github Copilot Agent. + + This agent wraps the Github Copilot SDK to provide Copilot agentic capabilities + within the Agent Framework. It supports both streaming and non-streaming responses, + custom tools, and session management. + + The agent can be used as an async context manager to ensure proper cleanup: + + Examples: + Basic usage: + + .. code-block:: python + + async with GithubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + print(response) + + With typed options: + + .. code-block:: python + + from agent_framework_github_copilot import GithubCopilotAgent, GithubCopilotOptions + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"model": "claude-sonnet-4", "timeout": 120} + ) + + With tools: + + .. code-block:: python + + def get_weather(city: str) -> str: + return f"Weather in {city} is sunny" + + + async with GithubCopilotAgent(tools=[get_weather]) as agent: + response = await agent.run("What's the weather in Seattle?") + """ + + AGENT_PROVIDER_NAME: ClassVar[str] = "github.copilot" + + def __init__( + self, + client: CopilotClient | None = None, + instructions: str | None = None, + *, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_provider: ContextProvider | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + tools: ToolProtocol + | Callable[..., Any] + | MutableMapping[str, Any] + | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | None = None, + default_options: TOptions | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize the Github Copilot Agent. + + Args: + client: Optional pre-configured CopilotClient instance. If not provided, + a new client will be created using the other parameters. + instructions: System message to append to the session. + + Keyword Args: + id: ID of the GithubCopilotAgent. + name: Name of the GithubCopilotAgent. + description: Description of the GithubCopilotAgent. + context_provider: Context Provider, to be used by the agent. + middleware: Agent middleware used by the agent. + tools: Tools to use for the agent. Can be functions, ToolProtocol instances, + or tool definition dicts. These are converted to Copilot SDK tools internally. + default_options: Default options for the agent. Can include cli_path, model, + timeout, log_level, etc. + env_file_path: Optional path to .env file for loading configuration. + env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. + + Raises: + ServiceInitializationError: If required configuration is missing or invalid. + """ + super().__init__( + id=id, + name=name, + description=description, + context_provider=context_provider, + middleware=list(middleware) if middleware else None, + ) + + self._client = client + self._owns_client = client is None + self._sessions: dict[str, CopilotSession] = {} + + # Parse options + opts = dict(default_options) if default_options else {} + cli_path = opts.pop("cli_path", None) + model = opts.pop("model", None) + timeout = opts.pop("timeout", None) + log_level = opts.pop("log_level", None) + allowed_permissions: Sequence[str] | None = opts.pop("allowed_permissions", None) + + try: + self._settings = GithubCopilotSettings( + cli_path=cli_path, + model=model, + timeout=timeout, + log_level=log_level, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create Github Copilot settings.", ex) from ex + + self._instructions = instructions + self._tools = normalize_tools(tools) + self._permission_handler = self._create_permission_handler(allowed_permissions) + self._default_options = opts + self._started = False + + async def __aenter__(self) -> "GithubCopilotAgent[TOptions]": + """Start the agent when entering async context.""" + await self.start() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Stop the agent when exiting async context.""" + await self.stop() + + async def start(self) -> None: + """Start the Copilot client. + + This method initializes the Copilot client and establishes a connection + to the Copilot CLI server. It is called automatically when using the + agent as an async context manager. + + Raises: + ServiceException: If the client fails to start. + """ + if self._started: + return + + if self._client is None: + options: dict[str, Any] = {} + if self._settings.cli_path: + options["cli_path"] = self._settings.cli_path + if self._settings.log_level: + options["log_level"] = self._settings.log_level + + self._client = CopilotClient(options if options else None) + + try: + await self._client.start() + self._started = True + except Exception as ex: + raise ServiceException(f"Failed to start Github Copilot client: {ex}") from ex + + async def stop(self) -> None: + """Stop the Copilot client and clean up resources. + + This method destroys all active sessions created by this agent and stops + the Copilot client. It is called automatically when using the agent as + an async context manager. + + Note: + Only sessions created by this agent instance (stored in self._sessions) + are destroyed. Sessions created elsewhere are not affected. + """ + for session in self._sessions.values(): + with contextlib.suppress(Exception): + await session.destroy() + + self._sessions.clear() + + if self._client and self._owns_client: + with contextlib.suppress(Exception): + await self._client.stop() + + self._started = False + + async def run( + self, + messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + tools: ToolProtocol + | Callable[..., Any] + | MutableMapping[str, Any] + | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | None = None, + options: TOptions | None = None, + **kwargs: Any, + ) -> AgentResponse: + """Get a response from the agent. + + This method returns the final result of the agent's execution + as a single AgentResponse object. The caller is blocked until + the final result is available. + + Args: + messages: The message(s) to send to the agent. + + Keyword Args: + thread: The conversation thread associated with the message(s). + tools: Additional tools to use for this specific run. + options: Runtime options (model, timeout, etc.). + kwargs: Additional keyword arguments. + + Returns: + An agent response item. + + Raises: + ServiceException: If the request fails. + """ + if not self._started: + await self.start() + + if not thread: + thread = self.get_new_thread() + + opts = dict(options) if options else {} + timeout = opts.pop("timeout", None) or self._settings.timeout or DEFAULT_TIMEOUT_SECONDS + + merged_tools = self._merge_tools(tools) + session = await self._get_or_create_session(thread, merged_tools, streaming=False) + input_messages = normalize_messages(messages) + prompt = "\n".join([message.text for message in input_messages]) + + try: + response_event = await session.send_and_wait({"prompt": prompt}, timeout=timeout) + except Exception as ex: + raise ServiceException(f"Github Copilot request failed: {ex}") from ex + + response_messages: list[ChatMessage] = [] + response_id: str | None = None + + if response_event and response_event.type == SessionEventType.ASSISTANT_MESSAGE: + content = response_event.data.content or "" + message_id = response_event.data.message_id + response_messages.append( + ChatMessage( + role=Role.ASSISTANT, + contents=[Content.from_text(content)], + message_id=message_id, + raw_representation=response_event, + ) + ) + response_id = message_id + + return AgentResponse(messages=response_messages, response_id=response_id) + + async def run_stream( + self, + messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + tools: ToolProtocol + | Callable[..., Any] + | MutableMapping[str, Any] + | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | None = None, + options: TOptions | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentResponseUpdate]: + """Run the agent as a stream. + + This method will return the intermediate steps and final results of the + agent's execution as a stream of AgentResponseUpdate objects to the caller. + + Args: + messages: The message(s) to send to the agent. + + Keyword Args: + thread: The conversation thread associated with the message(s). + tools: Additional tools to use for this specific run. + options: Runtime options (model, timeout, etc.). + kwargs: Additional keyword arguments. + + Yields: + An agent response update for each delta. + + Raises: + ServiceException: If the request fails. + """ + if not self._started: + await self.start() + + if not thread: + thread = self.get_new_thread() + + merged_tools = self._merge_tools(tools) + session = await self._get_or_create_session(thread, merged_tools, streaming=True) + input_messages = normalize_messages(messages) + prompt = "\n".join([message.text for message in input_messages]) + + import asyncio + + completion_event = asyncio.Event() + updates: list[AgentResponseUpdate] = [] + errors: list[Exception] = [] + + def event_handler(event: SessionEvent) -> None: + if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: + delta_content = event.data.delta_content or "" + if delta_content: + updates.append( + AgentResponseUpdate( + role=Role.ASSISTANT, + contents=[Content.from_text(delta_content)], + response_id=event.data.message_id, + message_id=event.data.message_id, + raw_representation=event, + ) + ) + elif event.type == SessionEventType.SESSION_IDLE: + completion_event.set() + elif event.type == SessionEventType.SESSION_ERROR: + error_msg = event.data.message or "Unknown error" + errors.append(ServiceException(f"Github Copilot session error: {error_msg}")) + completion_event.set() + + unsubscribe = session.on(event_handler) + + try: + await session.send({"prompt": prompt}) + await completion_event.wait() + + if errors: + raise errors[0] + + for update in updates: + yield update + finally: + unsubscribe() + + def _create_permission_handler( + self, + allowed_permissions: Sequence[str] | None, + ) -> PermissionHandlerType | None: + """Create a permission handler from a list of allowed permission kinds.""" + if not allowed_permissions: + return None + + allowed_set = set(allowed_permissions) + + def handler( + request: dict[str, Any], + context: dict[str, str], + ) -> dict[str, Any]: + kind = request.get("kind") + if kind in allowed_set: + return {"kind": "approved"} + return {"kind": "denied-interactively-by-user"} + + return handler + + def _merge_tools( + self, + runtime_tools: ToolProtocol + | Callable[..., Any] + | MutableMapping[str, Any] + | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | None, + ) -> list[ToolProtocol | MutableMapping[str, Any]]: + """Merge runtime tools with default tools.""" + result = list(self._tools) + result.extend(normalize_tools(runtime_tools)) + return result + + def _convert_tools_to_copilot_tools( + self, + tools: list[ToolProtocol | MutableMapping[str, Any]], + ) -> list[CopilotTool]: + """Convert Agent Framework tools to Copilot SDK tools. + + Args: + tools: List of Agent Framework tools. + + Returns: + List of Copilot SDK tools. + """ + copilot_tools: list[CopilotTool] = [] + + for tool in tools: + if isinstance(tool, AIFunction): + copilot_tools.append(self._ai_function_to_copilot_tool(tool)) + + return copilot_tools + + def _ai_function_to_copilot_tool(self, ai_func: AIFunction[Any, Any]) -> CopilotTool: + """Convert an AIFunction to a Copilot SDK tool.""" + + async def handler(invocation: ToolInvocation) -> ToolResult: + args = invocation.get("arguments", {}) + try: + if ai_func.input_model: + args_instance = ai_func.input_model(**args) + result = await ai_func.invoke(arguments=args_instance) + else: + result = await ai_func.invoke(arguments=args) + return ToolResult( + textResultForLlm=str(result), + resultType="success", + toolTelemetry={}, + ) + except Exception as e: + return ToolResult( + textResultForLlm=f"Error: {e}", + resultType="failure", + error=str(e), + toolTelemetry={}, + ) + + return CopilotTool( + name=ai_func.name, + description=ai_func.description, + handler=handler, + parameters=ai_func.parameters(), + ) + + async def _get_or_create_session( + self, + thread: AgentThread, + tools: list[ToolProtocol | MutableMapping[str, Any]], + streaming: bool = False, + ) -> CopilotSession: + """Get an existing session or create a new one for the thread. + + Args: + thread: The conversation thread. + tools: Tools to register with the session. + streaming: Whether to enable streaming for the session. + + Returns: + A CopilotSession instance. + + Raises: + ServiceException: If the session cannot be created. + """ + if not self._client: + raise ServiceException("Github Copilot client not initialized. Call start() first.") + + if thread.service_thread_id and thread.service_thread_id in self._sessions: + return self._sessions[thread.service_thread_id] + + config: dict[str, Any] = {"streaming": streaming} + + if self._settings.model: + config["model"] = self._settings.model + + if self._instructions: + config["system_message"] = {"mode": "append", "content": self._instructions} + + if tools: + config["tools"] = self._convert_tools_to_copilot_tools(tools) + + if self._permission_handler: + config["on_permission_request"] = self._permission_handler + + try: + session = await self._client.create_session(config) + thread.service_thread_id = session.session_id + self._sessions[session.session_id] = session + return session + except Exception as ex: + raise ServiceException(f"Failed to create Github Copilot session: {ex}") from ex diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py new file mode 100644 index 0000000000..904fe3615d --- /dev/null +++ b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import ClassVar + +from agent_framework._pydantic import AFBaseSettings + + +class GithubCopilotSettings(AFBaseSettings): + """Github Copilot model settings. + + The settings are first loaded from environment variables with the prefix 'GITHUB_COPILOT_'. + If the environment variables are not found, the settings can be loaded from a .env file + with the encoding 'utf-8'. If the settings are not found in the .env file, the settings + are ignored; however, validation will fail alerting that the settings are missing. + + Keyword Args: + cli_path: Path to the Copilot CLI executable. + Can be set via environment variable GITHUB_COPILOT_CLI_PATH. + model: Model to use (e.g., "gpt-5", "claude-sonnet-4"). + Can be set via environment variable GITHUB_COPILOT_MODEL. + timeout: Request timeout in seconds. + Can be set via environment variable GITHUB_COPILOT_TIMEOUT. + log_level: CLI log level. + Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL. + env_file_path: If provided, the .env settings are read from this file path location. + env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. + + Examples: + .. code-block:: python + + from agent_framework_github_copilot import GithubCopilotSettings + + # Using environment variables + # Set GITHUB_COPILOT_MODEL=gpt-5 + settings = GithubCopilotSettings() + + # Or passing parameters directly + settings = GithubCopilotSettings(model="claude-sonnet-4", timeout=120) + + # Or loading from a .env file + settings = GithubCopilotSettings(env_file_path="path/to/.env") + """ + + env_prefix: ClassVar[str] = "GITHUB_COPILOT_" + + cli_path: str | None = None + model: str | None = None + timeout: float | None = None + log_level: str | None = None diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml new file mode 100644 index 0000000000..5e36572985 --- /dev/null +++ b/python/packages/github_copilot/pyproject.toml @@ -0,0 +1,89 @@ +[project] +name = "agent-framework-github-copilot" +description = "Github Copilot integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260116" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core", + "github-copilot-sdk>=0.1.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" + + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_github_copilot"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_github_copilot" +test = "pytest --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/github_copilot/tests/__init__.py b/python/packages/github_copilot/tests/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/github_copilot/tests/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/github_copilot/tests/conftest.py b/python/packages/github_copilot/tests/conftest.py new file mode 100644 index 0000000000..36e88f9297 --- /dev/null +++ b/python/packages/github_copilot/tests/conftest.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft. All rights reserved. + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from copilot.generated.session_events import Data, SessionEvent, SessionEventType + + +def create_session_event( + event_type: SessionEventType, + content: str | None = None, + delta_content: str | None = None, + message_id: str | None = None, + error_message: str | None = None, +) -> SessionEvent: + """Create a mock session event for testing.""" + data = Data( + content=content, + delta_content=delta_content, + message_id=message_id or str(uuid4()), + message=error_message, + ) + return SessionEvent( + data=data, + id=uuid4(), + timestamp=datetime.now(timezone.utc), + type=event_type, + ) + + +@pytest.fixture +def mock_session() -> MagicMock: + """Create a mock CopilotSession.""" + session = MagicMock() + session.session_id = "test-session-id" + session.send = AsyncMock(return_value="test-message-id") + session.send_and_wait = AsyncMock() + session.destroy = AsyncMock() + session.on = MagicMock(return_value=lambda: None) + return session + + +@pytest.fixture +def mock_client(mock_session: MagicMock) -> MagicMock: + """Create a mock CopilotClient.""" + client = MagicMock() + client.start = AsyncMock() + client.stop = AsyncMock(return_value=[]) + client.create_session = AsyncMock(return_value=mock_session) + return client + + +@pytest.fixture +def assistant_message_event() -> SessionEvent: + """Create a mock assistant message event.""" + return create_session_event( + SessionEventType.ASSISTANT_MESSAGE, + content="Test response", + message_id="test-msg-id", + ) + + +@pytest.fixture +def assistant_delta_event() -> SessionEvent: + """Create a mock assistant message delta event.""" + return create_session_event( + SessionEventType.ASSISTANT_MESSAGE_DELTA, + delta_content="Hello", + message_id="test-msg-id", + ) + + +@pytest.fixture +def session_idle_event() -> SessionEvent: + """Create a mock session idle event.""" + return create_session_event(SessionEventType.SESSION_IDLE) + + +@pytest.fixture +def session_error_event() -> SessionEvent: + """Create a mock session error event.""" + return create_session_event( + SessionEventType.SESSION_ERROR, + error_message="Test error", + ) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py new file mode 100644 index 0000000000..5ebd15fdf6 --- /dev/null +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -0,0 +1,381 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage, Content, Role +from agent_framework.exceptions import ServiceException +from copilot.generated.session_events import SessionEvent + +from agent_framework_github_copilot import GithubCopilotAgent + + +class TestGithubCopilotAgentInit: + """Test cases for GithubCopilotAgent initialization.""" + + def test_init_with_client(self, mock_client: MagicMock) -> None: + """Test initialization with pre-configured client.""" + agent = GithubCopilotAgent(client=mock_client) + assert agent._client == mock_client + assert agent._owns_client is False + assert agent.id is not None + + def test_init_without_client(self) -> None: + """Test initialization without client creates settings.""" + agent = GithubCopilotAgent() + assert agent._client is None + assert agent._owns_client is True + assert agent._settings is not None + + def test_init_with_default_options(self) -> None: + """Test initialization with default_options parameter.""" + agent = GithubCopilotAgent(default_options={"model": "claude-sonnet-4", "timeout": 120}) + assert agent._settings.model == "claude-sonnet-4" + assert agent._settings.timeout == 120 + + def test_init_with_tools(self) -> None: + """Test initialization with function tools.""" + + def my_tool(arg: str) -> str: + return f"Result: {arg}" + + agent = GithubCopilotAgent(tools=[my_tool]) + assert len(agent._tools) == 1 + + def test_init_with_instructions(self) -> None: + """Test initialization with custom instructions.""" + agent = GithubCopilotAgent(instructions="You are a helpful assistant.") + assert agent._instructions == "You are a helpful assistant." + + +class TestGithubCopilotAgentLifecycle: + """Test cases for agent lifecycle management.""" + + async def test_start_creates_client(self) -> None: + """Test that start creates a client if none provided.""" + 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() + + MockClient.assert_called_once() + mock_client.start.assert_called_once() + assert agent._started is True + + async def test_start_uses_existing_client(self, mock_client: MagicMock) -> None: + """Test that start uses provided client.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + mock_client.start.assert_called_once() + assert agent._started is True + + async def test_start_idempotent(self, mock_client: MagicMock) -> None: + """Test that calling start multiple times is safe.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + await agent.start() + + mock_client.start.assert_called_once() + + async def test_stop_cleans_up(self, mock_client: MagicMock, mock_session: MagicMock) -> None: + """Test that stop cleans up sessions and client.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + mock_client.create_session.return_value = mock_session + await agent._get_or_create_session(AgentThread(), []) + + await agent.stop() + + mock_session.destroy.assert_called_once() + assert agent._started is False + + async def test_context_manager(self, mock_client: MagicMock) -> None: + """Test async context manager usage.""" + async with GithubCopilotAgent(client=mock_client) as agent: + assert agent._started is True + + # When client is provided externally, agent doesn't own it and won't stop it + mock_client.stop.assert_not_called() + assert agent._started is False + + +class TestGithubCopilotAgentRun: + """Test cases for run method.""" + + async def test_run_string_message( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test run method with string message.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client) + response = await agent.run("Hello") + + assert isinstance(response, AgentResponse) + assert len(response.messages) == 1 + assert response.messages[0].role == Role.ASSISTANT + assert response.messages[0].contents[0].text == "Test response" + + async def test_run_chat_message( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test run method with ChatMessage.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client) + chat_message = ChatMessage(role=Role.USER, contents=[Content.from_text("Hello")]) + response = await agent.run(chat_message) + + assert isinstance(response, AgentResponse) + assert len(response.messages) == 1 + + async def test_run_with_thread( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test run method with existing thread.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client) + thread = AgentThread() + response = await agent.run("Hello", thread=thread) + + assert isinstance(response, AgentResponse) + assert thread.service_thread_id == mock_session.session_id + + async def test_run_with_runtime_options( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test run method with runtime options.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client) + response = await agent.run("Hello", options={"timeout": 30}) + + assert isinstance(response, AgentResponse) + + async def test_run_empty_response( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test run method with no response event.""" + mock_session.send_and_wait.return_value = None + + agent = GithubCopilotAgent(client=mock_client) + response = await agent.run("Hello") + + assert isinstance(response, AgentResponse) + assert len(response.messages) == 0 + + async def test_run_auto_starts( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that run auto-starts the agent if not started.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client) + assert agent._started is False + + await agent.run("Hello") + + assert agent._started is True + mock_client.start.assert_called_once() + + +class TestGithubCopilotAgentRunStream: + """Test cases for run_stream method.""" + + async def test_run_stream_basic( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + """Test basic streaming response.""" + events = [assistant_delta_event, session_idle_event] + + def mock_on(handler: Any) -> Any: + for event in events: + handler(event) + return lambda: None + + mock_session.on = mock_on + + agent = GithubCopilotAgent(client=mock_client) + responses = [] + async for update in agent.run_stream("Hello"): + responses.append(update) + + assert len(responses) == 1 + assert isinstance(responses[0], AgentResponseUpdate) + assert responses[0].role == Role.ASSISTANT + assert responses[0].contents[0].text == "Hello" + + async def test_run_stream_with_thread( + self, + mock_client: MagicMock, + mock_session: MagicMock, + session_idle_event: SessionEvent, + ) -> None: + """Test streaming with existing thread.""" + + def mock_on(handler: Any) -> Any: + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + + agent = GithubCopilotAgent(client=mock_client) + thread = AgentThread() + + async for _ in agent.run_stream("Hello", thread=thread): + pass + + assert thread.service_thread_id == mock_session.session_id + + async def test_run_stream_error( + self, + mock_client: MagicMock, + mock_session: MagicMock, + session_error_event: SessionEvent, + ) -> None: + """Test streaming error handling.""" + + def mock_on(handler: Any) -> Any: + handler(session_error_event) + return lambda: None + + mock_session.on = mock_on + + agent = GithubCopilotAgent(client=mock_client) + + with pytest.raises(ServiceException, match="session error"): + async for _ in agent.run_stream("Hello"): + pass + + +class TestGithubCopilotAgentSessionManagement: + """Test cases for session management.""" + + async def test_session_reuse( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that sessions are reused for the same thread.""" + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client) + thread = AgentThread() + + await agent.run("Hello", thread=thread) + await agent.run("World", thread=thread) + + mock_client.create_session.assert_called_once() + + async def test_session_config_includes_model( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session config includes model setting.""" + agent = GithubCopilotAgent(client=mock_client, default_options={"model": "claude-sonnet-4"}) + await agent.start() + + await agent._get_or_create_session(AgentThread(), []) + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert config["model"] == "claude-sonnet-4" + + async def test_session_config_includes_instructions( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session config includes instructions.""" + agent = GithubCopilotAgent(client=mock_client, instructions="You are a helpful assistant.") + await agent.start() + + await agent._get_or_create_session(AgentThread(), []) + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert config["system_message"]["mode"] == "append" + assert config["system_message"]["content"] == "You are a helpful assistant." + + +class TestGithubCopilotAgentToolConversion: + """Test cases for tool conversion.""" + + async def test_function_tool_conversion( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that function tools are converted to Copilot tools.""" + + def my_tool(arg: str) -> str: + """A test tool.""" + return f"Result: {arg}" + + agent = GithubCopilotAgent(client=mock_client, tools=[my_tool]) + await agent.start() + + await agent._get_or_create_session(AgentThread(), agent._tools) + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert "tools" in config + assert len(config["tools"]) == 1 + assert config["tools"][0].name == "my_tool" + assert config["tools"][0].description == "A test tool." + + async def test_runtime_tools_merged( + self, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + """Test that runtime tools are merged with agent tools.""" + + def agent_tool(x: str) -> str: + """Agent tool.""" + return x + + def runtime_tool(y: str) -> str: + """Runtime tool.""" + return y + + mock_session.send_and_wait.return_value = assistant_message_event + + agent = GithubCopilotAgent(client=mock_client, tools=[agent_tool]) + await agent.run("Hello", tools=[runtime_tool]) + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert "tools" in config + assert len(config["tools"]) == 2 diff --git a/python/pyproject.toml b/python/pyproject.toml index 97e90fad5e..7ddff5b1f0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -101,6 +101,7 @@ agent-framework-mem0 = { workspace = true } agent-framework-ollama = { workspace = true } agent-framework-purview = { workspace = true } agent-framework-redis = { workspace = true } +agent-framework-github-copilot = { workspace = true } [tool.ruff] line-length = 120 diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/getting_started/agents/github_copilot/README.md new file mode 100644 index 0000000000..d13f785a93 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/README.md @@ -0,0 +1,104 @@ +# Github Copilot Agent Examples + +This directory contains examples demonstrating how to use the `GithubCopilotAgent` from the Microsoft Agent Framework. + +## Examples + +| File | Description | +|------|-------------| +| [`github_copilot_basic.py`](github_copilot_basic.py) | The simplest way to create an agent using `GithubCopilotAgent`. Demonstrates both streaming and non-streaming responses with function tools. | +| [`github_copilot_with_shell.py`](github_copilot_with_shell.py) | Shows how to enable shell command execution permissions. Demonstrates running system commands like listing files and getting system information. | +| [`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_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. | + +## Prerequisites + +1. **Github Copilot CLI**: Install and authenticate the Copilot CLI +2. **Github Copilot Subscription**: An active Github Copilot subscription +3. **Install the package**: + ```bash + pip install agent-framework-github-copilot --pre + ``` + +## Environment Variables + +The following environment variables can be configured: + +| Variable | Description | Default | +|----------|-------------|---------| +| `GITHUB_COPILOT_CLI_PATH` | Path to the Copilot CLI executable | `copilot` | +| `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` | + +## Permission Kinds + +When using `allowed_permissions`, the following permission kinds are available: + +| Permission | Description | +|------------|-------------| +| `shell` | Execute shell commands on the system | +| `read` | Read files from the filesystem | +| `write` | Write or create files on the filesystem | +| `mcp` | Use MCP (Model Context Protocol) servers | +| `url` | Fetch content from URLs | + +**Security Note**: Only enable permissions that are necessary for your use case. Each permission grants the agent additional capabilities that could affect your system. + +## Usage Patterns + +### Basic Usage (No Permissions) + +```python +from agent_framework.github_copilot import GithubCopilotAgent + +async with GithubCopilotAgent() as agent: + response = await agent.run("Hello!") +``` + +### With Custom Tools + +```python +from typing import Annotated + +from agent_framework.github_copilot import GithubCopilotAgent +from pydantic import Field + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + return f"The weather in {location} is sunny." + +async with GithubCopilotAgent(tools=[get_weather]) as agent: + response = await agent.run("What's the weather in Seattle?") +``` + +### With Permissions + +```python +from agent_framework.github_copilot import GithubCopilotAgent + +# Read-only access +async with GithubCopilotAgent( + default_options={"allowed_permissions": ["read"]} +) as agent: + response = await agent.run("Read the README.md file") + +# Full development access +async with GithubCopilotAgent( + default_options={"allowed_permissions": ["shell", "read", "write"]} +) as agent: + response = await agent.run("Create a new Python file with a hello world function") +``` + +## Running the Examples + +Each example can be run independently: + +```bash +python github_copilot_basic.py +python github_copilot_with_shell.py +python github_copilot_with_file_operations.py +python github_copilot_with_multiple_permissions.py +``` diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py new file mode 100644 index 0000000000..8d867f3ea1 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Github Copilot Agent Basic Example + +This sample demonstrates basic usage of GithubCopilotAgent. +Shows both streaming and non-streaming responses with function tools. + +Environment variables (optional): +- GITHUB_COPILOT_CLI_PATH - Path to the Copilot CLI executable +- GITHUB_COPILOT_MODEL - Model to use (e.g., "gpt-5", "claude-sonnet-4") +- GITHUB_COPILOT_TIMEOUT - Request timeout in seconds +- GITHUB_COPILOT_LOG_LEVEL - CLI log level +""" + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework.github_copilot import GithubCopilotAgent +from pydantic import Field + + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + async with GithubCopilotAgent( + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) as agent: + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + async with GithubCopilotAgent( + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) as agent: + query = "What's the weather like in Tokyo?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run_stream(query): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic Github Copilot Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py new file mode 100644 index 0000000000..e61bc12a81 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Github Copilot Agent with File Operation Permissions + +This sample demonstrates how to enable file read and write operations with GithubCopilotAgent. +By setting allowed_permissions to include "read" and/or "write", the agent can read from +and write to files on the filesystem. + +SECURITY NOTE: Only enable file permissions when you trust the agent's actions. +- "read" allows the agent to read any accessible file +- "write" allows the agent to create or modify files +""" + +import asyncio + +from agent_framework.github_copilot import GithubCopilotAgent + + +async def read_only_example() -> None: + """Example with read-only file permissions.""" + print("=== Read-Only File Access Example ===\n") + + async with GithubCopilotAgent( + instructions="You are a helpful assistant that can read files.", + default_options={"allowed_permissions": ["read"]}, + ) as agent: + query = "Read the contents of README.md and summarize it" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +async def read_write_example() -> None: + """Example with both read and write file permissions.""" + print("=== Read and Write File Access Example ===\n") + + async with GithubCopilotAgent( + instructions="You are a helpful assistant that can read and write files.", + default_options={"allowed_permissions": ["read", "write"]}, + ) as agent: + query = "Create a file called 'hello.txt' with the text 'Hello from Copilot!'" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +async def main() -> None: + print("=== Github Copilot Agent with File Operation Permissions ===\n") + + await read_only_example() + await read_write_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py new file mode 100644 index 0000000000..140a5e8687 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Github Copilot Agent with Multiple Permissions + +This sample demonstrates how to enable multiple permission types with GithubCopilotAgent. +By combining different permission kinds, the agent can perform complex tasks that require +multiple capabilities. + +Available permission kinds: +- "shell": Execute shell commands +- "read": Read files from the filesystem +- "write": Write files to the filesystem +- "mcp": Use MCP (Model Context Protocol) servers +- "url": Fetch content from URLs + +SECURITY NOTE: Only enable permissions that are necessary for your use case. +More permissions mean more potential for unintended actions. +""" + +import asyncio + +from agent_framework.github_copilot import GithubCopilotAgent + + +async def main() -> None: + print("=== Github Copilot Agent with Multiple Permissions ===\n") + + # Enable shell, read, and write permissions for a development assistant + async with GithubCopilotAgent( + instructions="You are a helpful development assistant that can read, write files and run commands.", + default_options={"allowed_permissions": ["shell", "read", "write"]}, + ) as agent: + # Complex task that requires multiple permissions + query = "List all Python files, then read the first one and create a summary in summary.txt" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py new file mode 100644 index 0000000000..a79fc7a719 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Github Copilot Agent with Shell Permissions + +This sample demonstrates how to enable shell command execution with GithubCopilotAgent. +By setting allowed_permissions to include "shell", the agent can execute shell commands +to perform tasks like listing files, running scripts, or executing system commands. + +SECURITY NOTE: Only enable shell permissions when you trust the agent's actions. +Shell commands have full access to your system within the permissions of the running process. +""" + +import asyncio + +from agent_framework.github_copilot import GithubCopilotAgent + + +async def main() -> None: + print("=== Github Copilot Agent with Shell Permissions ===\n") + + async with GithubCopilotAgent( + instructions="You are a helpful assistant that can execute shell commands.", + default_options={"allowed_permissions": ["shell"]}, + ) as agent: + # Example: List files in current directory + query = "List all Python files in the current directory" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + # Example: Get system information + query = "What is the current working directory?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 082c0b3d15..495aeb1066 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -40,6 +40,7 @@ members = [ "agent-framework-declarative", "agent-framework-devui", "agent-framework-foundry-local", + "agent-framework-github-copilot", "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", @@ -352,6 +353,7 @@ all = [ { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-github-copilot", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -371,6 +373,7 @@ requires-dist = [ { name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" }, { name = "agent-framework-declarative", marker = "extra == 'all'", editable = "packages/declarative" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, + { name = "agent-framework-github-copilot", marker = "extra == 'all'", editable = "packages/github_copilot" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, { name = "agent-framework-ollama", marker = "extra == 'all'", editable = "packages/ollama" }, @@ -463,6 +466,21 @@ requires-dist = [ { name = "foundry-local-sdk", specifier = ">=0.5.1,<1" }, ] +[[package]] +name = "agent-framework-github-copilot" +version = "1.0.0b260116" +source = { editable = "packages/github_copilot" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "github-copilot-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "github-copilot-sdk", specifier = ">=0.1.0" }, +] + [[package]] name = "agent-framework-lab" version = "1.0.0b260116" @@ -2235,6 +2253,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/8c/dce3b1b7593858eba995b2dfdb833f872c7f863e3da92aab7128a6b11af4/furl-2.1.4-py2.py3-none-any.whl", hash = "sha256:da34d0b34e53ffe2d2e6851a7085a05d96922b5b578620a37377ff1dbeeb11c8", size = 27550, upload-time = "2025-03-09T05:36:19.928Z" }, ] +[[package]] +name = "github-copilot-sdk" +version = "0.1.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/f4/3e8f7fde88c5491ce5d29818d850b9508868660cad5359a9352bb804364a/github_copilot_sdk-0.1.15.tar.gz", hash = "sha256:6f713fc80b282844344bc4aaa495f58f346d63cdc93d25df3bf7e1e8d0d4f20a", size = 89915, upload-time = "2026-01-22T04:25:20.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/be/1f1166914c2a769d43761b7da6a2137711e1cb2c8de77bf9c2da5c171ec7/github_copilot_sdk-0.1.15-py3-none-any.whl", hash = "sha256:64a45e4c63b4ae6a4863470f74d6cf6bcf2a94b8a6efa7dfbdc8bcea316c5e78", size = 31972, upload-time = "2026-01-22T04:25:18.652Z" }, +] + [[package]] name = "google-api-core" version = "2.29.0" From 5ae48c5fa8e3f4a80ad88dccc47c94524379753f Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:36:58 -0800 Subject: [PATCH 02/14] Fixed errors --- .../agent_framework_github_copilot/_agent.py | 37 +++++++------ .../tests/test_github_copilot_agent.py | 52 ++++++++++--------- .../github_copilot_with_file_operations.py | 14 +++-- ...ithub_copilot_with_multiple_permissions.py | 8 +-- .../github_copilot_with_shell.py | 8 +-- 5 files changed, 69 insertions(+), 50 deletions(-) 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 da1e60ef4d..7f6f2bcdfe 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -22,8 +22,15 @@ from agent_framework.exceptions import ServiceException, ServiceInitializationError from copilot import CopilotClient, CopilotSession from copilot.generated.session_events import SessionEvent, SessionEventType +from copilot.types import ( + CopilotClientOptions, + PermissionRequest, + PermissionRequestResult, + SessionConfig, + ToolInvocation, + ToolResult, +) from copilot.types import Tool as CopilotTool -from copilot.types import ToolInvocation, ToolResult from pydantic import ValidationError from ._settings import GithubCopilotSettings @@ -37,7 +44,7 @@ DEFAULT_TIMEOUT_SECONDS: float = 60.0 """Default timeout in seconds for Copilot requests.""" -PermissionHandlerType = Callable[[dict[str, Any], dict[str, str]], dict[str, Any]] +PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult] """Type for permission request handlers.""" @@ -170,7 +177,7 @@ def __init__( self._sessions: dict[str, CopilotSession] = {} # Parse options - opts = dict(default_options) if default_options else {} + opts: dict[str, Any] = dict(default_options) if default_options else {} cli_path = opts.pop("cli_path", None) model = opts.pop("model", None) timeout = opts.pop("timeout", None) @@ -218,13 +225,13 @@ async def start(self) -> None: return if self._client is None: - options: dict[str, Any] = {} + client_options: CopilotClientOptions = {} if self._settings.cli_path: - options["cli_path"] = self._settings.cli_path + client_options["cli_path"] = self._settings.cli_path if self._settings.log_level: - options["log_level"] = self._settings.log_level + client_options["log_level"] = self._settings.log_level # type: ignore[typeddict-item] - self._client = CopilotClient(options if options else None) + self._client = CopilotClient(client_options if client_options else None) try: await self._client.start() @@ -295,7 +302,7 @@ async def run( if not thread: thread = self.get_new_thread() - opts = dict(options) if options else {} + opts: dict[str, Any] = dict(options) if options else {} timeout = opts.pop("timeout", None) or self._settings.timeout or DEFAULT_TIMEOUT_SECONDS merged_tools = self._merge_tools(tools) @@ -421,13 +428,13 @@ def _create_permission_handler( allowed_set = set(allowed_permissions) def handler( - request: dict[str, Any], + request: PermissionRequest, context: dict[str, str], - ) -> dict[str, Any]: + ) -> PermissionRequestResult: kind = request.get("kind") if kind in allowed_set: - return {"kind": "approved"} - return {"kind": "denied-interactively-by-user"} + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") return handler @@ -460,7 +467,7 @@ def _convert_tools_to_copilot_tools( for tool in tools: if isinstance(tool, AIFunction): - copilot_tools.append(self._ai_function_to_copilot_tool(tool)) + copilot_tools.append(self._ai_function_to_copilot_tool(tool)) # type: ignore return copilot_tools @@ -520,10 +527,10 @@ async def _get_or_create_session( if thread.service_thread_id and thread.service_thread_id in self._sessions: return self._sessions[thread.service_thread_id] - config: dict[str, Any] = {"streaming": streaming} + config: SessionConfig = {"streaming": streaming} if self._settings.model: - config["model"] = self._settings.model + config["model"] = self._settings.model # type: ignore[typeddict-item] if self._instructions: config["system_message"] = {"mode": "append", "content": self._instructions} 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 5ebd15fdf6..6886c99010 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -8,7 +8,7 @@ from agent_framework.exceptions import ServiceException from copilot.generated.session_events import SessionEvent -from agent_framework_github_copilot import GithubCopilotAgent +from agent_framework_github_copilot import GithubCopilotAgent, GithubCopilotOptions class TestGithubCopilotAgentInit: @@ -17,22 +17,24 @@ class TestGithubCopilotAgentInit: def test_init_with_client(self, mock_client: MagicMock) -> None: """Test initialization with pre-configured client.""" agent = GithubCopilotAgent(client=mock_client) - assert agent._client == mock_client - assert agent._owns_client is False + assert agent._client == mock_client # type: ignore + assert agent._owns_client is False # type: ignore assert agent.id is not None def test_init_without_client(self) -> None: """Test initialization without client creates settings.""" agent = GithubCopilotAgent() - assert agent._client is None - assert agent._owns_client is True - assert agent._settings is not None + assert agent._client is None # type: ignore + assert agent._owns_client is True # type: ignore + assert agent._settings is not None # type: ignore def test_init_with_default_options(self) -> None: """Test initialization with default_options parameter.""" - agent = GithubCopilotAgent(default_options={"model": "claude-sonnet-4", "timeout": 120}) - assert agent._settings.model == "claude-sonnet-4" - assert agent._settings.timeout == 120 + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"model": "claude-sonnet-4", "timeout": 120} + ) + assert agent._settings.model == "claude-sonnet-4" # type: ignore + assert agent._settings.timeout == 120 # type: ignore def test_init_with_tools(self) -> None: """Test initialization with function tools.""" @@ -41,12 +43,12 @@ def my_tool(arg: str) -> str: return f"Result: {arg}" agent = GithubCopilotAgent(tools=[my_tool]) - assert len(agent._tools) == 1 + assert len(agent._tools) == 1 # type: ignore def test_init_with_instructions(self) -> None: """Test initialization with custom instructions.""" agent = GithubCopilotAgent(instructions="You are a helpful assistant.") - assert agent._instructions == "You are a helpful assistant." + assert agent._instructions == "You are a helpful assistant." # type: ignore class TestGithubCopilotAgentLifecycle: @@ -64,7 +66,7 @@ async def test_start_creates_client(self) -> None: MockClient.assert_called_once() mock_client.start.assert_called_once() - assert agent._started is True + assert agent._started is True # type: ignore async def test_start_uses_existing_client(self, mock_client: MagicMock) -> None: """Test that start uses provided client.""" @@ -72,7 +74,7 @@ async def test_start_uses_existing_client(self, mock_client: MagicMock) -> None: await agent.start() mock_client.start.assert_called_once() - assert agent._started is True + assert agent._started is True # type: ignore async def test_start_idempotent(self, mock_client: MagicMock) -> None: """Test that calling start multiple times is safe.""" @@ -88,21 +90,21 @@ async def test_stop_cleans_up(self, mock_client: MagicMock, mock_session: MagicM await agent.start() mock_client.create_session.return_value = mock_session - await agent._get_or_create_session(AgentThread(), []) + await agent._get_or_create_session(AgentThread(), []) # type: ignore await agent.stop() mock_session.destroy.assert_called_once() - assert agent._started is False + assert agent._started is False # type: ignore async def test_context_manager(self, mock_client: MagicMock) -> None: """Test async context manager usage.""" async with GithubCopilotAgent(client=mock_client) as agent: - assert agent._started is True + assert agent._started is True # type: ignore # When client is provided externally, agent doesn't own it and won't stop it mock_client.stop.assert_not_called() - assert agent._started is False + assert agent._started is False # type: ignore class TestGithubCopilotAgentRun: @@ -195,11 +197,11 @@ async def test_run_auto_starts( mock_session.send_and_wait.return_value = assistant_message_event agent = GithubCopilotAgent(client=mock_client) - assert agent._started is False + assert agent._started is False # type: ignore await agent.run("Hello") - assert agent._started is True + assert agent._started is True # type: ignore mock_client.start.assert_called_once() @@ -224,7 +226,7 @@ def mock_on(handler: Any) -> Any: mock_session.on = mock_on agent = GithubCopilotAgent(client=mock_client) - responses = [] + responses: list[AgentResponseUpdate] = [] async for update in agent.run_stream("Hello"): responses.append(update) @@ -302,10 +304,12 @@ async def test_session_config_includes_model( mock_session: MagicMock, ) -> None: """Test that session config includes model setting.""" - agent = GithubCopilotAgent(client=mock_client, default_options={"model": "claude-sonnet-4"}) + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + client=mock_client, default_options={"model": "claude-sonnet-4"} + ) await agent.start() - await agent._get_or_create_session(AgentThread(), []) + await agent._get_or_create_session(AgentThread(), []) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -320,7 +324,7 @@ async def test_session_config_includes_instructions( agent = GithubCopilotAgent(client=mock_client, instructions="You are a helpful assistant.") await agent.start() - await agent._get_or_create_session(AgentThread(), []) + await agent._get_or_create_session(AgentThread(), []) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -345,7 +349,7 @@ def my_tool(arg: str) -> str: agent = GithubCopilotAgent(client=mock_client, tools=[my_tool]) await agent.start() - await agent._get_or_create_session(AgentThread(), agent._tools) + await agent._get_or_create_session(AgentThread(), agent._tools) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py index e61bc12a81..e0339a475e 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py @@ -14,17 +14,19 @@ import asyncio -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions async def read_only_example() -> None: """Example with read-only file permissions.""" print("=== Read-Only File Access Example ===\n") - async with GithubCopilotAgent( + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful assistant that can read files.", default_options={"allowed_permissions": ["read"]}, - ) as agent: + ) + + async with agent: query = "Read the contents of README.md and summarize it" print(f"User: {query}") result = await agent.run(query) @@ -35,10 +37,12 @@ async def read_write_example() -> None: """Example with both read and write file permissions.""" print("=== Read and Write File Access Example ===\n") - async with GithubCopilotAgent( + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful assistant that can read and write files.", default_options={"allowed_permissions": ["read", "write"]}, - ) as agent: + ) + + async with agent: query = "Create a file called 'hello.txt' with the text 'Hello from Copilot!'" print(f"User: {query}") result = await agent.run(query) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py index 140a5e8687..b6e3a98f2e 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py @@ -20,17 +20,19 @@ import asyncio -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions async def main() -> None: print("=== Github Copilot Agent with Multiple Permissions ===\n") # Enable shell, read, and write permissions for a development assistant - async with GithubCopilotAgent( + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful development assistant that can read, write files and run commands.", default_options={"allowed_permissions": ["shell", "read", "write"]}, - ) as agent: + ) + + async with agent: # Complex task that requires multiple permissions query = "List all Python files, then read the first one and create a summary in summary.txt" print(f"User: {query}") diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py index a79fc7a719..0d8569f54a 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py @@ -13,16 +13,18 @@ import asyncio -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions async def main() -> None: print("=== Github Copilot Agent with Shell Permissions ===\n") - async with GithubCopilotAgent( + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful assistant that can execute shell commands.", default_options={"allowed_permissions": ["shell"]}, - ) as agent: + ) + + async with agent: # Example: List files in current directory query = "List all Python files in the current directory" print(f"User: {query}") From 7a6b401a0c9dc6464d41944564eb9aa2d05a53e0 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:00:35 -0800 Subject: [PATCH 03/14] Updated examples --- .../github_copilot_with_multiple_permissions.py | 2 +- .../agents/github_copilot/github_copilot_with_shell.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py index b6e3a98f2e..8bed30d03b 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py @@ -34,7 +34,7 @@ async def main() -> None: async with agent: # Complex task that requires multiple permissions - query = "List all Python files, then read the first one and create a summary in summary.txt" + query = "List the first 3 Python files, then read the first one and create a summary in summary.txt" print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py index 0d8569f54a..04dbf7d0d3 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py @@ -25,14 +25,14 @@ async def main() -> None: ) async with agent: - # Example: List files in current directory - query = "List all Python files in the current directory" + # Example: Run a shell command to display current directory + query = "Run 'pwd' and tell me the current directory" print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") - # Example: Get system information - query = "What is the current working directory?" + # Example: Run echo command + query = "Run 'echo Hello from shell!' and show me the output" print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") From b9990abb64a1ae1216208f1fb73d14caa577d5b0 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:22:29 -0800 Subject: [PATCH 04/14] Updated naming and tests --- python/packages/github_copilot/README.md | 14 +-- .../agent_framework_github_copilot/_agent.py | 20 ++--- .../_settings.py | 2 +- python/packages/github_copilot/pyproject.toml | 2 +- .../packages/github_copilot/tests/conftest.py | 87 ------------------- .../tests/test_github_copilot_agent.py | 83 +++++++++++++++++- .../agents/github_copilot/README.md | 6 +- .../github_copilot/github_copilot_basic.py | 4 +- .../github_copilot_with_file_operations.py | 4 +- ...ithub_copilot_with_multiple_permissions.py | 4 +- .../github_copilot_with_shell.py | 4 +- 11 files changed, 112 insertions(+), 118 deletions(-) delete mode 100644 python/packages/github_copilot/tests/conftest.py diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 5f051ed818..1f1a2fbd4d 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -1,4 +1,4 @@ -# Get Started with Microsoft Agent Framework Github Copilot +# Get Started with Microsoft Agent Framework GitHub Copilot Please install this package via pip: @@ -6,16 +6,16 @@ Please install this package via pip: pip install agent-framework-github-copilot --pre ``` -## Github Copilot Agent +## GitHub Copilot Agent -The Github Copilot agent enables integration with Github Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework. +The GitHub Copilot agent enables integration with GitHub Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework. ### Prerequisites -Before using the Github Copilot agent, you need: +Before using the GitHub Copilot agent, you need: -1. **Github Copilot CLI**: The Copilot CLI must be installed and authenticated -2. **Github Copilot Subscription**: An active Github Copilot subscription +1. **GitHub Copilot CLI**: The Copilot CLI must be installed and authenticated +2. **GitHub Copilot Subscription**: An active GitHub Copilot subscription ### Environment Variables @@ -126,7 +126,7 @@ Available permission kinds: ### Examples -For more comprehensive examples, see the [Github Copilot examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/github_copilot/) which demonstrate: +For more comprehensive examples, see the [GitHub Copilot examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/github_copilot/) which demonstrate: - Basic non-streaming and streaming execution - Custom tool integration using Agent Framework patterns 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 7f6f2bcdfe..72f6fe24a4 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -49,7 +49,7 @@ class GithubCopilotOptions(TypedDict, total=False): - """Github Copilot-specific options.""" + """GitHub Copilot-specific options.""" cli_path: str """Path to the Copilot CLI executable.""" @@ -81,9 +81,9 @@ class GithubCopilotOptions(TypedDict, total=False): class GithubCopilotAgent(BaseAgent, Generic[TOptions]): - """A Github Copilot Agent. + """A GitHub Copilot Agent. - This agent wraps the Github Copilot SDK to provide Copilot agentic capabilities + This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities within the Agent Framework. It supports both streaming and non-streaming responses, custom tools, and session management. @@ -141,7 +141,7 @@ def __init__( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize the Github Copilot Agent. + """Initialize the GitHub Copilot Agent. Args: client: Optional pre-configured CopilotClient instance. If not provided, @@ -194,7 +194,7 @@ def __init__( env_file_encoding=env_file_encoding, ) except ValidationError as ex: - raise ServiceInitializationError("Failed to create Github Copilot settings.", ex) from ex + raise ServiceInitializationError("Failed to create GitHub Copilot settings.", ex) from ex self._instructions = instructions self._tools = normalize_tools(tools) @@ -237,7 +237,7 @@ async def start(self) -> None: await self._client.start() self._started = True except Exception as ex: - raise ServiceException(f"Failed to start Github Copilot client: {ex}") from ex + raise ServiceException(f"Failed to start GitHub Copilot client: {ex}") from ex async def stop(self) -> None: """Stop the Copilot client and clean up resources. @@ -313,7 +313,7 @@ async def run( try: response_event = await session.send_and_wait({"prompt": prompt}, timeout=timeout) except Exception as ex: - raise ServiceException(f"Github Copilot request failed: {ex}") from ex + raise ServiceException(f"GitHub Copilot request failed: {ex}") from ex response_messages: list[ChatMessage] = [] response_id: str | None = None @@ -400,7 +400,7 @@ def event_handler(event: SessionEvent) -> None: completion_event.set() elif event.type == SessionEventType.SESSION_ERROR: error_msg = event.data.message or "Unknown error" - errors.append(ServiceException(f"Github Copilot session error: {error_msg}")) + errors.append(ServiceException(f"GitHub Copilot session error: {error_msg}")) completion_event.set() unsubscribe = session.on(event_handler) @@ -522,7 +522,7 @@ async def _get_or_create_session( ServiceException: If the session cannot be created. """ if not self._client: - raise ServiceException("Github Copilot client not initialized. Call start() first.") + raise ServiceException("GitHub Copilot client not initialized. Call start() first.") if thread.service_thread_id and thread.service_thread_id in self._sessions: return self._sessions[thread.service_thread_id] @@ -547,4 +547,4 @@ async def _get_or_create_session( self._sessions[session.session_id] = session return session except Exception as ex: - raise ServiceException(f"Failed to create Github Copilot session: {ex}") from ex + raise ServiceException(f"Failed to create GitHub Copilot session: {ex}") from ex diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py index 904fe3615d..ff81deb88b 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py @@ -6,7 +6,7 @@ class GithubCopilotSettings(AFBaseSettings): - """Github Copilot model settings. + """GitHub Copilot model settings. The settings are first loaded from environment variables with the prefix 'GITHUB_COPILOT_'. If the environment variables are not found, the settings can be loaded from a .env file diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 5e36572985..de3faf0d13 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-github-copilot" -description = "Github Copilot integration for Microsoft Agent Framework." +description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" diff --git a/python/packages/github_copilot/tests/conftest.py b/python/packages/github_copilot/tests/conftest.py deleted file mode 100644 index 36e88f9297..0000000000 --- a/python/packages/github_copilot/tests/conftest.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock -from uuid import uuid4 - -import pytest -from copilot.generated.session_events import Data, SessionEvent, SessionEventType - - -def create_session_event( - event_type: SessionEventType, - content: str | None = None, - delta_content: str | None = None, - message_id: str | None = None, - error_message: str | None = None, -) -> SessionEvent: - """Create a mock session event for testing.""" - data = Data( - content=content, - delta_content=delta_content, - message_id=message_id or str(uuid4()), - message=error_message, - ) - return SessionEvent( - data=data, - id=uuid4(), - timestamp=datetime.now(timezone.utc), - type=event_type, - ) - - -@pytest.fixture -def mock_session() -> MagicMock: - """Create a mock CopilotSession.""" - session = MagicMock() - session.session_id = "test-session-id" - session.send = AsyncMock(return_value="test-message-id") - session.send_and_wait = AsyncMock() - session.destroy = AsyncMock() - session.on = MagicMock(return_value=lambda: None) - return session - - -@pytest.fixture -def mock_client(mock_session: MagicMock) -> MagicMock: - """Create a mock CopilotClient.""" - client = MagicMock() - client.start = AsyncMock() - client.stop = AsyncMock(return_value=[]) - client.create_session = AsyncMock(return_value=mock_session) - return client - - -@pytest.fixture -def assistant_message_event() -> SessionEvent: - """Create a mock assistant message event.""" - return create_session_event( - SessionEventType.ASSISTANT_MESSAGE, - content="Test response", - message_id="test-msg-id", - ) - - -@pytest.fixture -def assistant_delta_event() -> SessionEvent: - """Create a mock assistant message delta event.""" - return create_session_event( - SessionEventType.ASSISTANT_MESSAGE_DELTA, - delta_content="Hello", - message_id="test-msg-id", - ) - - -@pytest.fixture -def session_idle_event() -> SessionEvent: - """Create a mock session idle event.""" - return create_session_event(SessionEventType.SESSION_IDLE) - - -@pytest.fixture -def session_error_event() -> SessionEvent: - """Create a mock session error event.""" - return create_session_event( - SessionEventType.SESSION_ERROR, - error_message="Test error", - ) 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 6886c99010..e82aa3ebf3 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1,16 +1,97 @@ # Copyright (c) Microsoft. All rights reserved. +from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 import pytest from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage, Content, Role from agent_framework.exceptions import ServiceException -from copilot.generated.session_events import SessionEvent +from copilot.generated.session_events import Data, SessionEvent, SessionEventType from agent_framework_github_copilot import GithubCopilotAgent, GithubCopilotOptions +def create_session_event( + event_type: SessionEventType, + content: str | None = None, + delta_content: str | None = None, + message_id: str | None = None, + error_message: str | None = None, +) -> SessionEvent: + """Create a mock session event for testing.""" + data = Data( + content=content, + delta_content=delta_content, + message_id=message_id or str(uuid4()), + message=error_message, + ) + return SessionEvent( + data=data, + id=uuid4(), + timestamp=datetime.now(timezone.utc), + type=event_type, + ) + + +@pytest.fixture +def mock_session() -> MagicMock: + """Create a mock CopilotSession.""" + session = MagicMock() + session.session_id = "test-session-id" + session.send = AsyncMock(return_value="test-message-id") + session.send_and_wait = AsyncMock() + session.destroy = AsyncMock() + session.on = MagicMock(return_value=lambda: None) + return session + + +@pytest.fixture +def mock_client(mock_session: MagicMock) -> MagicMock: + """Create a mock CopilotClient.""" + client = MagicMock() + client.start = AsyncMock() + client.stop = AsyncMock(return_value=[]) + client.create_session = AsyncMock(return_value=mock_session) + return client + + +@pytest.fixture +def assistant_message_event() -> SessionEvent: + """Create a mock assistant message event.""" + return create_session_event( + SessionEventType.ASSISTANT_MESSAGE, + content="Test response", + message_id="test-msg-id", + ) + + +@pytest.fixture +def assistant_delta_event() -> SessionEvent: + """Create a mock assistant message delta event.""" + return create_session_event( + SessionEventType.ASSISTANT_MESSAGE_DELTA, + delta_content="Hello", + message_id="test-msg-id", + ) + + +@pytest.fixture +def session_idle_event() -> SessionEvent: + """Create a mock session idle event.""" + return create_session_event(SessionEventType.SESSION_IDLE) + + +@pytest.fixture +def session_error_event() -> SessionEvent: + """Create a mock session error event.""" + return create_session_event( + SessionEventType.SESSION_ERROR, + error_message="Test error", + ) + + class TestGithubCopilotAgentInit: """Test cases for GithubCopilotAgent initialization.""" diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/getting_started/agents/github_copilot/README.md index d13f785a93..744869d94a 100644 --- a/python/samples/getting_started/agents/github_copilot/README.md +++ b/python/samples/getting_started/agents/github_copilot/README.md @@ -1,4 +1,4 @@ -# Github Copilot Agent Examples +# GitHub Copilot Agent Examples This directory contains examples demonstrating how to use the `GithubCopilotAgent` from the Microsoft Agent Framework. @@ -13,8 +13,8 @@ This directory contains examples demonstrating how to use the `GithubCopilotAgen ## Prerequisites -1. **Github Copilot CLI**: Install and authenticate the Copilot CLI -2. **Github Copilot Subscription**: An active Github Copilot subscription +1. **GitHub Copilot CLI**: Install and authenticate the Copilot CLI +2. **GitHub Copilot Subscription**: An active GitHub Copilot subscription 3. **Install the package**: ```bash pip install agent-framework-github-copilot --pre diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py index 8d867f3ea1..fd7168f1af 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. """ -Github Copilot Agent Basic Example +GitHub Copilot Agent Basic Example This sample demonstrates basic usage of GithubCopilotAgent. Shows both streaming and non-streaming responses with function tools. @@ -61,7 +61,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Basic Github Copilot Agent Example ===") + print("=== Basic GitHub Copilot Agent Example ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py index e0339a475e..76ffae22ac 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. """ -Github Copilot Agent with File Operation Permissions +GitHub Copilot Agent with File Operation Permissions This sample demonstrates how to enable file read and write operations with GithubCopilotAgent. By setting allowed_permissions to include "read" and/or "write", the agent can read from @@ -50,7 +50,7 @@ async def read_write_example() -> None: async def main() -> None: - print("=== Github Copilot Agent with File Operation Permissions ===\n") + print("=== GitHub Copilot Agent with File Operation Permissions ===\n") await read_only_example() await read_write_example() diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py index 8bed30d03b..a37204e2c7 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. """ -Github Copilot Agent with Multiple Permissions +GitHub Copilot Agent with Multiple Permissions This sample demonstrates how to enable multiple permission types with GithubCopilotAgent. By combining different permission kinds, the agent can perform complex tasks that require @@ -24,7 +24,7 @@ async def main() -> None: - print("=== Github Copilot Agent with Multiple Permissions ===\n") + print("=== GitHub Copilot Agent with Multiple Permissions ===\n") # Enable shell, read, and write permissions for a development assistant agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py index 04dbf7d0d3..e3b65548d0 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. """ -Github Copilot Agent with Shell Permissions +GitHub Copilot Agent with Shell Permissions This sample demonstrates how to enable shell command execution with GithubCopilotAgent. By setting allowed_permissions to include "shell", the agent can execute shell commands @@ -17,7 +17,7 @@ async def main() -> None: - print("=== Github Copilot Agent with Shell Permissions ===\n") + print("=== GitHub Copilot Agent with Shell Permissions ===\n") agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful assistant that can execute shell commands.", From da2edb54e5fc0709eb5e38597dc78daa105b5f56 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:31:39 -0800 Subject: [PATCH 05/14] Resolved comments and more tests --- .../agent_framework_github_copilot/_agent.py | 37 +-- .../tests/test_github_copilot_agent.py | 262 ++++++++++++++++++ 2 files changed, 277 insertions(+), 22 deletions(-) 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 72f6fe24a4..2de35523d1 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import contextlib import sys from collections.abc import AsyncIterable, Callable, MutableMapping, Sequence @@ -377,43 +378,35 @@ async def run_stream( input_messages = normalize_messages(messages) prompt = "\n".join([message.text for message in input_messages]) - import asyncio - - completion_event = asyncio.Event() - updates: list[AgentResponseUpdate] = [] - errors: list[Exception] = [] + queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() def event_handler(event: SessionEvent) -> None: if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: delta_content = event.data.delta_content or "" if delta_content: - updates.append( - AgentResponseUpdate( - role=Role.ASSISTANT, - contents=[Content.from_text(delta_content)], - response_id=event.data.message_id, - message_id=event.data.message_id, - raw_representation=event, - ) + update = AgentResponseUpdate( + role=Role.ASSISTANT, + contents=[Content.from_text(delta_content)], + response_id=event.data.message_id, + message_id=event.data.message_id, + raw_representation=event, ) + queue.put_nowait(update) elif event.type == SessionEventType.SESSION_IDLE: - completion_event.set() + queue.put_nowait(None) elif event.type == SessionEventType.SESSION_ERROR: error_msg = event.data.message or "Unknown error" - errors.append(ServiceException(f"GitHub Copilot session error: {error_msg}")) - completion_event.set() + queue.put_nowait(ServiceException(f"GitHub Copilot session error: {error_msg}")) unsubscribe = session.on(event_handler) try: await session.send({"prompt": prompt}) - await completion_event.wait() - - if errors: - raise errors[0] - for update in updates: - yield update + while (item := await queue.get()) is not None: + if isinstance(item, Exception): + raise item + yield item finally: unsubscribe() 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 e82aa3ebf3..69dd1e8bda 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -187,6 +187,36 @@ async def test_context_manager(self, mock_client: MagicMock) -> None: mock_client.stop.assert_not_called() assert agent._started is False # type: ignore + async def test_stop_calls_client_stop_when_agent_owns_client(self) -> None: + """Test that stop calls client.stop() when agent created the client.""" + with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient: + mock_client = MagicMock() + mock_client.start = AsyncMock() + mock_client.stop = AsyncMock() + MockClient.return_value = mock_client + + agent = GithubCopilotAgent() + await agent.start() + await agent.stop() + + mock_client.stop.assert_called_once() + + async def test_start_creates_client_with_options(self) -> None: + """Test that start creates client with cli_path and log_level from settings.""" + 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={"cli_path": "/custom/path", "log_level": "debug"} + ) + await agent.start() + + call_args = MockClient.call_args[0][0] + assert call_args["cli_path"] == "/custom/path" + assert call_args["log_level"] == "debug" + class TestGithubCopilotAgentRun: """Test cases for run method.""" @@ -358,6 +388,29 @@ def mock_on(handler: Any) -> Any: async for _ in agent.run_stream("Hello"): pass + async def test_run_stream_auto_starts( + self, + mock_client: MagicMock, + mock_session: MagicMock, + session_idle_event: SessionEvent, + ) -> None: + """Test that run_stream auto-starts the agent if not started.""" + + def mock_on(handler: Any) -> Any: + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + + agent = GithubCopilotAgent(client=mock_client) + assert agent._started is False # type: ignore + + async for _ in agent.run_stream("Hello"): + pass + + assert agent._started is True # type: ignore + mock_client.start.assert_called_once() + class TestGithubCopilotAgentSessionManagement: """Test cases for session management.""" @@ -412,6 +465,21 @@ async def test_session_config_includes_instructions( assert config["system_message"]["mode"] == "append" assert config["system_message"]["content"] == "You are a helpful assistant." + async def test_session_config_includes_streaming_flag( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session config includes the streaming flag.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + await agent._get_or_create_session(AgentThread(), [], streaming=True) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert config["streaming"] is True + class TestGithubCopilotAgentToolConversion: """Test cases for tool conversion.""" @@ -464,3 +532,197 @@ def runtime_tool(y: str) -> str: config = call_args[0][0] assert "tools" in config assert len(config["tools"]) == 2 + + async def test_tool_handler_returns_success_result( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that tool handler returns success result on successful invocation.""" + + def my_tool(arg: str) -> str: + """A test tool.""" + return f"Result: {arg}" + + agent = GithubCopilotAgent(client=mock_client, tools=[my_tool]) + await agent.start() + + await agent._get_or_create_session(AgentThread(), agent._tools) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + copilot_tool = config["tools"][0] + + result = await copilot_tool.handler({"arguments": {"arg": "test"}}) + + assert result["resultType"] == "success" + assert result["textResultForLlm"] == "Result: test" + + async def test_tool_handler_returns_failure_result_on_error( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that tool handler returns failure result when invocation raises exception.""" + + def failing_tool(arg: str) -> str: + """A tool that fails.""" + raise ValueError("Something went wrong") + + agent = GithubCopilotAgent(client=mock_client, tools=[failing_tool]) + await agent.start() + + await agent._get_or_create_session(AgentThread(), agent._tools) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + copilot_tool = config["tools"][0] + + result = await copilot_tool.handler({"arguments": {"arg": "test"}}) + + assert result["resultType"] == "failure" + assert "Something went wrong" in result["textResultForLlm"] + assert "Something went wrong" in result["error"] + + +class TestGithubCopilotAgentErrorHandling: + """Test cases for error handling.""" + + async def test_start_raises_on_client_error(self, mock_client: MagicMock) -> None: + """Test that start raises ServiceException when client fails to start.""" + mock_client.start.side_effect = Exception("Connection failed") + + agent = GithubCopilotAgent(client=mock_client) + + with pytest.raises(ServiceException, match="Failed to start GitHub Copilot client"): + await agent.start() + + async def test_run_raises_on_send_error( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that run raises ServiceException when send_and_wait fails.""" + mock_session.send_and_wait.side_effect = Exception("Request timeout") + + agent = GithubCopilotAgent(client=mock_client) + + with pytest.raises(ServiceException, match="GitHub Copilot request failed"): + await agent.run("Hello") + + async def test_get_or_create_session_raises_on_create_error( + self, + mock_client: MagicMock, + ) -> None: + """Test that _get_or_create_session raises ServiceException when create_session fails.""" + mock_client.create_session.side_effect = Exception("Session creation failed") + + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + with pytest.raises(ServiceException, match="Failed to create GitHub Copilot session"): + await agent._get_or_create_session(AgentThread(), []) # type: ignore + + async def test_get_or_create_session_raises_when_client_not_initialized(self) -> None: + """Test that _get_or_create_session raises ServiceException when client is not initialized.""" + agent = GithubCopilotAgent() + # Don't call start() - client remains None + + with pytest.raises(ServiceException, match="GitHub Copilot client not initialized"): + await agent._get_or_create_session(AgentThread(), []) # type: ignore + + +class TestGithubCopilotAgentPermissions: + """Test cases for permission handling.""" + + def test_create_permission_handler_returns_none_when_no_permissions(self) -> None: + """Test that no handler is created when allowed_permissions is not provided.""" + agent = GithubCopilotAgent() + assert agent._permission_handler is None # type: ignore + + def test_create_permission_handler_returns_none_for_empty_list(self) -> None: + """Test that no handler is created when allowed_permissions is empty.""" + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"allowed_permissions": []} + ) + assert agent._permission_handler is None # type: ignore + + def test_create_permission_handler_returns_handler_when_permissions_provided(self) -> None: + """Test that a handler is created when allowed_permissions is provided.""" + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"allowed_permissions": ["shell"]} + ) + assert agent._permission_handler is not None # type: ignore + + def test_permission_handler_approves_allowed_permission(self) -> None: + """Test that the handler approves permissions in the allowed list.""" + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"allowed_permissions": ["shell", "read"]} + ) + handler = agent._permission_handler # type: ignore + assert handler is not None + + result = handler({"kind": "shell"}, {}) + assert result["kind"] == "approved" + + result = handler({"kind": "read"}, {}) + assert result["kind"] == "approved" + + def test_permission_handler_denies_non_allowed_permission(self) -> None: + """Test that the handler denies permissions not in the allowed list.""" + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"allowed_permissions": ["shell"]} + ) + handler = agent._permission_handler # type: ignore + assert handler is not None + + result = handler({"kind": "write"}, {}) + assert result["kind"] == "denied-interactively-by-user" + + result = handler({"kind": "read"}, {}) + assert result["kind"] == "denied-interactively-by-user" + + def test_permission_handler_denies_unknown_permission(self) -> None: + """Test that the handler denies unknown permission kinds.""" + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"allowed_permissions": ["shell"]} + ) + handler = agent._permission_handler # type: ignore + assert handler is not None + + result = handler({"kind": "unknown"}, {}) + assert result["kind"] == "denied-interactively-by-user" + + async def test_session_config_includes_permission_handler( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session config includes permission handler when permissions are set.""" + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + client=mock_client, + default_options={"allowed_permissions": ["shell", "read"]}, + ) + await agent.start() + + await agent._get_or_create_session(AgentThread(), []) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert "on_permission_request" in config + assert config["on_permission_request"] is not None + + async def test_session_config_excludes_permission_handler_when_not_set( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session config does not include permission handler when not set.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + await agent._get_or_create_session(AgentThread(), []) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert "on_permission_request" not in config From 3e96ed65800c09256ca1fe2165590853bb72f1e2 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:11:32 -0800 Subject: [PATCH 06/14] Updated tool handling --- .../agent_framework_github_copilot/_agent.py | 37 ++---------- .../tests/test_github_copilot_agent.py | 60 ++++++------------- 2 files changed, 21 insertions(+), 76 deletions(-) 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 2de35523d1..15d29b2d65 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -268,11 +268,6 @@ async def run( messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, *, thread: AgentThread | None = None, - tools: ToolProtocol - | Callable[..., Any] - | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] - | None = None, options: TOptions | None = None, **kwargs: Any, ) -> AgentResponse: @@ -287,7 +282,6 @@ async def run( Keyword Args: thread: The conversation thread associated with the message(s). - tools: Additional tools to use for this specific run. options: Runtime options (model, timeout, etc.). kwargs: Additional keyword arguments. @@ -306,8 +300,7 @@ async def run( opts: dict[str, Any] = dict(options) if options else {} timeout = opts.pop("timeout", None) or self._settings.timeout or DEFAULT_TIMEOUT_SECONDS - merged_tools = self._merge_tools(tools) - session = await self._get_or_create_session(thread, merged_tools, streaming=False) + session = await self._get_or_create_session(thread, streaming=False) input_messages = normalize_messages(messages) prompt = "\n".join([message.text for message in input_messages]) @@ -339,11 +332,6 @@ async def run_stream( messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, *, thread: AgentThread | None = None, - tools: ToolProtocol - | Callable[..., Any] - | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] - | None = None, options: TOptions | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: @@ -357,7 +345,6 @@ async def run_stream( Keyword Args: thread: The conversation thread associated with the message(s). - tools: Additional tools to use for this specific run. options: Runtime options (model, timeout, etc.). kwargs: Additional keyword arguments. @@ -373,8 +360,7 @@ async def run_stream( if not thread: thread = self.get_new_thread() - merged_tools = self._merge_tools(tools) - session = await self._get_or_create_session(thread, merged_tools, streaming=True) + session = await self._get_or_create_session(thread, streaming=True) input_messages = normalize_messages(messages) prompt = "\n".join([message.text for message in input_messages]) @@ -431,19 +417,6 @@ def handler( return handler - def _merge_tools( - self, - runtime_tools: ToolProtocol - | Callable[..., Any] - | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] - | None, - ) -> list[ToolProtocol | MutableMapping[str, Any]]: - """Merge runtime tools with default tools.""" - result = list(self._tools) - result.extend(normalize_tools(runtime_tools)) - return result - def _convert_tools_to_copilot_tools( self, tools: list[ToolProtocol | MutableMapping[str, Any]], @@ -498,14 +471,12 @@ async def handler(invocation: ToolInvocation) -> ToolResult: async def _get_or_create_session( self, thread: AgentThread, - tools: list[ToolProtocol | MutableMapping[str, Any]], streaming: bool = False, ) -> CopilotSession: """Get an existing session or create a new one for the thread. Args: thread: The conversation thread. - tools: Tools to register with the session. streaming: Whether to enable streaming for the session. Returns: @@ -528,8 +499,8 @@ async def _get_or_create_session( if self._instructions: config["system_message"] = {"mode": "append", "content": self._instructions} - if tools: - config["tools"] = self._convert_tools_to_copilot_tools(tools) + if self._tools: + config["tools"] = self._convert_tools_to_copilot_tools(self._tools) if self._permission_handler: config["on_permission_request"] = self._permission_handler 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 69dd1e8bda..2894943ea0 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -171,7 +171,7 @@ async def test_stop_cleans_up(self, mock_client: MagicMock, mock_session: MagicM await agent.start() mock_client.create_session.return_value = mock_session - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore await agent.stop() @@ -443,7 +443,7 @@ async def test_session_config_includes_model( ) await agent.start() - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -458,7 +458,7 @@ async def test_session_config_includes_instructions( agent = GithubCopilotAgent(client=mock_client, instructions="You are a helpful assistant.") await agent.start() - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -474,7 +474,7 @@ async def test_session_config_includes_streaming_flag( agent = GithubCopilotAgent(client=mock_client) await agent.start() - await agent._get_or_create_session(AgentThread(), [], streaming=True) # type: ignore + await agent._get_or_create_session(AgentThread(), streaming=True) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -498,7 +498,7 @@ def my_tool(arg: str) -> str: agent = GithubCopilotAgent(client=mock_client, tools=[my_tool]) await agent.start() - await agent._get_or_create_session(AgentThread(), agent._tools) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -507,32 +507,6 @@ def my_tool(arg: str) -> str: assert config["tools"][0].name == "my_tool" assert config["tools"][0].description == "A test tool." - async def test_runtime_tools_merged( - self, - mock_client: MagicMock, - mock_session: MagicMock, - assistant_message_event: SessionEvent, - ) -> None: - """Test that runtime tools are merged with agent tools.""" - - def agent_tool(x: str) -> str: - """Agent tool.""" - return x - - def runtime_tool(y: str) -> str: - """Runtime tool.""" - return y - - mock_session.send_and_wait.return_value = assistant_message_event - - agent = GithubCopilotAgent(client=mock_client, tools=[agent_tool]) - await agent.run("Hello", tools=[runtime_tool]) - - call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "tools" in config - assert len(config["tools"]) == 2 - async def test_tool_handler_returns_success_result( self, mock_client: MagicMock, @@ -547,7 +521,7 @@ def my_tool(arg: str) -> str: agent = GithubCopilotAgent(client=mock_client, tools=[my_tool]) await agent.start() - await agent._get_or_create_session(AgentThread(), agent._tools) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -572,7 +546,7 @@ def failing_tool(arg: str) -> str: agent = GithubCopilotAgent(client=mock_client, tools=[failing_tool]) await agent.start() - await agent._get_or_create_session(AgentThread(), agent._tools) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -621,7 +595,7 @@ async def test_get_or_create_session_raises_on_create_error( await agent.start() with pytest.raises(ServiceException, match="Failed to create GitHub Copilot session"): - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore async def test_get_or_create_session_raises_when_client_not_initialized(self) -> None: """Test that _get_or_create_session raises ServiceException when client is not initialized.""" @@ -629,7 +603,7 @@ async def test_get_or_create_session_raises_when_client_not_initialized(self) -> # Don't call start() - client remains None with pytest.raises(ServiceException, match="GitHub Copilot client not initialized"): - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore class TestGithubCopilotAgentPermissions: @@ -663,10 +637,10 @@ def test_permission_handler_approves_allowed_permission(self) -> None: assert handler is not None result = handler({"kind": "shell"}, {}) - assert result["kind"] == "approved" + assert result["kind"] == "approved" # type: ignore result = handler({"kind": "read"}, {}) - assert result["kind"] == "approved" + assert result["kind"] == "approved" # type: ignore def test_permission_handler_denies_non_allowed_permission(self) -> None: """Test that the handler denies permissions not in the allowed list.""" @@ -677,10 +651,10 @@ def test_permission_handler_denies_non_allowed_permission(self) -> None: assert handler is not None result = handler({"kind": "write"}, {}) - assert result["kind"] == "denied-interactively-by-user" + assert result["kind"] == "denied-interactively-by-user" # type: ignore result = handler({"kind": "read"}, {}) - assert result["kind"] == "denied-interactively-by-user" + assert result["kind"] == "denied-interactively-by-user" # type: ignore def test_permission_handler_denies_unknown_permission(self) -> None: """Test that the handler denies unknown permission kinds.""" @@ -690,8 +664,8 @@ def test_permission_handler_denies_unknown_permission(self) -> None: handler = agent._permission_handler # type: ignore assert handler is not None - result = handler({"kind": "unknown"}, {}) - assert result["kind"] == "denied-interactively-by-user" + result = handler({"kind": "unknown"}, {}) # type: ignore + assert result["kind"] == "denied-interactively-by-user" # type: ignore async def test_session_config_includes_permission_handler( self, @@ -705,7 +679,7 @@ async def test_session_config_includes_permission_handler( ) await agent.start() - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -721,7 +695,7 @@ async def test_session_config_excludes_permission_handler_when_not_set( agent = GithubCopilotAgent(client=mock_client) await agent.start() - await agent._get_or_create_session(AgentThread(), []) # type: ignore + await agent._get_or_create_session(AgentThread()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] From 45f40386017a6896b5427460c43d53d38859a853 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:49:29 -0800 Subject: [PATCH 07/14] Updated tool handling --- .../agent_framework_github_copilot/_agent.py | 13 ++++- .../tests/test_github_copilot_agent.py | 54 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) 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 15d29b2d65..05a11aba6b 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import logging import sys from collections.abc import AsyncIterable, Callable, MutableMapping, Sequence from typing import Any, ClassVar, Generic, TypedDict @@ -48,6 +49,8 @@ PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult] """Type for permission request handlers.""" +logger = logging.getLogger("agent_framework.github_copilot") + class GithubCopilotOptions(TypedDict, total=False): """GitHub Copilot-specific options.""" @@ -432,8 +435,14 @@ def _convert_tools_to_copilot_tools( copilot_tools: list[CopilotTool] = [] for tool in tools: - if isinstance(tool, AIFunction): - copilot_tools.append(self._ai_function_to_copilot_tool(tool)) # type: ignore + if isinstance(tool, ToolProtocol): + match tool: + case AIFunction(): + copilot_tools.append(self._ai_function_to_copilot_tool(tool)) # type: ignore + case _: + logger.debug(f"Unsupported tool type: {type(tool)}") + elif isinstance(tool, CopilotTool): + copilot_tools.append(tool) return copilot_tools 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 2894943ea0..3384561995 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -558,6 +558,60 @@ def failing_tool(arg: str) -> str: assert "Something went wrong" in result["textResultForLlm"] assert "Something went wrong" in result["error"] + def test_copilot_tool_passthrough( + self, + mock_client: MagicMock, + ) -> None: + """Test that CopilotTool instances are passed through as-is.""" + from copilot.types import Tool as CopilotTool + + async def tool_handler(invocation: Any) -> Any: + return {"textResultForLlm": "result", "resultType": "success"} + + copilot_tool = CopilotTool( + name="direct_tool", + description="A direct CopilotTool", + handler=tool_handler, + parameters={"type": "object", "properties": {}}, + ) + + agent = GithubCopilotAgent(client=mock_client) + result = agent._convert_tools_to_copilot_tools([copilot_tool]) # type: ignore + + assert len(result) == 1 + assert result[0] == copilot_tool + + def test_mixed_tools_conversion( + self, + mock_client: MagicMock, + ) -> None: + """Test that mixed tool types are handled correctly.""" + from agent_framework._tools import ai_function + from copilot.types import Tool as CopilotTool + + @ai_function + def my_function(arg: str) -> str: + """A function tool.""" + return arg + + async def tool_handler(invocation: Any) -> Any: + return {"textResultForLlm": "result", "resultType": "success"} + + copilot_tool = CopilotTool( + name="direct_tool", + description="A direct CopilotTool", + handler=tool_handler, + ) + + agent = GithubCopilotAgent(client=mock_client) + result = agent._convert_tools_to_copilot_tools([my_function, copilot_tool]) # type: ignore + + assert len(result) == 2 + # First tool is converted AIFunction + assert result[0].name == "my_function" + # Second tool is CopilotTool passthrough + assert result[1] == copilot_tool + class TestGithubCopilotAgentErrorHandling: """Test cases for error handling.""" From 87381102b6a6d8a319f91ae8a7af7a5831617c65 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:55:21 -0800 Subject: [PATCH 08/14] Small fixes --- .../packages/core/agent_framework/_types.py | 1 - python/packages/github_copilot/README.md | 124 ------------------ 2 files changed, 125 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index d586f9ff5d..d8c34c769e 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -33,7 +33,6 @@ "FinishReason", "Role", "TextSpanRegion", - "TextSpanRegion", "ToolMode", "UsageDetails", "add_usage_details", diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 1f1a2fbd4d..87b4ea5d3e 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -9,127 +9,3 @@ pip install agent-framework-github-copilot --pre ## GitHub Copilot Agent The GitHub Copilot agent enables integration with GitHub Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework. - -### Prerequisites - -Before using the GitHub Copilot agent, you need: - -1. **GitHub Copilot CLI**: The Copilot CLI must be installed and authenticated -2. **GitHub Copilot Subscription**: An active GitHub Copilot subscription - -### Environment Variables - -The following environment variables can be used for configuration: - -- `GITHUB_COPILOT_CLI_PATH` - Path to the Copilot CLI executable (default: "copilot") -- `GITHUB_COPILOT_MODEL` - Model to use (e.g., "gpt-5", "claude-sonnet-4") -- `GITHUB_COPILOT_TIMEOUT` - Request timeout in seconds (default: 60) -- `GITHUB_COPILOT_LOG_LEVEL` - CLI log level (default: "info") - -### Basic Usage Example - -```python -import asyncio -from agent_framework.github_copilot import GithubCopilotAgent - -async def main(): - # Create agent using environment variables or defaults - async with GithubCopilotAgent() as agent: - # Run a simple query - result = await agent.run("What is the capital of France?") - print(result) - -asyncio.run(main()) -``` - -### Streaming Example - -```python -import asyncio -from agent_framework.github_copilot import GithubCopilotAgent - -async def main(): - async with GithubCopilotAgent() as agent: - print("Agent: ", end="", flush=True) - async for chunk in agent.run_stream("Explain Python decorators"): - if chunk.text: - print(chunk.text, end="", flush=True) - print() - -asyncio.run(main()) -``` - -### Using Typed Options - -```python -import asyncio -from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions - -async def main(): - # Create agent with typed options for IDE autocomplete - agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - default_options={"model": "claude-sonnet-4", "timeout": 120} - ) - - async with agent: - result = await agent.run("Hello!") - print(result) - -asyncio.run(main()) -``` - -### Using Tools - -Tools are defined using standard Agent Framework patterns (functions, callables, or ToolProtocol instances): - -```python -import asyncio -from agent_framework.github_copilot import GithubCopilotAgent - -def get_weather(city: str) -> str: - """Get the current weather for a city.""" - return f"The weather in {city} is sunny and 72F" - -async def main(): - async with GithubCopilotAgent(tools=[get_weather]) as agent: - result = await agent.run("What's the weather in Seattle?") - print(result) - -asyncio.run(main()) -``` - -### Using Permissions - -Enable Copilot to perform system operations by specifying allowed permissions: - -```python -import asyncio -from agent_framework.github_copilot import GithubCopilotAgent - -async def main(): - # Enable read and shell permissions - async with GithubCopilotAgent( - default_options={"allowed_permissions": ["read", "shell"]} - ) as agent: - result = await agent.run("List all Python files and show their line counts") - print(result) - -asyncio.run(main()) -``` - -Available permission kinds: -- `shell` - Execute shell commands -- `read` - Read files from the filesystem -- `write` - Write files to the filesystem -- `mcp` - Use MCP servers -- `url` - Fetch content from URLs - -### Examples - -For more comprehensive examples, see the [GitHub Copilot examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/github_copilot/) which demonstrate: - -- Basic non-streaming and streaming execution -- Custom tool integration using Agent Framework patterns -- Shell command execution with permissions -- File read and write operations -- Combining multiple permissions for complex tasks From cec0cb0fdc90175a92544c919f7a72721a159776 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:14:55 -0800 Subject: [PATCH 09/14] Removed default permission handler --- .../agent_framework_github_copilot/_agent.py | 56 +++++---------- .../tests/test_github_copilot_agent.py | 72 ++++++------------- .../agents/github_copilot/README.md | 26 ++++--- .../github_copilot_with_file_operations.py | 43 +++++------ ...ithub_copilot_with_multiple_permissions.py | 25 +++++-- .../github_copilot_with_shell.py | 30 +++++--- 6 files changed, 110 insertions(+), 142 deletions(-) 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 05a11aba6b..c19319d740 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -67,11 +67,10 @@ class GithubCopilotOptions(TypedDict, total=False): log_level: str """CLI log level.""" - allowed_permissions: Sequence[str] - """List of permission kinds to approve automatically. - Copilot may request permissions for operations like shell commands, - file writes, etc. Only permissions in this list will be approved. - Valid values: "shell", "write", "read", "mcp", "url". + on_permission_request: PermissionHandlerType + """Permission request handler. + Called when Copilot requests permission to perform an action (shell, read, write, etc.). + Takes a PermissionRequest and context dict, returns PermissionRequestResult. If not provided, all permission requests will be denied by default. """ @@ -186,7 +185,7 @@ def __init__( model = opts.pop("model", None) timeout = opts.pop("timeout", None) log_level = opts.pop("log_level", None) - allowed_permissions: Sequence[str] | None = opts.pop("allowed_permissions", None) + on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None) try: self._settings = GithubCopilotSettings( @@ -202,7 +201,7 @@ def __init__( self._instructions = instructions self._tools = normalize_tools(tools) - self._permission_handler = self._create_permission_handler(allowed_permissions) + self._permission_handler = on_permission_request self._default_options = opts self._started = False @@ -316,16 +315,17 @@ async def run( response_id: str | None = None if response_event and response_event.type == SessionEventType.ASSISTANT_MESSAGE: - content = response_event.data.content or "" message_id = response_event.data.message_id - response_messages.append( - ChatMessage( - role=Role.ASSISTANT, - contents=[Content.from_text(content)], - message_id=message_id, - raw_representation=response_event, + + if response_event.data.content: + response_messages.append( + ChatMessage( + role=Role.ASSISTANT, + contents=[Content.from_text(response_event.data.content)], + message_id=message_id, + raw_representation=response_event, + ) ) - ) response_id = message_id return AgentResponse(messages=response_messages, response_id=response_id) @@ -371,11 +371,10 @@ async def run_stream( def event_handler(event: SessionEvent) -> None: if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: - delta_content = event.data.delta_content or "" - if delta_content: + if event.data.delta_content: update = AgentResponseUpdate( role=Role.ASSISTANT, - contents=[Content.from_text(delta_content)], + contents=[Content.from_text(event.data.delta_content)], response_id=event.data.message_id, message_id=event.data.message_id, raw_representation=event, @@ -399,27 +398,6 @@ def event_handler(event: SessionEvent) -> None: finally: unsubscribe() - def _create_permission_handler( - self, - allowed_permissions: Sequence[str] | None, - ) -> PermissionHandlerType | None: - """Create a permission handler from a list of allowed permission kinds.""" - if not allowed_permissions: - return None - - allowed_set = set(allowed_permissions) - - def handler( - request: PermissionRequest, - context: dict[str, str], - ) -> PermissionRequestResult: - kind = request.get("kind") - if kind in allowed_set: - return PermissionRequestResult(kind="approved") - return PermissionRequestResult(kind="denied-interactively-by-user") - - return handler - def _convert_tools_to_copilot_tools( self, tools: list[ToolProtocol | MutableMapping[str, Any]], 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 3384561995..de1fabc2ed 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -663,73 +663,41 @@ async def test_get_or_create_session_raises_when_client_not_initialized(self) -> class TestGithubCopilotAgentPermissions: """Test cases for permission handling.""" - def test_create_permission_handler_returns_none_when_no_permissions(self) -> None: - """Test that no handler is created when allowed_permissions is not provided.""" + def test_no_permission_handler_when_not_provided(self) -> None: + """Test that no handler is set when on_permission_request is not provided.""" agent = GithubCopilotAgent() assert agent._permission_handler is None # type: ignore - def test_create_permission_handler_returns_none_for_empty_list(self) -> None: - """Test that no handler is created when allowed_permissions is empty.""" - agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - default_options={"allowed_permissions": []} - ) - assert agent._permission_handler is None # type: ignore - - def test_create_permission_handler_returns_handler_when_permissions_provided(self) -> None: - """Test that a handler is created when allowed_permissions is provided.""" - agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - default_options={"allowed_permissions": ["shell"]} - ) - assert agent._permission_handler is not None # type: ignore - - def test_permission_handler_approves_allowed_permission(self) -> None: - """Test that the handler approves permissions in the allowed list.""" - agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - default_options={"allowed_permissions": ["shell", "read"]} - ) - handler = agent._permission_handler # type: ignore - assert handler is not None + def test_permission_handler_set_when_provided(self) -> None: + """Test that a handler is set when on_permission_request is provided.""" + from copilot.types import PermissionRequest, PermissionRequestResult - result = handler({"kind": "shell"}, {}) - assert result["kind"] == "approved" # type: ignore + def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + if request.get("kind") == "shell": + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") - result = handler({"kind": "read"}, {}) - assert result["kind"] == "approved" # type: ignore - - def test_permission_handler_denies_non_allowed_permission(self) -> None: - """Test that the handler denies permissions not in the allowed list.""" - agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - default_options={"allowed_permissions": ["shell"]} - ) - handler = agent._permission_handler # type: ignore - assert handler is not None - - result = handler({"kind": "write"}, {}) - assert result["kind"] == "denied-interactively-by-user" # type: ignore - - result = handler({"kind": "read"}, {}) - assert result["kind"] == "denied-interactively-by-user" # type: ignore - - def test_permission_handler_denies_unknown_permission(self) -> None: - """Test that the handler denies unknown permission kinds.""" agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - default_options={"allowed_permissions": ["shell"]} + default_options={"on_permission_request": approve_shell} ) - handler = agent._permission_handler # type: ignore - assert handler is not None - - result = handler({"kind": "unknown"}, {}) # type: ignore - assert result["kind"] == "denied-interactively-by-user" # type: ignore + assert agent._permission_handler is not None # type: ignore async def test_session_config_includes_permission_handler( self, mock_client: MagicMock, mock_session: MagicMock, ) -> None: - """Test that session config includes permission handler when permissions are set.""" + """Test that session config includes permission handler when provided.""" + from copilot.types import PermissionRequest, PermissionRequestResult + + def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + if request.get("kind") in ("shell", "read"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( client=mock_client, - default_options={"allowed_permissions": ["shell", "read"]}, + default_options={"on_permission_request": approve_shell_read}, ) await agent.start() diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/getting_started/agents/github_copilot/README.md index 744869d94a..e790328b6a 100644 --- a/python/samples/getting_started/agents/github_copilot/README.md +++ b/python/samples/getting_started/agents/github_copilot/README.md @@ -33,7 +33,7 @@ The following environment variables can be configured: ## Permission Kinds -When using `allowed_permissions`, the following permission kinds are available: +The following permission kinds can be approved via `on_permission_request`: | Permission | Description | |------------|-------------| @@ -76,20 +76,28 @@ async with GithubCopilotAgent(tools=[get_weather]) as agent: ### With Permissions +Implement a permission handler to approve specific actions: + ```python from agent_framework.github_copilot import GithubCopilotAgent +from copilot.types import PermissionRequest, PermissionRequestResult -# Read-only access -async with GithubCopilotAgent( - default_options={"allowed_permissions": ["read"]} -) as agent: - response = await agent.run("Read the README.md file") +def my_permission_handler( + request: PermissionRequest, + context: dict[str, str], +) -> PermissionRequestResult: + kind = request.get("kind") + print(f"Permission requested: {kind}") + + # Implement your approval logic here + if kind == "shell": + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") -# Full development access async with GithubCopilotAgent( - default_options={"allowed_permissions": ["shell", "read", "write"]} + default_options={"on_permission_request": my_permission_handler} ) as agent: - response = await agent.run("Create a new Python file with a hello world function") + response = await agent.run("List Python files") ``` ## Running the Examples diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py index 76ffae22ac..e96c135e36 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py @@ -4,8 +4,8 @@ GitHub Copilot Agent with File Operation Permissions This sample demonstrates how to enable file read and write operations with GithubCopilotAgent. -By setting allowed_permissions to include "read" and/or "write", the agent can read from -and write to files on the filesystem. +By providing a permission handler that approves "read" and/or "write" requests, the agent can +read from and write to files on the filesystem. SECURITY NOTE: Only enable file permissions when you trust the agent's actions. - "read" allows the agent to read any accessible file @@ -15,46 +15,37 @@ import asyncio from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions +from copilot.types import PermissionRequest, PermissionRequestResult -async def read_only_example() -> None: - """Example with read-only file permissions.""" - print("=== Read-Only File Access Example ===\n") +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + kind = request.get("kind", "unknown") + print(f"\n[Permission Request: {kind}]") - agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - instructions="You are a helpful assistant that can read files.", - default_options={"allowed_permissions": ["read"]}, - ) + if "path" in request: + print(f" Path: {request.get('path')}") - async with agent: - query = "Read the contents of README.md and summarize it" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") -async def read_write_example() -> None: - """Example with both read and write file permissions.""" - print("=== Read and Write File Access Example ===\n") +async def main() -> None: + print("=== GitHub Copilot Agent with File Operation Permissions ===\n") agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful assistant that can read and write files.", - default_options={"allowed_permissions": ["read", "write"]}, + default_options={"on_permission_request": prompt_permission}, ) async with agent: - query = "Create a file called 'hello.txt' with the text 'Hello from Copilot!'" + query = "Read the contents of README.md and summarize it" print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") -async def main() -> None: - print("=== GitHub Copilot Agent with File Operation Permissions ===\n") - - await read_only_example() - await read_write_example() - - if __name__ == "__main__": asyncio.run(main()) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py index a37204e2c7..445f2fb95e 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py @@ -4,8 +4,8 @@ GitHub Copilot Agent with Multiple Permissions This sample demonstrates how to enable multiple permission types with GithubCopilotAgent. -By combining different permission kinds, the agent can perform complex tasks that require -multiple capabilities. +By combining different permission kinds in the handler, the agent can perform complex tasks +that require multiple capabilities. Available permission kinds: - "shell": Execute shell commands @@ -21,19 +21,34 @@ import asyncio from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions +from copilot.types import PermissionRequest, PermissionRequestResult + + +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + kind = request.get("kind", "unknown") + print(f"\n[Permission Request: {kind}]") + + if "command" in request: + print(f" Command: {request.get('command')}") + if "path" in request: + print(f" Path: {request.get('path')}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") async def main() -> None: print("=== GitHub Copilot Agent with Multiple Permissions ===\n") - # Enable shell, read, and write permissions for a development assistant agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful development assistant that can read, write files and run commands.", - default_options={"allowed_permissions": ["shell", "read", "write"]}, + default_options={"on_permission_request": prompt_permission}, ) async with agent: - # Complex task that requires multiple permissions query = "List the first 3 Python files, then read the first one and create a summary in summary.txt" print(f"User: {query}") result = await agent.run(query) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py index e3b65548d0..57ffd7897a 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py @@ -4,8 +4,8 @@ GitHub Copilot Agent with Shell Permissions This sample demonstrates how to enable shell command execution with GithubCopilotAgent. -By setting allowed_permissions to include "shell", the agent can execute shell commands -to perform tasks like listing files, running scripts, or executing system commands. +By providing a permission handler that approves "shell" requests, the agent can execute +shell commands to perform tasks like listing files, running scripts, or executing system commands. SECURITY NOTE: Only enable shell permissions when you trust the agent's actions. Shell commands have full access to your system within the permissions of the running process. @@ -14,6 +14,21 @@ import asyncio from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions +from copilot.types import PermissionRequest, PermissionRequestResult + + +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + kind = request.get("kind", "unknown") + print(f"\n[Permission Request: {kind}]") + + if "command" in request: + print(f" Command: {request.get('command')}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") async def main() -> None: @@ -21,18 +36,11 @@ async def main() -> None: agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( instructions="You are a helpful assistant that can execute shell commands.", - default_options={"allowed_permissions": ["shell"]}, + default_options={"on_permission_request": prompt_permission}, ) async with agent: - # Example: Run a shell command to display current directory - query = "Run 'pwd' and tell me the current directory" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - # Example: Run echo command - query = "Run 'echo Hello from shell!' and show me the output" + query = "List the first 3 Python files in the current directory" print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") From bfac4f2e13294d79e870dfcc2b9170219b8642e7 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:45:46 -0800 Subject: [PATCH 10/14] Resolved comments --- .../{github_copilot => github}/__init__.py | 2 +- .../{github_copilot => github}/__init__.pyi | 0 .../agent_framework_github_copilot/_agent.py | 18 ++++++++++-------- .../tests/test_github_copilot_agent.py | 13 +++++++++---- .../agents/github_copilot/README.md | 6 +++--- .../github_copilot/github_copilot_basic.py | 18 +++++++++++------- .../github_copilot_with_file_operations.py | 8 +++++--- ...github_copilot_with_multiple_permissions.py | 8 +++++--- .../github_copilot_with_shell.py | 8 +++++--- 9 files changed, 49 insertions(+), 32 deletions(-) rename python/packages/core/agent_framework/{github_copilot => github}/__init__.py (91%) rename python/packages/core/agent_framework/{github_copilot => github}/__init__.pyi (100%) diff --git a/python/packages/core/agent_framework/github_copilot/__init__.py b/python/packages/core/agent_framework/github/__init__.py similarity index 91% rename from python/packages/core/agent_framework/github_copilot/__init__.py rename to python/packages/core/agent_framework/github/__init__.py index b5e3aacce1..561be0a897 100644 --- a/python/packages/core/agent_framework/github_copilot/__init__.py +++ b/python/packages/core/agent_framework/github/__init__.py @@ -21,7 +21,7 @@ def __getattr__(name: str) -> Any: f"The package {package_name} is required to use `{name}`. " f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." ) from exc - raise AttributeError(f"Module `agent_framework.github_copilot` has no attribute {name}.") + raise AttributeError(f"Module `agent_framework.github` has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/github_copilot/__init__.pyi b/python/packages/core/agent_framework/github/__init__.pyi similarity index 100% rename from python/packages/core/agent_framework/github_copilot/__init__.pyi rename to python/packages/core/agent_framework/github/__init__.pyi 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 c19319d740..65907f8aa1 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -55,8 +55,11 @@ class GithubCopilotOptions(TypedDict, total=False): """GitHub Copilot-specific options.""" + instructions: str + """System message to append to the session.""" + cli_path: str - """Path to the Copilot CLI executable.""" + """Path to the Copilot CLI executable. Defaults to COPILOT_CLI_PATH env var or 'copilot' in PATH.""" model: str """Model to use (e.g., "gpt-5", "claude-sonnet-4").""" @@ -101,7 +104,7 @@ class GithubCopilotAgent(BaseAgent, Generic[TOptions]): response = await agent.run("Hello, world!") print(response) - With typed options: + With explicitly typed options: .. code-block:: python @@ -128,7 +131,6 @@ def get_weather(city: str) -> str: def __init__( self, client: CopilotClient | None = None, - instructions: str | None = None, *, id: str | None = None, name: str | None = None, @@ -149,7 +151,6 @@ def __init__( Args: client: Optional pre-configured CopilotClient instance. If not provided, a new client will be created using the other parameters. - instructions: System message to append to the session. Keyword Args: id: ID of the GithubCopilotAgent. @@ -181,6 +182,7 @@ def __init__( # Parse options opts: dict[str, Any] = dict(default_options) if default_options else {} + instructions = opts.pop("instructions", None) cli_path = opts.pop("cli_path", None) model = opts.pop("model", None) timeout = opts.pop("timeout", None) @@ -314,6 +316,8 @@ async def run( response_messages: list[ChatMessage] = [] response_id: str | None = None + # 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 @@ -398,7 +402,7 @@ def event_handler(event: SessionEvent) -> None: finally: unsubscribe() - def _convert_tools_to_copilot_tools( + def _prepare_tools( self, tools: list[ToolProtocol | MutableMapping[str, Any]], ) -> list[CopilotTool]: @@ -438,14 +442,12 @@ async def handler(invocation: ToolInvocation) -> ToolResult: return ToolResult( textResultForLlm=str(result), resultType="success", - toolTelemetry={}, ) except Exception as e: return ToolResult( textResultForLlm=f"Error: {e}", resultType="failure", error=str(e), - toolTelemetry={}, ) return CopilotTool( @@ -487,7 +489,7 @@ async def _get_or_create_session( config["system_message"] = {"mode": "append", "content": self._instructions} if self._tools: - config["tools"] = self._convert_tools_to_copilot_tools(self._tools) + config["tools"] = self._prepare_tools(self._tools) if self._permission_handler: config["on_permission_request"] = self._permission_handler 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 de1fabc2ed..daef7364a1 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -128,7 +128,9 @@ def my_tool(arg: str) -> str: def test_init_with_instructions(self) -> None: """Test initialization with custom instructions.""" - agent = GithubCopilotAgent(instructions="You are a helpful assistant.") + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful assistant."} + ) assert agent._instructions == "You are a helpful assistant." # type: ignore @@ -455,7 +457,10 @@ async def test_session_config_includes_instructions( mock_session: MagicMock, ) -> None: """Test that session config includes instructions.""" - agent = GithubCopilotAgent(client=mock_client, instructions="You are a helpful assistant.") + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + client=mock_client, + default_options={"instructions": "You are a helpful assistant."}, + ) await agent.start() await agent._get_or_create_session(AgentThread()) # type: ignore @@ -576,7 +581,7 @@ async def tool_handler(invocation: Any) -> Any: ) agent = GithubCopilotAgent(client=mock_client) - result = agent._convert_tools_to_copilot_tools([copilot_tool]) # type: ignore + result = agent._prepare_tools([copilot_tool]) # type: ignore assert len(result) == 1 assert result[0] == copilot_tool @@ -604,7 +609,7 @@ async def tool_handler(invocation: Any) -> Any: ) agent = GithubCopilotAgent(client=mock_client) - result = agent._convert_tools_to_copilot_tools([my_function, copilot_tool]) # type: ignore + result = agent._prepare_tools([my_function, copilot_tool]) # type: ignore assert len(result) == 2 # First tool is converted AIFunction diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/getting_started/agents/github_copilot/README.md index e790328b6a..5b8eadefa2 100644 --- a/python/samples/getting_started/agents/github_copilot/README.md +++ b/python/samples/getting_started/agents/github_copilot/README.md @@ -50,7 +50,7 @@ The following permission kinds can be approved via `on_permission_request`: ### Basic Usage (No Permissions) ```python -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github import GithubCopilotAgent async with GithubCopilotAgent() as agent: response = await agent.run("Hello!") @@ -61,7 +61,7 @@ async with GithubCopilotAgent() as agent: ```python from typing import Annotated -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github import GithubCopilotAgent from pydantic import Field def get_weather( @@ -79,7 +79,7 @@ async with GithubCopilotAgent(tools=[get_weather]) as agent: Implement a permission handler to approve specific actions: ```python -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github import GithubCopilotAgent from copilot.types import PermissionRequest, PermissionRequestResult def my_permission_handler( diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py index fd7168f1af..afa9dbb7d6 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py @@ -17,7 +17,7 @@ from random import randint from typing import Annotated -from agent_framework.github_copilot import GithubCopilotAgent +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions from pydantic import Field @@ -33,10 +33,12 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - async with GithubCopilotAgent( - instructions="You are a helpful weather agent.", + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful weather agent."}, tools=[get_weather], - ) as agent: + ) + + async with agent: query = "What's the weather like in Seattle?" print(f"User: {query}") result = await agent.run(query) @@ -47,10 +49,12 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - async with GithubCopilotAgent( - instructions="You are a helpful weather agent.", + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful weather agent."}, tools=[get_weather], - ) as agent: + ) + + async with agent: query = "What's the weather like in Tokyo?" print(f"User: {query}") print("Agent: ", end="", flush=True) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py index e96c135e36..8068baef99 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py @@ -14,7 +14,7 @@ import asyncio -from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions from copilot.types import PermissionRequest, PermissionRequestResult @@ -36,8 +36,10 @@ async def main() -> None: print("=== GitHub Copilot Agent with File Operation Permissions ===\n") agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - instructions="You are a helpful assistant that can read and write files.", - default_options={"on_permission_request": prompt_permission}, + default_options={ + "instructions": "You are a helpful assistant that can read and write files.", + "on_permission_request": prompt_permission, + }, ) async with agent: diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py index 445f2fb95e..faff06ec33 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py @@ -20,7 +20,7 @@ import asyncio -from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions from copilot.types import PermissionRequest, PermissionRequestResult @@ -44,8 +44,10 @@ async def main() -> None: print("=== GitHub Copilot Agent with Multiple Permissions ===\n") agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - instructions="You are a helpful development assistant that can read, write files and run commands.", - default_options={"on_permission_request": prompt_permission}, + default_options={ + "instructions": "You are a helpful development assistant that can read, write files and run commands.", + "on_permission_request": prompt_permission, + }, ) async with agent: diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py index 57ffd7897a..ba983f518b 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py @@ -13,7 +13,7 @@ import asyncio -from agent_framework.github_copilot import GithubCopilotAgent, GithubCopilotOptions +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions from copilot.types import PermissionRequest, PermissionRequestResult @@ -35,8 +35,10 @@ async def main() -> None: print("=== GitHub Copilot Agent with Shell Permissions ===\n") agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( - instructions="You are a helpful assistant that can execute shell commands.", - default_options={"on_permission_request": prompt_permission}, + default_options={ + "instructions": "You are a helpful assistant that can execute shell commands.", + "on_permission_request": prompt_permission, + }, ) async with agent: From 285cec66f444fcfbe890cf9d7ca2e9b337bc8e74 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:56:12 -0800 Subject: [PATCH 11/14] Updated positional args --- .../github_copilot/agent_framework_github_copilot/_agent.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 65907f8aa1..ce0870286b 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -130,8 +130,8 @@ def get_weather(city: str) -> str: def __init__( self, - client: CopilotClient | None = None, *, + client: CopilotClient | None = None, id: str | None = None, name: str | None = None, description: str | None = None, @@ -148,11 +148,9 @@ def __init__( ) -> None: """Initialize the GitHub Copilot Agent. - Args: + Keyword Args: client: Optional pre-configured CopilotClient instance. If not provided, a new client will be created using the other parameters. - - Keyword Args: id: ID of the GithubCopilotAgent. name: Name of the GithubCopilotAgent. description: Description of the GithubCopilotAgent. From 59fe5a6647969c97a09bec1945fd3e1b9089250f Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:19:55 -0800 Subject: [PATCH 12/14] Updated docstrings --- .../agent_framework_github_copilot/_agent.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 ce0870286b..473b2e4f78 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -59,16 +59,17 @@ class GithubCopilotOptions(TypedDict, total=False): """System message to append to the session.""" cli_path: str - """Path to the Copilot CLI executable. Defaults to COPILOT_CLI_PATH env var or 'copilot' in PATH.""" + """Path to the Copilot CLI executable. Defaults to GITHUB_COPILOT_CLI_PATH environment variable + or 'copilot' in PATH.""" model: str - """Model to use (e.g., "gpt-5", "claude-sonnet-4").""" + """Model to use (e.g., "gpt-5", "claude-sonnet-4"). Defaults to GITHUB_COPILOT_MODEL environment variable.""" timeout: float - """Request timeout in seconds.""" + """Request timeout in seconds. Defaults to GITHUB_COPILOT_TIMEOUT environment variable or 60 seconds.""" log_level: str - """CLI log level.""" + """CLI log level. Defaults to GITHUB_COPILOT_LOG_LEVEL environment variable.""" on_permission_request: PermissionHandlerType """Permission request handler. From 1c891dc08444e348178766f8b8818478e36ecd34 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:23:55 -0800 Subject: [PATCH 13/14] Small fixes and more examples --- .../agent_framework_github_copilot/_agent.py | 57 +++++--- .../tests/test_github_copilot_agent.py | 65 ++++++++- .../agents/github_copilot/README.md | 96 ++---------- .../github_copilot_with_session.py | 137 ++++++++++++++++++ .../github_copilot/github_copilot_with_url.py | 52 +++++++ 5 files changed, 291 insertions(+), 116 deletions(-) create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_with_url.py 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 473b2e4f78..4571dea382 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -28,6 +28,7 @@ CopilotClientOptions, PermissionRequest, PermissionRequestResult, + ResumeSessionConfig, SessionConfig, ToolInvocation, ToolResult, @@ -177,7 +178,6 @@ def __init__( self._client = client self._owns_client = client is None - self._sessions: dict[str, CopilotSession] = {} # Parse options opts: dict[str, Any] = dict(default_options) if default_options else {} @@ -246,20 +246,10 @@ async def start(self) -> None: async def stop(self) -> None: """Stop the Copilot client and clean up resources. - This method destroys all active sessions created by this agent and stops - the Copilot client. It is called automatically when using the agent as - an async context manager. - - Note: - Only sessions created by this agent instance (stored in self._sessions) - are destroyed. Sessions created elsewhere are not affected. + Stops the Copilot client if owned by this agent. The client handles + session cleanup internally. Called automatically when using the agent + as an async context manager. """ - for session in self._sessions.values(): - with contextlib.suppress(Exception): - await session.destroy() - - self._sessions.clear() - if self._client and self._owns_client: with contextlib.suppress(Exception): await self._client.stop() @@ -476,8 +466,20 @@ async def _get_or_create_session( if not self._client: raise ServiceException("GitHub Copilot client not initialized. Call start() first.") - if thread.service_thread_id and thread.service_thread_id in self._sessions: - return self._sessions[thread.service_thread_id] + try: + if thread.service_thread_id: + return await self._resume_session(thread.service_thread_id, streaming) + + session = await self._create_session(streaming) + thread.service_thread_id = session.session_id + return session + except Exception as ex: + raise ServiceException(f"Failed to create GitHub Copilot session: {ex}") from ex + + async def _create_session(self, streaming: bool) -> CopilotSession: + """Create a new Copilot session.""" + if not self._client: + raise ServiceException("GitHub Copilot client not initialized. Call start() first.") config: SessionConfig = {"streaming": streaming} @@ -493,10 +495,19 @@ async def _get_or_create_session( if self._permission_handler: config["on_permission_request"] = self._permission_handler - try: - session = await self._client.create_session(config) - thread.service_thread_id = session.session_id - self._sessions[session.session_id] = session - return session - except Exception as ex: - raise ServiceException(f"Failed to create GitHub Copilot session: {ex}") from ex + return await self._client.create_session(config) + + async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession: + """Resume an existing Copilot session by ID.""" + if not self._client: + raise ServiceException("GitHub Copilot client not initialized. Call start() first.") + + config: ResumeSessionConfig = {"streaming": streaming} + + if self._tools: + config["tools"] = self._prepare_tools(self._tools) + + if self._permission_handler: + config["on_permission_request"] = self._permission_handler + + return await self._client.resume_session(session_id, config) 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 daef7364a1..834e56bb6c 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import unittest.mock from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -54,6 +55,7 @@ def mock_client(mock_session: MagicMock) -> MagicMock: client.start = AsyncMock() client.stop = AsyncMock(return_value=[]) client.create_session = AsyncMock(return_value=mock_session) + client.resume_session = AsyncMock(return_value=mock_session) return client @@ -168,16 +170,12 @@ async def test_start_idempotent(self, mock_client: MagicMock) -> None: mock_client.start.assert_called_once() async def test_stop_cleans_up(self, mock_client: MagicMock, mock_session: MagicMock) -> None: - """Test that stop cleans up sessions and client.""" + """Test that stop resets started state.""" agent = GithubCopilotAgent(client=mock_client) await agent.start() - mock_client.create_session.return_value = mock_session - await agent._get_or_create_session(AgentThread()) # type: ignore - await agent.stop() - mock_session.destroy.assert_called_once() assert agent._started is False # type: ignore async def test_context_manager(self, mock_client: MagicMock) -> None: @@ -417,13 +415,13 @@ def mock_on(handler: Any) -> Any: class TestGithubCopilotAgentSessionManagement: """Test cases for session management.""" - async def test_session_reuse( + async def test_session_resumed_for_same_thread( self, mock_client: MagicMock, mock_session: MagicMock, assistant_message_event: SessionEvent, ) -> None: - """Test that sessions are reused for the same thread.""" + """Test that subsequent calls on the same thread resume the session.""" mock_session.send_and_wait.return_value = assistant_message_event agent = GithubCopilotAgent(client=mock_client) @@ -433,6 +431,7 @@ async def test_session_reuse( await agent.run("World", thread=thread) mock_client.create_session.assert_called_once() + mock_client.resume_session.assert_called_once_with(mock_session.session_id, unittest.mock.ANY) async def test_session_config_includes_model( self, @@ -485,6 +484,58 @@ async def test_session_config_includes_streaming_flag( config = call_args[0][0] assert config["streaming"] is True + async def test_resume_session_with_existing_service_thread_id( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session is resumed when thread has a service_thread_id.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + thread = AgentThread() + thread.service_thread_id = "existing-session-id" + + await agent._get_or_create_session(thread) # type: ignore + + mock_client.create_session.assert_not_called() + mock_client.resume_session.assert_called_once() + call_args = mock_client.resume_session.call_args + assert call_args[0][0] == "existing-session-id" + + async def test_resume_session_includes_tools_and_permissions( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that resumed session config includes tools and permission handler.""" + from copilot.types import PermissionRequest, PermissionRequestResult + + def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + return PermissionRequestResult(kind="approved") + + def my_tool(arg: str) -> str: + """A test tool.""" + return arg + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + client=mock_client, + tools=[my_tool], + default_options={"on_permission_request": my_handler}, + ) + await agent.start() + + thread = AgentThread() + thread.service_thread_id = "existing-session-id" + + await agent._get_or_create_session(thread) # type: ignore + + mock_client.resume_session.assert_called_once() + call_args = mock_client.resume_session.call_args + config = call_args[0][1] + assert "tools" in config + assert "on_permission_request" in config + class TestGithubCopilotAgentToolConversion: """Test cases for tool conversion.""" diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/getting_started/agents/github_copilot/README.md index 5b8eadefa2..515e8a799c 100644 --- a/python/samples/getting_started/agents/github_copilot/README.md +++ b/python/samples/getting_started/agents/github_copilot/README.md @@ -2,14 +2,7 @@ This directory contains examples demonstrating how to use the `GithubCopilotAgent` from the Microsoft Agent Framework. -## Examples - -| File | Description | -|------|-------------| -| [`github_copilot_basic.py`](github_copilot_basic.py) | The simplest way to create an agent using `GithubCopilotAgent`. Demonstrates both streaming and non-streaming responses with function tools. | -| [`github_copilot_with_shell.py`](github_copilot_with_shell.py) | Shows how to enable shell command execution permissions. Demonstrates running system commands like listing files and getting system information. | -| [`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_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. | +> **Security Note**: These examples demonstrate various permission types (shell, read, write, url). Only enable permissions that are necessary for your use case. Each permission grants the agent additional capabilities that could affect your system. ## Prerequisites @@ -31,82 +24,13 @@ The following environment variables can be configured: | `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` | | `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` | -## Permission Kinds - -The following permission kinds can be approved via `on_permission_request`: - -| Permission | Description | -|------------|-------------| -| `shell` | Execute shell commands on the system | -| `read` | Read files from the filesystem | -| `write` | Write or create files on the filesystem | -| `mcp` | Use MCP (Model Context Protocol) servers | -| `url` | Fetch content from URLs | - -**Security Note**: Only enable permissions that are necessary for your use case. Each permission grants the agent additional capabilities that could affect your system. - -## Usage Patterns - -### Basic Usage (No Permissions) - -```python -from agent_framework.github import GithubCopilotAgent - -async with GithubCopilotAgent() as agent: - response = await agent.run("Hello!") -``` - -### With Custom Tools - -```python -from typing import Annotated - -from agent_framework.github import GithubCopilotAgent -from pydantic import Field - -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny." - -async with GithubCopilotAgent(tools=[get_weather]) as agent: - response = await agent.run("What's the weather in Seattle?") -``` - -### With Permissions - -Implement a permission handler to approve specific actions: - -```python -from agent_framework.github import GithubCopilotAgent -from copilot.types import PermissionRequest, PermissionRequestResult - -def my_permission_handler( - request: PermissionRequest, - context: dict[str, str], -) -> PermissionRequestResult: - kind = request.get("kind") - print(f"Permission requested: {kind}") - - # Implement your approval logic here - if kind == "shell": - return PermissionRequestResult(kind="approved") - return PermissionRequestResult(kind="denied-interactively-by-user") - -async with GithubCopilotAgent( - default_options={"on_permission_request": my_permission_handler} -) as agent: - response = await agent.run("List Python files") -``` - -## Running the Examples - -Each example can be run independently: +## Examples -```bash -python github_copilot_basic.py -python github_copilot_with_shell.py -python github_copilot_with_file_operations.py -python github_copilot_with_multiple_permissions.py -``` +| File | Description | +|------|-------------| +| [`github_copilot_basic.py`](github_copilot_basic.py) | The simplest way to create an agent using `GithubCopilotAgent`. Demonstrates both streaming and non-streaming responses with function tools. | +| [`github_copilot_with_session.py`](github_copilot_with_session.py) | Shows session management with automatic creation, persistence via thread objects, and resuming sessions by ID. | +| [`github_copilot_with_shell.py`](github_copilot_with_shell.py) | Shows how to enable shell command execution permissions. Demonstrates running system commands like listing files and getting system information. | +| [`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_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. | diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py new file mode 100644 index 0000000000..2d5f027874 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +GitHub Copilot Agent with Session Management + +This sample demonstrates session management with GithubCopilotAgent, showing +persistent conversation capabilities. Sessions are automatically persisted +server-side by the Copilot CLI. +""" + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions +from pydantic import Field + + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Each run() without thread creates a new session.""" + print("=== Automatic Session Creation Example ===") + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful weather agent."}, + tools=[get_weather], + ) + + async with agent: + # First query - creates a new session + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1}") + + # Second query - without thread, creates another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence() -> None: + """Reuse session via thread object for multi-turn conversations.""" + print("=== Session Persistence Example ===") + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful weather agent."}, + tools=[get_weather], + ) + + async with agent: + # Create a thread to maintain conversation context + thread = agent.get_new_thread() + + # First query + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, thread=thread) + print(f"Agent: {result1}") + + # Second query - using same thread maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, thread=thread) + print(f"Agent: {result2}") + + # Third query - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, thread=thread) + print(f"Agent: {result3}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """Resume session in new agent instance using service_thread_id.""" + print("=== Existing Session ID Example ===") + + existing_session_id = None + + # First agent instance - start a conversation + agent1: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful weather agent."}, + tools=[get_weather], + ) + + async with agent1: + thread = agent1.get_new_thread() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent1.run(query1, thread=thread) + print(f"Agent: {result1}") + + # Capture the session ID for later use + existing_session_id = thread.service_thread_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + # Second agent instance - resume the conversation + agent2: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={"instructions": "You are a helpful weather agent."}, + tools=[get_weather], + ) + + async with agent2: + # Create thread with existing session ID + thread = agent2.get_new_thread(service_thread_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent2.run(query2, thread=thread) + print(f"Agent: {result2}") + print("Note: The agent continues the conversation using the session ID.\n") + + +async def main() -> None: + print("=== GitHub Copilot Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_url.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_url.py new file mode 100644 index 0000000000..ede5d3d986 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_url.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +GitHub Copilot Agent with URL Fetching + +This sample demonstrates how to enable URL fetching with GithubCopilotAgent. +By providing a permission handler that approves "url" requests, the agent can +fetch and process content from web URLs. + +SECURITY NOTE: Only enable URL permissions when you trust the agent's actions. +URL fetching allows the agent to access any URL accessible from your network. +""" + +import asyncio + +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions +from copilot.types import PermissionRequest, PermissionRequestResult + + +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + kind = request.get("kind", "unknown") + print(f"\n[Permission Request: {kind}]") + + if "url" in request: + print(f" URL: {request.get('url')}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + +async def main() -> None: + print("=== GitHub Copilot Agent with URL Fetching ===\n") + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={ + "instructions": "You are a helpful assistant that can fetch and summarize web content.", + "on_permission_request": prompt_permission, + }, + ) + + async with agent: + query = "Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) From d8b26e1b6e9c2753097f1803fa15b2fedc21d502 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:59:43 -0800 Subject: [PATCH 14/14] Added example with MCP --- .../agent_framework_github_copilot/_agent.py | 15 +++ .../tests/test_github_copilot_agent.py | 91 +++++++++++++++++++ .../agents/github_copilot/README.md | 1 + .../github_copilot/github_copilot_with_mcp.py | 75 +++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 python/samples/getting_started/agents/github_copilot/github_copilot_with_mcp.py 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 4571dea382..d38e50fe6b 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -26,6 +26,7 @@ from copilot.generated.session_events import SessionEvent, SessionEventType from copilot.types import ( CopilotClientOptions, + MCPServerConfig, PermissionRequest, PermissionRequestResult, ResumeSessionConfig, @@ -79,6 +80,12 @@ class GithubCopilotOptions(TypedDict, total=False): If not provided, all permission requests will be denied by default. """ + mcp_servers: dict[str, MCPServerConfig] + """MCP (Model Context Protocol) server configurations. + A dictionary mapping server names to their configurations. + Supports both local (stdio) and remote (HTTP/SSE) servers. + """ + TOptions = TypeVar( "TOptions", @@ -187,6 +194,7 @@ def __init__( timeout = opts.pop("timeout", None) log_level = opts.pop("log_level", None) on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None) + mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None) try: self._settings = GithubCopilotSettings( @@ -203,6 +211,7 @@ def __init__( self._instructions = instructions self._tools = normalize_tools(tools) self._permission_handler = on_permission_request + self._mcp_servers = mcp_servers self._default_options = opts self._started = False @@ -495,6 +504,9 @@ async def _create_session(self, streaming: bool) -> CopilotSession: if self._permission_handler: config["on_permission_request"] = self._permission_handler + if self._mcp_servers: + config["mcp_servers"] = self._mcp_servers + return await self._client.create_session(config) async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession: @@ -510,4 +522,7 @@ async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSess if self._permission_handler: config["on_permission_request"] = self._permission_handler + if self._mcp_servers: + config["mcp_servers"] = self._mcp_servers + return await self._client.resume_session(session_id, config) 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 834e56bb6c..22df440482 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -537,6 +537,97 @@ def my_tool(arg: str) -> str: assert "on_permission_request" in config +class TestGithubCopilotAgentMCPServers: + """Test cases for MCP server configuration.""" + + async def test_mcp_servers_passed_to_create_session( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that mcp_servers are passed through to create_session config.""" + from copilot.types import MCPServerConfig + + mcp_servers: dict[str, MCPServerConfig] = { + "filesystem": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "tools": ["*"], + }, + "remote": { + "type": "http", + "url": "https://example.com/mcp", + "tools": ["*"], + }, + } + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + client=mock_client, + default_options={"mcp_servers": mcp_servers}, + ) + await agent.start() + + await agent._get_or_create_session(AgentThread()) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert "mcp_servers" in config + assert "filesystem" in config["mcp_servers"] + assert "remote" in config["mcp_servers"] + assert config["mcp_servers"]["filesystem"]["command"] == "npx" + assert config["mcp_servers"]["remote"]["url"] == "https://example.com/mcp" + + async def test_mcp_servers_passed_to_resume_session( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that mcp_servers are passed through to resume_session config.""" + from copilot.types import MCPServerConfig + + mcp_servers: dict[str, MCPServerConfig] = { + "test-server": { + "type": "stdio", + "command": "echo", + "args": ["hello"], + "tools": ["*"], + }, + } + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + client=mock_client, + default_options={"mcp_servers": mcp_servers}, + ) + await agent.start() + + thread = AgentThread() + thread.service_thread_id = "existing-session-id" + + await agent._get_or_create_session(thread) # type: ignore + + mock_client.resume_session.assert_called_once() + call_args = mock_client.resume_session.call_args + config = call_args[0][1] + assert "mcp_servers" in config + assert "test-server" in config["mcp_servers"] + + async def test_session_config_excludes_mcp_servers_when_not_set( + self, + mock_client: MagicMock, + mock_session: MagicMock, + ) -> None: + """Test that session config does not include mcp_servers when not set.""" + agent = GithubCopilotAgent(client=mock_client) + await agent.start() + + await agent._get_or_create_session(AgentThread()) # type: ignore + + call_args = mock_client.create_session.call_args + config = call_args[0][0] + assert "mcp_servers" not in config + + class TestGithubCopilotAgentToolConversion: """Test cases for tool conversion.""" diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/getting_started/agents/github_copilot/README.md index 515e8a799c..a9a0cb6916 100644 --- a/python/samples/getting_started/agents/github_copilot/README.md +++ b/python/samples/getting_started/agents/github_copilot/README.md @@ -33,4 +33,5 @@ The following environment variables can be configured: | [`github_copilot_with_shell.py`](github_copilot_with_shell.py) | Shows how to enable shell command execution permissions. Demonstrates running system commands like listing files and getting system information. | | [`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_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. | diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_mcp.py b/python/samples/getting_started/agents/github_copilot/github_copilot_with_mcp.py new file mode 100644 index 0000000000..2fc5e61b29 --- /dev/null +++ b/python/samples/getting_started/agents/github_copilot/github_copilot_with_mcp.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +GitHub Copilot Agent with MCP Servers + +This sample demonstrates how to configure MCP (Model Context Protocol) servers +with GithubCopilotAgent. It shows both local (stdio) and remote (HTTP) server +configurations, giving the agent access to external tools and data sources. + +SECURITY NOTE: MCP servers can expose powerful capabilities. Only configure +servers you trust. The permission handler below prompts the user for approval +of MCP-related actions. +""" + +import asyncio + +from agent_framework.github import GithubCopilotAgent, GithubCopilotOptions +from copilot.types import MCPServerConfig, PermissionRequest, PermissionRequestResult + + +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + kind = request.get("kind", "unknown") + print(f"\n[Permission Request: {kind}]") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + +async def main() -> None: + print("=== GitHub Copilot Agent with MCP Servers ===\n") + + # Configure both local and remote MCP servers + mcp_servers: dict[str, MCPServerConfig] = { + # Local stdio server: provides filesystem access tools + "filesystem": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "tools": ["*"], + }, + # Remote HTTP server: Microsoft Learn documentation + "microsoft-learn": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp", + "tools": ["*"], + }, + } + + agent: GithubCopilotAgent[GithubCopilotOptions] = GithubCopilotAgent( + default_options={ + "instructions": "You are a helpful assistant with access to the local filesystem and Microsoft Learn.", + "on_permission_request": prompt_permission, + "mcp_servers": mcp_servers, + }, + ) + + async with agent: + # Query that exercises the local filesystem MCP server + query1 = "List the files in the current directory" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1}\n") + + # Query that exercises the remote Microsoft Learn MCP server + query2 = "Search Microsoft Learn for 'Azure Functions Python' and summarize the top result" + print(f"User: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2}\n") + + +if __name__ == "__main__": + asyncio.run(main())