diff --git a/python/packages/core/agent_framework/github/__init__.py b/python/packages/core/agent_framework/github/__init__.py index 831a838c0d..0ff654fa17 100644 --- a/python/packages/core/agent_framework/github/__init__.py +++ b/python/packages/core/agent_framework/github/__init__.py @@ -9,6 +9,7 @@ - GitHubCopilotAgent - GitHubCopilotOptions - GitHubCopilotSettings +- RawGitHubCopilotAgent """ import importlib @@ -18,6 +19,7 @@ "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"), + "RawGitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"), } diff --git a/python/packages/core/agent_framework/github/__init__.pyi b/python/packages/core/agent_framework/github/__init__.pyi index 567ab9490d..f7b68966cf 100644 --- a/python/packages/core/agent_framework/github/__init__.pyi +++ b/python/packages/core/agent_framework/github/__init__.pyi @@ -4,10 +4,12 @@ from agent_framework_github_copilot import ( GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, + RawGitHubCopilotAgent, ) __all__ = [ "GitHubCopilotAgent", "GitHubCopilotOptions", "GitHubCopilotSettings", + "RawGitHubCopilotAgent", ] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py index 432427fd9d..56b46f8dee 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings +from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, RawGitHubCopilotAgent try: __version__ = importlib.metadata.version(__name__) @@ -13,5 +13,6 @@ "GitHubCopilotAgent", "GitHubCopilotOptions", "GitHubCopilotSettings", + "RawGitHubCopilotAgent", "__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 index 4599cd3526..a9e26fc7cc 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -7,7 +7,7 @@ import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload +from typing import Any, ClassVar, Generic, Literal, TypedDict, overload from agent_framework import ( AgentMiddlewareTypes, @@ -27,22 +27,14 @@ from agent_framework._tools import FunctionTool, ToolTypes from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import AgentException +from agent_framework.observability import AgentTelemetryLayer try: - from copilot import CopilotClient, CopilotSession + from copilot import CopilotClient, CopilotSession, SubprocessConfig from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType - from copilot.types import ( - CopilotClientOptions, - MCPServerConfig, - MessageOptions, - PermissionRequestResult, - ResumeSessionConfig, - SessionConfig, - SystemMessageConfig, - ToolInvocation, - ToolResult, - ) - from copilot.types import Tool as CopilotTool + from copilot.session import MCPServerConfig, PermissionRequestResult, SystemMessageConfig + from copilot.tools import Tool as CopilotTool + from copilot.tools import ToolInvocation, ToolResult except ImportError as _copilot_import_error: raise ImportError( "GitHubCopilotAgent requires the 'github-copilot-sdk' package, which is only available on Python 3.11+. " @@ -64,6 +56,14 @@ logger = logging.getLogger("agent_framework.github_copilot") +def _deny_all_permissions( + _request: PermissionRequest, + _invocation: dict[str, str], +) -> PermissionRequestResult: + """Default permission handler that denies all requests.""" + return PermissionRequestResult() + + class GitHubCopilotSettings(TypedDict, total=False): """GitHub Copilot model settings. @@ -130,8 +130,11 @@ class GitHubCopilotOptions(TypedDict, total=False): ) -class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): - """A GitHub Copilot Agent. +class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): + """A GitHub Copilot Agent without telemetry layers. + + This is the core GitHub Copilot agent implementation without OpenTelemetry instrumentation. + For most use cases, prefer :class:`GitHubCopilotAgent` which includes telemetry support. This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities within the Agent Framework. It supports both streaming and non-streaming responses, @@ -144,7 +147,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - async with GitHubCopilotAgent() as agent: + async with RawGitHubCopilotAgent() as agent: response = await agent.run("Hello, world!") print(response) @@ -152,22 +155,11 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions + from agent_framework_github_copilot import RawGitHubCopilotAgent, GitHubCopilotOptions - agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent( 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" @@ -195,9 +187,9 @@ def __init__( Keyword Args: client: Optional pre-configured CopilotClient instance. If not provided, a new client will be created using the other parameters. - id: ID of the GitHubCopilotAgent. - name: Name of the GitHubCopilotAgent. - description: Description of the GitHubCopilotAgent. + id: ID of the RawGitHubCopilotAgent. + name: Name of the RawGitHubCopilotAgent. + description: Description of the RawGitHubCopilotAgent. context_providers: Context Providers, to be used by the agent. middleware: Agent middleware used by the agent. tools: Tools to use for the agent. Can be functions @@ -251,7 +243,7 @@ def __init__( self._default_options = opts self._started = False - async def __aenter__(self) -> GitHubCopilotAgent[OptionsT]: + async def __aenter__(self) -> RawGitHubCopilotAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -274,16 +266,13 @@ async def start(self) -> None: return if self._client is None: - client_options: CopilotClientOptions = {} - cli_path = self._settings.get("cli_path") - if cli_path: - client_options["cli_path"] = cli_path + cli_path = self._settings.get("cli_path") or None + log_level = self._settings.get("log_level") or None - log_level = self._settings.get("log_level") + subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path} if log_level: - client_options["log_level"] = log_level # type: ignore[typeddict-item] - - self._client = CopilotClient(client_options if client_options else None) + subprocess_kwargs["log_level"] = log_level + self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs)) try: await self._client.start() @@ -304,6 +293,20 @@ async def stop(self) -> None: self._started = False + @property + def default_options(self) -> dict[str, Any]: + """Expose default options including model from settings. + + Returns a merged dict of ``_default_options`` with the resolved ``model`` + from settings injected under the ``model`` key. This is read by + :class:`AgentTelemetryLayer` to include the model name in span attributes. + """ + opts = dict(self._default_options) + model = self._settings.get("model") + if model: + opts["model"] = model + return opts + @overload def run( self, @@ -312,6 +315,7 @@ def run( stream: Literal[False] = False, session: AgentSession | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload @@ -322,6 +326,7 @@ def run( stream: Literal[True], session: AgentSession | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( @@ -331,6 +336,7 @@ def run( stream: bool = False, session: AgentSession | None = None, options: OptionsT | None = None, + **kwargs: Any, # type: ignore[override] ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -345,6 +351,8 @@ def run( stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). options: Runtime options (model, timeout, etc.). + kwargs: Additional keyword arguments for compatibility with the shared agent + interface (e.g. compaction_strategy, tokenizer). Not used by this agent. Returns: When stream=False: An Awaitable[AgentResponse]. @@ -407,10 +415,9 @@ async def _run_impl( prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt - message_options = cast(MessageOptions, {"prompt": prompt}) try: - response_event = await copilot_session.send_and_wait(message_options, timeout=timeout) + response_event = await copilot_session.send_and_wait(prompt, timeout=timeout) except Exception as ex: raise AgentException(f"GitHub Copilot request failed: {ex}") from ex @@ -489,7 +496,6 @@ async def _stream_updates( prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt - message_options = cast(MessageOptions, {"prompt": prompt}) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() @@ -550,7 +556,7 @@ def event_handler(event: SessionEvent) -> None: unsubscribe = copilot_session.on(event_handler) try: - await copilot_session.send(message_options) + await copilot_session.send(prompt) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -730,43 +736,125 @@ async def _create_session( raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") opts = runtime_options or {} - config: SessionConfig = {"streaming": streaming} + model = opts.get("model") or self._settings.get("model") or None + system_message = opts.get("system_message") or self._default_options.get("system_message") or None + permission_handler: PermissionHandlerType = ( + opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions + ) + mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None + tools = self._prepare_tools(self._tools) if self._tools else None + + return await self._client.create_session( + on_permission_request=permission_handler, + streaming=streaming, + model=model or None, + system_message=system_message or None, + tools=tools or None, + mcp_servers=mcp_servers or None, + ) - model = opts.get("model") or self._settings.get("model") - if model: - config["model"] = model # type: ignore[typeddict-item] + async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession: + """Resume an existing Copilot session by ID.""" + if not self._client: + raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") - system_message = opts.get("system_message") or self._default_options.get("system_message") - if system_message: - config["system_message"] = system_message + permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions + tools = self._prepare_tools(self._tools) if self._tools else None - if self._tools: - config["tools"] = self._prepare_tools(self._tools) + return await self._client.resume_session( + session_id, + on_permission_request=permission_handler, + streaming=streaming, + tools=tools or None, + mcp_servers=self._mcp_servers or None, + ) - permission_handler = opts.get("on_permission_request") or self._permission_handler - if permission_handler: - config["on_permission_request"] = permission_handler - mcp_servers = opts.get("mcp_servers") or self._mcp_servers - if mcp_servers: - config["mcp_servers"] = mcp_servers +class GitHubCopilotAgent(AgentTelemetryLayer, RawGitHubCopilotAgent[OptionsT], Generic[OptionsT]): + """A GitHub Copilot Agent with OpenTelemetry instrumentation. - return await self._client.create_session(config) + This is the recommended agent class for most use cases. It includes + OpenTelemetry-based telemetry for observability. For a minimal + implementation without telemetry, use :class:`RawGitHubCopilotAgent`. - async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession: - """Resume an existing Copilot session by ID.""" - if not self._client: - raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") + 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. - config: ResumeSessionConfig = {"streaming": streaming} + The agent can be used as an async context manager to ensure proper cleanup: - if self._tools: - config["tools"] = self._prepare_tools(self._tools) + Examples: + Basic usage: + + .. code-block:: python + + async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + print(response) - if self._permission_handler: - config["on_permission_request"] = self._permission_handler + With explicitly typed options: - if self._mcp_servers: - config["mcp_servers"] = self._mcp_servers + .. code-block:: python - return await self._client.resume_session(session_id, config) + from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions + + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "claude-sonnet-4", "timeout": 120} + ) + + With observability: + + .. code-block:: python + + from agent_framework.observability import configure_otel_providers + + configure_otel_providers() + async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + """ + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + options: OptionsT | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse]: ... + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + options: OptionsT | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... + + def run( # pyright: ignore[reportIncompatibleMethodOverride] # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + options: OptionsT | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + """Run the GitHub Copilot agent with telemetry enabled.""" + from typing import cast + + super_run = cast( + "Callable[..., Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]]", + super().run, + ) + return super_run( + messages=messages, + stream=stream, + session=session, + options=options, + **kwargs, + ) diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index aecdaa02f9..2a71d577af 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0,<2", - "github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'", + "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] [tool.uv] @@ -102,3 +102,4 @@ interpreter = "posix" [build-system] requires = ["flit-core >= 3.11,<4.0"] build-backend = "flit_core.buildapi" + 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 e91a725765..17e9432e40 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -23,7 +23,7 @@ ) from agent_framework.exceptions import AgentException from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType -from copilot.types import ToolInvocation, ToolResult +from copilot.tools import ToolInvocation, ToolResult from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions @@ -268,8 +268,8 @@ async def test_start_creates_client_with_options(self) -> None: await agent.start() call_args = MockClient.call_args[0][0] - assert call_args["cli_path"] == "/custom/path" - assert call_args["log_level"] == "debug" + assert call_args.cli_path == "/custom/path" + assert call_args.log_level == "debug" class TestGitHubCopilotAgentRun: @@ -855,7 +855,13 @@ async def test_session_resumed_for_same_session( await agent.run("World", session=session) mock_client.create_session.assert_called_once() - mock_client.resume_session.assert_called_once_with(mock_session.session_id, unittest.mock.ANY) + mock_client.resume_session.assert_called_once_with( + mock_session.session_id, + on_permission_request=unittest.mock.ANY, + streaming=unittest.mock.ANY, + tools=unittest.mock.ANY, + mcp_servers=unittest.mock.ANY, + ) async def test_session_config_includes_model( self, @@ -871,7 +877,7 @@ async def test_session_config_includes_model( await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["model"] == "claude-sonnet-4" async def test_session_config_includes_instructions( @@ -889,7 +895,7 @@ async def test_session_config_includes_instructions( await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["system_message"]["mode"] == "append" assert config["system_message"]["content"] == "You are a helpful assistant." @@ -914,7 +920,7 @@ async def test_runtime_options_take_precedence_over_default( ) call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["system_message"]["mode"] == "replace" assert config["system_message"]["content"] == "Runtime instructions" @@ -930,7 +936,7 @@ async def test_session_config_includes_streaming_flag( await agent._get_or_create_session(AgentSession(), streaming=True) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["streaming"] is True async def test_resume_session_with_existing_service_session_id( @@ -958,7 +964,8 @@ async def test_resume_session_includes_tools_and_permissions( mock_session: MagicMock, ) -> None: """Test that resumed session config includes tools and permission handler.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: return PermissionRequestResult(kind="approved") @@ -981,7 +988,7 @@ def my_tool(arg: str) -> str: mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args - config = call_args[0][1] + config = call_args.kwargs assert "tools" in config assert "on_permission_request" in config @@ -995,7 +1002,7 @@ async def test_mcp_servers_passed_to_create_session( mock_session: MagicMock, ) -> None: """Test that mcp_servers are passed through to create_session config.""" - from copilot.types import MCPServerConfig + from copilot.session import MCPServerConfig mcp_servers: dict[str, MCPServerConfig] = { "filesystem": { @@ -1020,7 +1027,7 @@ async def test_mcp_servers_passed_to_create_session( await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "mcp_servers" in config assert "filesystem" in config["mcp_servers"] assert "remote" in config["mcp_servers"] @@ -1033,7 +1040,7 @@ async def test_mcp_servers_passed_to_resume_session( mock_session: MagicMock, ) -> None: """Test that mcp_servers are passed through to resume_session config.""" - from copilot.types import MCPServerConfig + from copilot.session import MCPServerConfig mcp_servers: dict[str, MCPServerConfig] = { "test-server": { @@ -1057,7 +1064,7 @@ async def test_mcp_servers_passed_to_resume_session( mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args - config = call_args[0][1] + config = call_args.kwargs assert "mcp_servers" in config assert "test-server" in config["mcp_servers"] @@ -1073,8 +1080,8 @@ async def test_session_config_excludes_mcp_servers_when_not_set( await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "mcp_servers" not in config + config = call_args.kwargs + assert config["mcp_servers"] is None class TestGitHubCopilotAgentToolConversion: @@ -1097,7 +1104,7 @@ def my_tool(arg: str) -> str: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "tools" in config assert len(config["tools"]) == 1 assert config["tools"][0].name == "my_tool" @@ -1120,7 +1127,7 @@ def my_tool(arg: str) -> str: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"})) @@ -1146,7 +1153,7 @@ def failing_tool(arg: str) -> str: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"})) @@ -1173,7 +1180,7 @@ def my_tool(arg: str) -> str: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] with pytest.raises((TypeError, AttributeError)): @@ -1196,7 +1203,7 @@ def no_args_tool() -> str: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={})) @@ -1210,7 +1217,7 @@ def test_copilot_tool_passthrough( mock_client: MagicMock, ) -> None: """Test that CopilotTool instances are passed through as-is.""" - from copilot.types import Tool as CopilotTool + from copilot.tools import Tool as CopilotTool async def tool_handler(invocation: Any) -> Any: return {"text_result_for_llm": "result", "result_type": "success"} @@ -1234,7 +1241,7 @@ def test_mixed_tools_conversion( ) -> None: """Test that mixed tool types are handled correctly.""" from agent_framework import tool - from copilot.types import Tool as CopilotTool + from copilot.tools import Tool as CopilotTool @tool(approval_mode="never_require") def my_function(arg: str) -> str: @@ -1317,10 +1324,11 @@ def test_no_permission_handler_when_not_provided(self) -> 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 + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: - if request.get("kind") == "shell": + if request.kind == "shell": return PermissionRequestResult(kind="approved") return PermissionRequestResult(kind="denied-interactively-by-user") @@ -1335,10 +1343,11 @@ async def test_session_config_includes_permission_handler( mock_session: MagicMock, ) -> None: """Test that session config includes permission handler when provided.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: - if request.get("kind") in ("shell", "read"): + if request.kind in ("shell", "read"): return PermissionRequestResult(kind="approved") return PermissionRequestResult(kind="denied-interactively-by-user") @@ -1351,24 +1360,29 @@ def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> P await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs 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( + async def test_session_config_uses_deny_all_when_no_permission_handler_set( self, mock_client: MagicMock, mock_session: MagicMock, ) -> None: - """Test that session config does not include permission handler when not set.""" + """Test that session config uses deny-all handler when no permission handler is set. + + In SDK 0.2.x, on_permission_request is required by create_session, so the agent + always falls back to _deny_all_permissions when no handler is provided. + """ agent = GitHubCopilotAgent(client=mock_client) await agent.start() await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "on_permission_request" not in config + config = call_args.kwargs + assert "on_permission_request" in config + assert config["on_permission_request"] is not None class SpyContextProvider(ContextProvider): @@ -1454,7 +1468,7 @@ async def test_provider_instructions_included_in_prompt( session = agent.create_session() await agent.run("Hello", session=session) - sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + sent_prompt = mock_session.send_and_wait.call_args[0][0] assert "Injected by spy provider" in sent_prompt async def test_after_run_receives_response( @@ -1547,7 +1561,7 @@ def mock_on(handler: Any) -> Any: async for _ in agent.run("Hello", stream=True, session=session): pass - sent_prompt = mock_session.send.call_args[0][0]["prompt"] + sent_prompt = mock_session.send.call_args[0][0] assert "Injected by spy provider" in sent_prompt async def test_context_preserved_across_runs( @@ -1608,7 +1622,7 @@ async def after_run( session = agent.create_session() await agent.run("Hello", session=session) - sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + sent_prompt = mock_session.send_and_wait.call_args[0][0] assert "History message" in sent_prompt assert "Hello" in sent_prompt @@ -1659,7 +1673,7 @@ async def after_run( async for _ in agent.run("Hello", stream=True, session=session): pass - sent_prompt = mock_session.send.call_args[0][0]["prompt"] + sent_prompt = mock_session.send.call_args[0][0] assert "History message" in sent_prompt assert "Hello" in sent_prompt diff --git a/python/samples/02-agents/providers/github_copilot/README.md b/python/samples/02-agents/providers/github_copilot/README.md index 572ec9c444..a9403ae9b9 100644 --- a/python/samples/02-agents/providers/github_copilot/README.md +++ b/python/samples/02-agents/providers/github_copilot/README.md @@ -24,12 +24,23 @@ The following environment variables can be configured: | `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` | | `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` | +### OpenTelemetry Environment Variables + +When using [`github_copilot_with_observability.py`](github_copilot_with_observability.py), the following OTel variables can be configured: + +| Variable | Description | Default | +|----------|-------------|---------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (e.g., `http://localhost:4317`) | Console only | +| `OTEL_SERVICE_NAME` | Service name shown in traces | `agent_framework` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | Protocol: `grpc` or `http/protobuf` | `grpc` | + ## 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_session.py`](github_copilot_with_session.py) | Shows session management with automatic creation, persistence via session objects, and resuming sessions by ID. | +| [`github_copilot_with_observability.py`](github_copilot_with_observability.py) | Shows how to enable OpenTelemetry tracing with `configure_otel_providers()`. Traces agent runs with spans and metrics sent to a configured OTLP backend or console. | | [`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. | diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index af59ae9b3b..93e37d89bb 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -20,7 +20,7 @@ from agent_framework import tool from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from dotenv import load_dotenv from pydantic import Field diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py index 145702591f..1a82d9867d 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py @@ -16,7 +16,7 @@ from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py index 081dd1b21f..71bd67efb4 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py @@ -16,7 +16,7 @@ from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import MCPServerConfig, PermissionRequestResult +from copilot.session import MCPServerConfig, PermissionRequestResult from dotenv import load_dotenv # Load environment variables from .env file diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py index 5ccc8e51f7..916061f939 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py @@ -22,7 +22,7 @@ from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_observability.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_observability.py new file mode 100644 index 0000000000..de0d5956e2 --- /dev/null +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_observability.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +GitHub Copilot Agent with OpenTelemetry Observability + +This sample demonstrates how to enable OpenTelemetry tracing for GitHubCopilotAgent. +Traces are exported via OTLP (configure via environment variables) and/or to the +console for local development. + +Environment variables (OTel): +- OTEL_EXPORTER_OTLP_ENDPOINT - OTLP endpoint (e.g., "http://localhost:4317") +- OTEL_SERVICE_NAME - Service name shown in traces +- OTEL_EXPORTER_OTLP_PROTOCOL - "grpc" or "http/protobuf" + +Environment variables (agent): +- 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 import tool +from agent_framework.github import GitHubCopilotAgent +from agent_framework.observability import configure_otel_providers +from copilot.generated.session_events import PermissionRequest +from copilot.session import PermissionRequestResult +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + + +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +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_with_telemetry() -> None: + """Non-streaming example with OTel tracing.""" + print("=== Non-streaming with Telemetry ===") + + agent = GitHubCopilotAgent( + instructions="You are a helpful weather agent.", + tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, + ) + + async with agent: + query = "What's the weather like in Seattle and Tokyo?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + +async def streaming_with_telemetry() -> None: + """Streaming example with OTel tracing.""" + print("=== Streaming with Telemetry ===") + + agent = GitHubCopilotAgent( + instructions="You are a helpful weather agent.", + tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, + ) + + async with agent: + query = "What's the weather like in Paris?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + # Configure OTel providers before creating any agents. + # - enable_console_exporters=True writes spans to stdout for local development. + # - Set OTEL_EXPORTER_OTLP_ENDPOINT to send traces to a collector instead. + configure_otel_providers(enable_console_exporters=True) + + print("=== GitHub Copilot Agent with OpenTelemetry ===\n") + await non_streaming_with_telemetry() + await streaming_with_telemetry() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index 758eccb015..801edf52f3 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -15,7 +15,7 @@ from agent_framework import tool from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from pydantic import Field diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py index 98c37d40f3..729aad6863 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py @@ -15,7 +15,7 @@ from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py index 827dfd86c1..2f14648bae 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py @@ -15,7 +15,7 @@ from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/scripts/sample_validation/create_dynamic_workflow_executor.py b/python/scripts/sample_validation/create_dynamic_workflow_executor.py index 66ba578228..f9356bbdd8 100644 --- a/python/scripts/sample_validation/create_dynamic_workflow_executor.py +++ b/python/scripts/sample_validation/create_dynamic_workflow_executor.py @@ -15,7 +15,7 @@ ) from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from pydantic import BaseModel from typing_extensions import Never diff --git a/python/uv.lock b/python/uv.lock index 6c0af082d4..1dab0ddc45 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -526,7 +526,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.31,<0.1.33" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] [[package]] @@ -2290,19 +2290,19 @@ wheels = [ [[package]] name = "github-copilot-sdk" -version = "0.1.32" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/67/ebd002c14fe7d2640d0fff47a0b29fdb21ed239b597afa2d2c6f6cfebb0b/github_copilot_sdk-0.1.32-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d97bc39fbd4b51e0aea3405299da1e643838ddbf6bff284f688a2d8c20d82ff8", size = 58576987, upload-time = "2026-03-07T15:28:24.062Z" }, - { url = "https://files.pythonhosted.org/packages/a5/50/add440f61e19f5b7e6989c89c5cefcb14c23f06627621e7c3a15a1f75e5d/github_copilot_sdk-0.1.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8098592f34e7ee7decbcbb7615c7eb924471e65a3e4d0d93bc49b0d112f8ec51", size = 55328145, upload-time = "2026-03-07T15:28:28.395Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b8/c3ca0678b21d8a0dd8fe3aa8fad4b7ec5f22cbe9d5fb3a11f82df4f40578/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c20cae4bec3584ce007a65a363216a1f98a71428a3ca3b76622f9e556307eed2", size = 61456678, upload-time = "2026-03-07T15:28:32.646Z" }, - { url = "https://files.pythonhosted.org/packages/21/5c/bdfe177353f88d44da9600c3ec478e2b0df7a838901947b168e869ba5ad7/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0941fd445e97a9b13fb713086c4a8c09c20ec8c7ab854cf009bd7cc213488999", size = 59641536, upload-time = "2026-03-07T15:28:36.977Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d0/2f3a07c74ecd24587b8f7d26729738f73e63f3341bf4bdc9eb2bb73ddaaf/github_copilot_sdk-0.1.32-py3-none-win_amd64.whl", hash = "sha256:37a82ff0908e01512052b69df4aa498332fa5769999635425015ed43cd850622", size = 54077464, upload-time = "2026-03-07T15:28:41.34Z" }, - { url = "https://files.pythonhosted.org/packages/1c/76/292088d6ccf2daf8bcb8a94b22b4f16005a6772087896f1b43c4f0d5edaa/github_copilot_sdk-0.1.32-py3-none-win_arm64.whl", hash = "sha256:3199c99604e8d393b1d60905be80b84da44e70d16d30b92e2ae9b92814cdc4ae", size = 52083845, upload-time = "2026-03-07T15:28:45.092Z" }, + { url = "https://files.pythonhosted.org/packages/67/41/76a9d50d7600bf8d26c659dc113be62e4e56e00a5cbfd544e1b5b200f45c/github_copilot_sdk-0.2.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c0823150f3b73431f04caee43d1dbafac22ae7e8bd1fc83727ee8363089ee038", size = 61076141, upload-time = "2026-04-03T20:18:22.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/d2e8bf4587c4da270ccb9cbd5ab8a2c4b41217c2bf04a43904be8a27ae20/github_copilot_sdk-0.2.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef7ff68eb8960515e1a2e199ac0ffb9a17cd3325266461e6edd7290e43dcf012", size = 57838464, upload-time = "2026-04-03T20:18:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/78/8b/cc8ee46724bd9fdfd6afe855a043c8403ed6884c5f3a55a9737780810396/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:890f7124e3b147532a1ac6c8d5f66421ea37757b2b9990d7967f3f147a2f533a", size = 63940155, upload-time = "2026-04-03T20:18:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ee/facf04e22e42d4bdd4fe3d356f3a51180a6ea769ae2ac306d0897f9bf9d9/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6502be0b9ececacbda671835e5f61c7aaa906c6b8657ee252cad6cc8335cac8e", size = 62130538, upload-time = "2026-04-03T20:18:34.061Z" }, + { url = "https://files.pythonhosted.org/packages/3f/1c/8b105f14bf61d1d304a00ac29460cb0d4e7406ceb89907d5a7b41a72fe85/github_copilot_sdk-0.2.1-py3-none-win_amd64.whl", hash = "sha256:8275ca8e387e6b29bc5155a3c02a0eb3d035c6bc7b1896253eb0d469f2385790", size = 56547331, upload-time = "2026-04-03T20:18:37.859Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/0ce319d2f618e9bc89f275e60b1920f4587eb0218bba6cbb84283dc7a7f3/github_copilot_sdk-0.2.1-py3-none-win_arm64.whl", hash = "sha256:1f9b59b7c41f31be416bf20818f58e25b6adc76f6d17357653fde6fbab662606", size = 54499549, upload-time = "2026-04-03T20:18:41.77Z" }, ] [[package]]