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 4744013b56..a9e26fc7cc 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -27,6 +27,7 @@ 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, SubprocessConfig @@ -129,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, @@ -143,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) @@ -151,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" @@ -194,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 @@ -250,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 @@ -300,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, @@ -308,6 +315,7 @@ def run( stream: Literal[False] = False, session: AgentSession | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload @@ -318,6 +326,7 @@ def run( stream: Literal[True], session: AgentSession | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( @@ -327,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. @@ -341,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]. @@ -756,3 +768,93 @@ async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSess tools=tools or None, mcp_servers=self._mcp_servers or None, ) + + +class GitHubCopilotAgent(AgentTelemetryLayer, RawGitHubCopilotAgent[OptionsT], Generic[OptionsT]): + """A GitHub Copilot Agent with OpenTelemetry instrumentation. + + 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`. + + 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 explicitly 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 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/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 17e9432e40..45125cdc22 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -189,6 +189,30 @@ def test_instructions_parameter_defaults_to_append_mode(self) -> None: "content": "Direct instructions", } + def test_default_options_includes_model_for_telemetry(self) -> None: + """Test that default_options merges model from settings for AgentTelemetryLayer span attributes.""" + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "claude-sonnet-4-5", "timeout": 120} + ) + opts = agent.default_options + assert opts["model"] == "claude-sonnet-4-5" + + def test_default_options_without_model_configured(self) -> None: + """Test that default_options works correctly when no model is configured.""" + agent = GitHubCopilotAgent(instructions="Helper") + opts = agent.default_options + assert "model" not in opts + assert opts.get("system_message") == {"mode": "append", "content": "Helper"} + + def test_default_options_returns_independent_copy(self) -> None: + """Test that mutating the returned dict does not affect internal state.""" + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "gpt-5.1-mini"} + ) + opts = agent.default_options + opts["model"] = "mutated" + assert agent._settings.get("model") == "gpt-5.1-mini" + class TestGitHubCopilotAgentLifecycle: """Test cases for agent lifecycle management.""" 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_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())