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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import contextlib
import inspect
import json
import logging
import sys
import warnings
Expand Down Expand Up @@ -52,7 +53,12 @@
from typing_extensions import TypeVar # pragma: no cover

try:
from copilot import CopilotClient, CopilotSession, RuntimeConnection
from copilot import (
CopilotClient,
CopilotSession,
RuntimeConnection,
TelemetryConfig,
)
from copilot.generated.rpc import (
PermissionDecisionApproveForSession,
PermissionDecisionApproveForSessionApproval,
Expand Down Expand Up @@ -338,6 +344,26 @@ async def normalized_handler(request: PermissionRequest, invocation: dict[str, s
return normalized_handler


def _parse_telemetry_config(raw: str) -> TelemetryConfig | None:
# GITHUB_COPILOT_TELEMETRY and matching .env values are read as plain strings while the
# Copilot SDK expects a mapping, so parse here before the value reaches CopilotClient.
# Malformed values are logged and ignored so a bad telemetry setting cannot prevent the
# agent from starting.
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
logger.warning(
"Ignoring malformed GITHUB_COPILOT_TELEMETRY value; expected a JSON object with TelemetryConfig keys."
)
return None
if not isinstance(parsed, dict):
logger.warning(
"Ignoring invalid GITHUB_COPILOT_TELEMETRY value; expected a JSON object with TelemetryConfig keys."
)
return None
return cast(TelemetryConfig, parsed)


class GitHubCopilotSettings(TypedDict, total=False):
"""GitHub Copilot model settings.

Expand All @@ -359,13 +385,16 @@ class GitHubCopilotSettings(TypedDict, total=False):
GITHUB_COPILOT_BASE_DIRECTORY. Defaults to ~/.copilot when not set.
Only applicable when the SDK spawns the CLI process (ignored when
connecting to an external server via a pre-configured client).
telemetry: OpenTelemetry configuration for the Copilot CLI process. This is
passed to the SDK client when it is created by the agent.
"""

cli_path: str | None
model: str | None
timeout: float | None
log_level: str | None
base_directory: str | None
telemetry: dict[str, Any]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we parse and validate this setting before handing it to the SDK, or keep it out of GitHubCopilotSettings? load_settings leaves GITHUB_COPILOT_TELEMETRY and .env values as strings, so a normal JSON value reaches CopilotClient; SDK 1.0.2 then indexes it as telemetry["otlp_endpoint"] during start() and raises TypeError, preventing the agent from starting. An explicit json.loads into a validated TelemetryConfig would preserve the documented environment path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call - pushed a fix that parses the setting before it reaches CopilotClient. GITHUB_COPILOT_TELEMETRY and .env values now go through json.loads into a TelemetryConfig mapping (malformed values are logged and dropped so startup can't break on a bad string), with regression tests for both the JSON and malformed paths. Also rebased onto main while I was in there.



class GitHubCopilotOptions(TypedDict, total=False):
Expand Down Expand Up @@ -437,6 +466,9 @@ class GitHubCopilotOptions(TypedDict, total=False):
base_directory: str
"""Directory where the CLI stores session state, configuration, and other persistent data."""

telemetry: TelemetryConfig
"""OpenTelemetry configuration for the Copilot CLI process."""

on_pre_tool_use: PreToolUseHandler
"""Pre-tool-use hook handler for the Copilot SDK.

Expand Down Expand Up @@ -574,6 +606,7 @@ def __init__(
on_pre_tool_use: PreToolUseHandler | None = opts.pop("on_pre_tool_use", None)
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
base_directory = opts.pop("base_directory", None)
telemetry = opts.pop("telemetry", None)

if on_function_approval is not None and on_pre_tool_use is not None:
raise ValueError(
Expand All @@ -600,6 +633,7 @@ def __init__(
timeout=timeout,
log_level=log_level,
base_directory=base_directory,
telemetry=telemetry,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
Expand Down Expand Up @@ -640,6 +674,9 @@ async def start(self) -> None:
cli_path = self._settings.get("cli_path") or None
log_level = self._settings.get("log_level") or None
base_directory = self._settings.get("base_directory") or None
telemetry = self._settings.get("telemetry") or None
if isinstance(telemetry, str):
telemetry = _parse_telemetry_config(telemetry)

client_kwargs: dict[str, Any] = {}
if cli_path:
Expand All @@ -648,6 +685,8 @@ async def start(self) -> None:
client_kwargs["log_level"] = log_level
if base_directory:
client_kwargs["base_directory"] = base_directory
if telemetry:
client_kwargs["telemetry"] = telemetry
self._client = CopilotClient(**client_kwargs)

try:
Expand Down Expand Up @@ -1434,7 +1473,15 @@ def _build_session_kwargs(
# Strip agent-internal and client-level keys that are consumed here or in the
# run methods (and settings) but are NOT valid create_session parameters, so
# they don't leak through the passthrough layer and raise TypeError.
for key in ("on_pre_tool_use", "on_function_approval", "timeout", "cli_path", "log_level", "base_directory"):
for key in (
"on_pre_tool_use",
"on_function_approval",
"timeout",
"cli_path",
"log_level",
"base_directory",
"telemetry",
):
kwargs.pop(key, None)

return kwargs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import base64
import inspect
import json
import os
import unittest.mock
from collections.abc import Sequence
Expand Down Expand Up @@ -420,6 +421,57 @@ async def test_start_passes_base_directory_to_client(self) -> None:
kwargs = MockClient.call_args.kwargs
assert kwargs["base_directory"] == "/custom/copilot/home"

async def test_start_passes_telemetry_to_client(self) -> None:
"""Test that telemetry settings are passed to the Copilot client."""
telemetry = {
"exporter_type": "otlp-http",
"otlp_endpoint": "http://localhost:4318",
"otlp_protocol": "http/json",
"capture_content": True,
}
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client

agent = GitHubCopilotAgent(
default_options=copilot_options(cast(GitHubCopilotOptions, {"telemetry": telemetry}))
)
await agent.start()

assert MockClient.call_args.kwargs["telemetry"] == telemetry

async def test_start_parses_json_telemetry_string(self) -> None:
"""JSON strings from env/.env settings are parsed before reaching the client."""
telemetry = {
"exporter_type": "otlp-http",
"otlp_endpoint": "http://localhost:4318",
"capture_content": True,
}
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client

agent = GitHubCopilotAgent()
agent._settings["telemetry"] = json.dumps(telemetry)
await agent.start()

assert MockClient.call_args.kwargs["telemetry"] == telemetry

async def test_start_ignores_malformed_telemetry_string(self) -> None:
"""A malformed telemetry JSON value is dropped instead of breaking startup."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client

agent = GitHubCopilotAgent()
agent._settings["telemetry"] = "{not json"
await agent.start()

assert "telemetry" not in MockClient.call_args.kwargs

async def test_start_base_directory_not_set_when_unspecified(self) -> None:
"""Test that base_directory is not included in client kwargs when not specified."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
Expand Down Expand Up @@ -1927,10 +1979,28 @@ def runtime_hook(_input: Any, _context: Any) -> Any:

agent = GitHubCopilotAgent(client=mock_client)
# timeout and on_pre_tool_use are consumed by the agent, not create_session.
await agent.run("hello", options=cast(Any, {"timeout": 30, "on_pre_tool_use": runtime_hook}))
await agent.run(
"hello",
options=cast(
Any,
{
"timeout": 30,
"on_pre_tool_use": runtime_hook,
"telemetry": {"exporter_type": "file", "file_path": "/tmp/copilot.jsonl"},
},
),
)

config = mock_client.create_session.call_args.kwargs
for leaked in ("timeout", "on_pre_tool_use", "on_function_approval", "cli_path", "log_level", "base_directory"):
for leaked in (
"timeout",
"on_pre_tool_use",
"on_function_approval",
"cli_path",
"log_level",
"base_directory",
"telemetry",
):
assert leaked not in config
# on_pre_tool_use is still honored via the hooks parameter.
assert config["hooks"]["on_pre_tool_use"] is runtime_hook
Expand Down
Loading