From 02a2d72e12225514ab043485f6b0346f110d949b Mon Sep 17 00:00:00 2001 From: bala Date: Thu, 29 Oct 2026 10:41:05 +0000 Subject: [PATCH 1/4] feat(copilot): SDK session options, skill tracking and image selection Let CopilotBot use a repository's agent skills and run on any sandbox image: - session_options: extra kwargs forwarded to CopilotClient.create_session (skill_directories, custom_agents, mcp_servers, working_directory, ...). Keys are validated against the installed SDK; model, provider, streaming, hooks and system_message stay managed by CopilotBot. run() accepts per-run overrides. Only option names are logged. - invoked_skills: names of skills used in the last run (skill.invoked events); skill load/invoke events are logged. - image: container image of the auto-created environment. copilot-cli's Node.js 22 is installed for the distro read from /etc/os-release (NodeSource on Debian, checksum-verified nodejs.org build on Azure Linux, which only packages Node 20; Debian's steps when the distro is unknown; unsupported distros fail before running). - Integration test: the agent upgrades util-linux in an Azure Linux checkout using its AGENTS.md and azldev-* skills (READ_ONLY by default, AZURELINUX_KEEP_CHANGES=1 to keep the changes). - Docs: session options and Azure Linux skills example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> # Conflicts: # docs/advanced/bots/copilot-bot.md # src/microbots/bot/CopilotBot.py # test/bot/test_copilot_bot.py --- docs/advanced/bots/copilot-bot.md | 54 ++++ src/microbots/bot/CopilotBot.py | 189 +++++++++++++- test/bot/test_copilot_bot.py | 239 ++++++++++++++++++ .../bot/test_copilot_bot_azurelinux_skills.py | 204 +++++++++++++++ 4 files changed, 674 insertions(+), 12 deletions(-) create mode 100644 test/bot/test_copilot_bot_azurelinux_skills.py diff --git a/docs/advanced/bots/copilot-bot.md b/docs/advanced/bots/copilot-bot.md index b834862..3fa3a7c 100644 --- a/docs/advanced/bots/copilot-bot.md +++ b/docs/advanced/bots/copilot-bot.md @@ -226,6 +226,8 @@ When multiple auth methods are configured simultaneously, CopilotBot resolves th | `azure_api_version` | `str` | `None` | Azure API version (for `provider_type="azure"` only). | | `token_provider` | `Callable[[], str]` | `None` | Callable returning a bearer token string. | | `sudo_access` | `bool` | `False` | Give the agent passwordless `sudo` inside the sandbox. See [Sudo access](../sandbox-images.md#sudo-access). | +| `image` | `str` | `None` | Shell-server image of the auto-created sandbox, e.g. `AZURELINUX_IMAGE`. See [Sandbox Images](../sandbox-images.md). | +| `session_options` | `Mapping[str, Any]` | `None` | Extra keyword arguments forwarded to the SDK's `create_session` on every run. See [Copilot SDK session options](#copilot-sdk-session-options). | ### `run()` method @@ -235,6 +237,9 @@ When multiple auth methods are configured simultaneously, CopilotBot resolves th | `additional_mounts` | `list[Mount]` | `None` | Extra folders to copy into the container. | | `timeout_in_seconds` | `int` | `600` | Maximum wall-clock time for the agent run. | | `streaming` | `bool` | `False` | Enable streaming delta events (logged at DEBUG level). | +| `session_options` | `Mapping[str, Any]` | `None` | Per-run `create_session` arguments. They override the constructor's `session_options` for this run only. | + +After a run, `bot.invoked_skills` lists the names of the agent skills used during that run. ### Return value — `BotRunResult` @@ -326,6 +331,55 @@ result = bot.run( bot.stop() ``` +## Copilot SDK session options + +`session_options` passes extra keyword arguments to the Copilot SDK's `CopilotClient.create_session`. Use it to configure agent skills, custom agents, MCP servers, or the working directory: + +```python +bot = CopilotBot( + folder_to_mount="/path/to/project", + session_options={ + "working_directory": "/workdir/project", + "skill_directories": ["/workdir/project/.agents/skills"], + "disabled_skills": ["experimental-skill"], + }, +) +``` + +- Paths are resolved inside the sandbox. The mounted folder is at `/workdir/`. +- CopilotBot checks each key against the installed SDK's `create_session` signature and raises `ValueError` for unknown keys. +- CopilotBot manages `model`, `provider`, `streaming`, `hooks`, and `system_message` itself, so `session_options` rejects them. Use the matching CopilotBot parameters instead. +- Only option names are logged, never values, because values such as MCP server environment variables can contain secrets. + +The Copilot CLI always loads `AGENTS.md` and `.github/copilot-instructions.md` from the working directory. + +## Using a repository's agent skills (Azure Linux example) + +The Azure Linux repository ships `AGENTS.md` and `azldev-*` skills under `.agents/skills`. These skills drive `azldev`, `mock`, and `rpm`, which Microbots provides as the `azldev.yaml`, `mock.yaml`, and `rpm.yaml` [tool definitions](../sandbox-images.md#azure-linux-build-tools). They run in the Azure Linux sandbox image, and `mock` requires `sudo_access=True`. + +```python +from microbots.bot.CopilotBot import CopilotBot +from microbots.environment.local_docker.images import AZURELINUX_IMAGE +from microbots.tools.tool_yaml_parser import parse_tool_definition + +bot = CopilotBot( + folder_to_mount="/mnt/azurelinux", + permission="READ_WRITE", # use READ_ONLY to try changes without keeping them + image=AZURELINUX_IMAGE, + sudo_access=True, + additional_tools=[parse_tool_definition(n) for n in ("rpm.yaml", "mock.yaml", "azldev.yaml")], + session_options={ + "working_directory": "/workdir/azurelinux", + "skill_directories": ["/workdir/azurelinux/.agents/skills"], + }, +) +result = bot.run("Upgrade the util-linux component to the newest Fedora rawhide version.") +print(bot.invoked_skills) +bot.stop() +``` + +See `test/bot/test_copilot_bot_azurelinux_skills.py` for an end-to-end example. + ## Cleanup Always call `bot.stop()` when you are done. This tears down the SDK client, the CLI server, and the Docker container: diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index 446db8c..019f654 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -34,17 +34,19 @@ """ import asyncio +import inspect import os import time import threading -from collections.abc import Callable +from collections.abc import Callable, Mapping from logging import getLogger -from typing import Optional +from typing import Any, Optional from microbots.constants import ( DOCKER_WORKING_DIR, PermissionLabels, ) +from microbots.environment.local_docker.images import DEFAULT_IMAGE from microbots.environment.local_docker.LocalDockerEnvironment import ( LocalDockerEnvironment, ) @@ -66,6 +68,96 @@ # copilot-cli port inside the container _CONTAINER_CLI_PORT = 4321 +# ``create_session`` arguments CopilotBot sets itself, mapped to where callers +# configure them instead of ``session_options``. +_RESERVED_SESSION_OPTIONS = { + "model": "the 'model' argument (or COPILOT_BYOK_MODEL)", + "provider": "the BYOK arguments (api_key, bearer_token, base_url, provider_type, ...)", + "streaming": "run(streaming=...)", + "hooks": "nothing — CopilotBot uses them for tool-call logging", + "system_message": "folder_to_mount / additional_tools usage instructions", +} + + +def _validate_session_options( + session_options: Optional[Mapping[str, Any]], + create_session: Callable, +) -> dict[str, Any]: + """Return a copy of *session_options* after checking each key is an + argument of the SDK's ``create_session`` that CopilotBot does not own. + + Parameters + ---------- + session_options : Optional[Mapping[str, Any]] + Keyword arguments destined for ``create_session``. + create_session : Callable + The SDK's ``create_session`` whose signature defines accepted keys. + + Returns + ------- + dict[str, Any] + A shallow copy of the options, or an empty dict when *None*. + + Raises + ------ + TypeError + If *session_options* is not a mapping. + ValueError + If a key is managed by CopilotBot or unknown to the installed SDK. + """ + if session_options is None: + return {} + if not isinstance(session_options, Mapping): + raise TypeError("session_options must be a mapping of create_session keyword arguments.") + + for key in session_options: + if key in _RESERVED_SESSION_OPTIONS: + raise ValueError( + f"session_options cannot set '{key}'; it is managed by CopilotBot " + f"via {_RESERVED_SESSION_OPTIONS[key]}." + ) + + accepted = { + name + for name, param in inspect.signature(create_session).parameters.items() + if param.kind in (param.KEYWORD_ONLY, param.POSITIONAL_OR_KEYWORD) and name != "self" + } + unknown = sorted(set(session_options) - accepted) + if unknown: + raise ValueError( + f"Unsupported session_options {unknown} for the installed github-copilot-sdk. " + f"Supported options: {sorted(accepted - set(_RESERVED_SESSION_OPTIONS))}" + ) + return dict(session_options) + + +# copilot-cli needs Node.js 22+; how to get it differs per sandbox distro. +# NOTE: Pinning to 1.0.39 which is the latest @github/copilot release compatible with github-copilot-sdk==0.3.0 as of May 2026. +_COPILOT_CLI_PACKAGE = "@github/copilot@1.0.39" +_NODE_INSTALL_COMMANDS = { + "debian": [ + # Remove stale third-party repos that may have expired GPG keys + "rm -f /etc/apt/sources.list.d/yarn.list", + "apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1", + "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1", + "apt-get install -y -qq nodejs > /dev/null 2>&1", + ], + # Azure Linux 3 only packages Node.js 20: install the official Node.js 22 + # build, verified against the release's SHASUMS256.txt. + "azurelinux": [ + "tdnf install -y curl ca-certificates tar xz > /dev/null", + "set -e; base=https://nodejs.org/dist/latest-v22.x; " + "case \"$(uname -m)\" in x86_64) arch=x64;; aarch64) arch=arm64;; *) arch=$(uname -m);; esac; " + "cd /tmp; curl -fsSL \"$base/SHASUMS256.txt\" -o SHASUMS256.txt; " + "tarball=$(grep -o \"node-v22[^ ]*-linux-$arch.tar.xz\" SHASUMS256.txt | head -1); " + "curl -fsSL \"$base/$tarball\" -o \"$tarball\"; " + "grep \" $tarball\\$\" SHASUMS256.txt | sha256sum -c - > /dev/null; " + "tar -xJf \"$tarball\" -C /usr/local --strip-components=1 --no-same-owner; " + "rm -f \"$tarball\" SHASUMS256.txt", + ], +} + + # Environment variable names for BYOK configuration _BYOK_ENV_PROVIDER_TYPE = "COPILOT_BYOK_PROVIDER_TYPE" _BYOK_ENV_BASE_URL = "COPILOT_BYOK_BASE_URL" @@ -308,6 +400,21 @@ class CopilotBot: tasks and repositories. Applies to the auto-created environment; a passed-in ``environment`` must already have been created with ``sudo_access=True``. + image : Optional[str] + Shell-server image of the auto-created environment, for example + ``images.AZURELINUX_IMAGE``. Defaults to the Debian image. Cannot + be combined with ``environment``. copilot-cli is installed to suit + the image's distro (from its ``/etc/os-release``). + session_options : Optional[Mapping[str, Any]] + Extra keyword arguments forwarded to the SDK's + ``CopilotClient.create_session`` for every run — for example + ``skill_directories``, ``disabled_skills``, ``custom_agents``, + ``mcp_servers``, ``working_directory``, ``enable_config_discovery``, + ``reasoning_effort`` or ``on_permission_request``. Paths are + resolved inside the sandbox (the mounted folder lives at + ``/workdir/``). ``model``, ``provider``, + ``streaming``, ``hooks`` and ``system_message`` are managed by + CopilotBot and rejected here. ``run()`` accepts per-run overrides. """ def __init__( @@ -326,6 +433,8 @@ def __init__( azure_api_version: Optional[str] = None, token_provider: Optional[Callable[[], str]] = None, sudo_access: bool = False, + image: Optional[str] = None, + session_options: Optional[Mapping[str, Any]] = None, ): try: from copilot import CopilotClient, ExternalServerConfig @@ -335,6 +444,19 @@ def __init__( "Install with: pip install microbots[ghcp]" ) + self._sdk_create_session = CopilotClient.create_session + self.session_options = _validate_session_options( + session_options, self._sdk_create_session + ) + self.invoked_skills: list[str] = [] + + if image is not None and environment is not None: + raise ValueError( + "Pass either `image` or `environment`, not both: the image of a " + "provided environment is chosen when that environment is created." + ) + self.image = image or DEFAULT_IMAGE + # ``PermissionHandler`` lives in ``copilot.session`` in # github-copilot-sdk >= 0.3.0 and in ``copilot.types`` in older # releases. Try the new location first and fall back to the old @@ -440,6 +562,7 @@ def run( additional_mounts: Optional[list[Mount]] = None, timeout_in_seconds: int = 600, streaming: bool = False, + session_options: Optional[Mapping[str, Any]] = None, ) -> BotRunResult: """Send *task* to the Copilot agent and wait for completion. @@ -457,6 +580,9 @@ def run( Maximum wall-clock time for the agent run. streaming : bool Whether to enable streaming delta events (logged at DEBUG level). + session_options : Optional[Mapping[str, Any]] + Per-run ``create_session`` arguments, merged over (and taking + precedence on) the constructor's ``session_options``. Returns ------- @@ -466,6 +592,12 @@ def run( """ logger.info("🚀 Starting CopilotBot run — task: %.120s...", task) + run_session_options = { + **self.session_options, + **_validate_session_options(session_options, self._sdk_create_session), + } + self.invoked_skills = [] + # Setup additional tools (env vars, files, setup_commands) for tool in self.additional_tools: logger.info("⚙️ Setting up tool '%s'", tool.name) @@ -485,6 +617,7 @@ def run( system_content=system_content, timeout=timeout_in_seconds, streaming=streaming, + session_options=run_session_options, ) ) logger.info("✅ CopilotBot run completed successfully") @@ -536,6 +669,7 @@ def _create_environment(self): self.environment = LocalDockerEnvironment( port=free_port, folder_to_mount=self.folder_to_mount, + image=self.image, sudo_access=self.sudo_access, ) @@ -543,17 +677,9 @@ def _install_copilot_cli(self): """Install copilot-cli inside the Docker container.""" logger.info("📦 Installing copilot-cli inside container...") - # Install Node.js (required for copilot-cli via npm) install_commands = [ - # Remove stale third-party repos that may have expired GPG keys - "rm -f /etc/apt/sources.list.d/yarn.list", - # Install Node.js 22.x (copilot-cli requires Node 22+) - "apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1", - "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1", - "apt-get install -y -qq nodejs > /dev/null 2>&1", - # Install copilot-cli globally - # NOTE: Pinning to 1.0.39 which is the latest @github/copilot release compatible with github-copilot-sdk==0.3.0 as of May 2026. - "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1", + *self._node_install_commands(), + f"npm install -g {_COPILOT_CLI_PACKAGE} > /dev/null 2>&1", ] for cmd in install_commands: @@ -572,6 +698,31 @@ def _install_copilot_cli(self): ) logger.info("✅ copilot-cli installed: %s", verify.stdout.strip()) + def _node_install_commands(self) -> list[str]: + """Return the commands that install Node.js 22+ on the sandbox's distro. + + Returns + ------- + list[str] + Commands for the first supported distro ID (``ID`` then + ``ID_LIKE``); Debian's when the distro cannot be detected. + + Raises + ------ + RuntimeError + If the sandbox runs a detected but unsupported distro. + """ + ids = list(self.environment.get_distro_ids()) + for distro_id in ids: + if distro_id in _NODE_INSTALL_COMMANDS: + return _NODE_INSTALL_COMMANDS[distro_id] + if not ids: + return _NODE_INSTALL_COMMANDS["debian"] + raise RuntimeError( + f"CopilotBot cannot install copilot-cli on distro {'/'.join(ids)}; " + f"supported: {sorted(_NODE_INSTALL_COMMANDS)}." + ) + def _start_copilot_cli_server(self): """Start copilot-cli in headless server mode inside the container. @@ -647,6 +798,7 @@ async def _execute_session( system_content: str, timeout: int, streaming: bool, + session_options: Optional[Mapping[str, Any]] = None, ) -> str: """Create a Copilot session, send the task, and collect the result.""" from copilot.generated.session_events import SessionEventType @@ -669,10 +821,16 @@ async def _execute_session( logger.info("📡 Creating Copilot session (model=%s, streaming=%s, byok=%s)", self.model, streaming, self._provider_config is not None) logger.debug("Session kwargs: %s", session_kwargs) + if session_options: + # Only keys are logged: values (e.g. MCP server env/headers) may hold secrets. + logger.info("🧩 Extra session options: %s", sorted(session_options)) + session_kwargs.update(session_options) session = await self._client.create_session(**session_kwargs) collected_text = [] done_event = asyncio.Event() + skill_invoked = getattr(SessionEventType, "SKILL_INVOKED", None) + skills_loaded = getattr(SessionEventType, "SESSION_SKILLS_LOADED", None) def _on_event(event): if event.type == SessionEventType.ASSISTANT_MESSAGE: @@ -685,6 +843,13 @@ def _on_event(event): elif event.type == SessionEventType.SESSION_IDLE: logger.info("⏹️ Session idle — agent finished processing") done_event.set() + elif skill_invoked is not None and event.type == skill_invoked: + name = getattr(event.data, "name", None) or "unknown" + self.invoked_skills.append(name) + logger.info("🧠 Skill invoked: %s (%s)", name, getattr(event.data, "path", "")) + elif skills_loaded is not None and event.type == skills_loaded: + names = [s.name for s in (getattr(event.data, "skills", None) or [])] + logger.info("📚 Skills loaded (%d): %s", len(names), ", ".join(names)) else: logger.debug("📨 Session event: %s", event.type) diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index d5a9f3c..8dc59dc 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -1639,6 +1639,245 @@ def test_run_with_old_sdk_layout_types(self): assert kwargs["on_permission_request"] is types_handler.approve_all +# --------------------------------------------------------------------------- +# Unit tests — session_options, distro and skill tracking +# --------------------------------------------------------------------------- + +async def _fake_create_session( + self, *, model=None, streaming=None, hooks=None, provider=None, + system_message=None, on_permission_request=None, skill_directories=None, + disabled_skills=None, working_directory=None, mcp_servers=None, +): + """Stand-in with the keyword surface of the SDK's ``create_session``.""" + + +@contextlib.contextmanager +def _bot_dependencies(mock_environment, client): + client_cls = MagicMock(return_value=client) + client_cls.create_session = _fake_create_session + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment) as lde, + patch("microbots.bot.CopilotBot.get_free_port", return_value=9000), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", new=client_cls), + patch("copilot.ExternalServerConfig", return_value=MagicMock()), + ): + from microbots.bot.CopilotBot import CopilotBot + yield CopilotBot, lde + + +@pytest.mark.unit +class TestValidateSessionOptions: + """Tests for the module-level _validate_session_options helper.""" + + def _validate(self, options): + from microbots.bot.CopilotBot import _validate_session_options + return _validate_session_options(options, _fake_create_session) + + def test_none_returns_empty_dict(self): + assert self._validate(None) == {} + + def test_returns_copy_of_valid_options(self): + options = {"skill_directories": ["/workdir/repo/.agents/skills"], "disabled_skills": ["x"]} + validated = self._validate(options) + assert validated == options + assert validated is not options + + def test_rejects_non_mapping(self): + with pytest.raises(TypeError, match="must be a mapping"): + self._validate([("skill_directories", [])]) + + @pytest.mark.parametrize("key", ["model", "provider", "streaming", "hooks", "system_message"]) + def test_rejects_reserved_keys(self, key): + with pytest.raises(ValueError, match=f"cannot set '{key}'"): + self._validate({key: "value"}) + + def test_rejects_unknown_keys_and_lists_supported(self): + with pytest.raises(ValueError, match=r"Unsupported session_options \['skill_dirs'\]") as err: + self._validate({"skill_dirs": []}) + assert "skill_directories" in str(err.value) + assert "'self'" not in str(err.value) + assert "'model'" not in str(err.value) + + +@pytest.mark.unit +class TestCopilotBotSessionOptions: + """session_options are validated up front and forwarded to create_session.""" + + def test_invalid_option_fails_before_environment_creation(self, mock_environment, mock_copilot_client): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + with pytest.raises(ValueError, match="Unsupported session_options"): + CopilotBot(model="gpt-4.1", github_token="ghp_test", session_options={"nope": 1}) + lde.assert_not_called() + + def test_options_forwarded_and_run_overrides_take_precedence( + self, mock_environment, mock_copilot_client + ): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _): + bot = CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + session_options={ + "skill_directories": ["/workdir/repo/.agents/skills"], + "working_directory": "/workdir/repo", + }, + ) + result = bot.run("task", session_options={"working_directory": "/workdir/other"}) + bot.stop() + + assert result.status is True, result.error + kwargs = mock_copilot_client.create_session.call_args.kwargs + assert kwargs["skill_directories"] == ["/workdir/repo/.agents/skills"] + assert kwargs["working_directory"] == "/workdir/other" + assert kwargs["model"] == "gpt-4.1" + # Per-run overrides do not leak into the bot's defaults. + assert bot.session_options["working_directory"] == "/workdir/repo" + + def test_invalid_run_option_raises(self, mock_environment, mock_copilot_client): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _): + bot = CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + with pytest.raises(ValueError, match="cannot set 'model'"): + bot.run("task", session_options={"model": "other"}) + bot.stop() + mock_copilot_client.create_session.assert_not_called() + + def test_option_values_are_not_logged(self, mock_environment, mock_copilot_client, caplog): + import logging + + secret_servers = {"srv": {"env": {"TOKEN": "super-secret"}}} + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _): + bot = CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + session_options={"mcp_servers": secret_servers}, + ) + with caplog.at_level(logging.DEBUG): + bot.run("task") + bot.stop() + + assert "mcp_servers" in caplog.text + assert "super-secret" not in caplog.text + assert mock_copilot_client.create_session.call_args.kwargs["mcp_servers"] == secret_servers + + +@pytest.mark.unit +class TestCopilotBotSkillEvents: + """Skill events from the session are logged and recorded per run.""" + + def test_records_invoked_skills_and_resets_each_run( + self, mock_environment, mock_copilot_client, mock_copilot_session, caplog + ): + import logging + + captured = [] + mock_copilot_session.on = MagicMock(side_effect=captured.append) + + def _event(event_type, **data): + return Mock(type=event_type, data=Mock(**data)) + + loaded = _event("session.skills_loaded", skills=[Mock(), Mock()]) + loaded.data.skills[0].name = "azldev-comp-toml" + loaded.data.skills[1].name = "azldev-update-component" + invoked = _event("skill.invoked", path="/workdir/repo/.agents/skills/x/SKILL.md") + invoked.data.name = "azldev-update-component" + events = [loaded, invoked] + + response = Mock() + response.data = Mock(content="done") + + async def _send_and_wait(task, timeout): + for event in events: + captured[-1](event) + return response + + mock_copilot_session.send_and_wait = _send_and_wait + + with ( + _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _), + patch.object(_mock_events.SessionEventType, "SKILL_INVOKED", "skill.invoked", create=True), + patch.object( + _mock_events.SessionEventType, "SESSION_SKILLS_LOADED", "session.skills_loaded", create=True + ), + ): + bot = CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + with caplog.at_level(logging.INFO): + assert bot.run("first").status is True + assert bot.invoked_skills == ["azldev-update-component"] + + events = [] + assert bot.run("second").status is True + assert bot.invoked_skills == [] + bot.stop() + + assert "Skills loaded (2): azldev-comp-toml, azldev-update-component" in caplog.text + assert "Skill invoked: azldev-update-component" in caplog.text + + +@pytest.mark.unit +class TestCopilotBotImage: + """image selects the sandbox image; copilot-cli is installed per detected distro.""" + + @pytest.mark.parametrize("image, expected", [ + (None, "mcr.microsoft.com/devcontainers/python:3.11"), + ("mcr.microsoft.com/azurelinux/base/core:3.0", "mcr.microsoft.com/azurelinux/base/core:3.0"), + ]) + def test_forwarded_to_auto_created_environment( + self, mock_environment, mock_copilot_client, image, expected + ): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + bot = CopilotBot(model="gpt-4.1", github_token="ghp_test", image=image) + bot.stop() + assert bot.image == expected + assert lde.call_args.kwargs["image"] == expected + + def test_rejects_image_with_provided_environment(self, mock_environment, mock_copilot_client): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + with pytest.raises(ValueError, match="Pass either `image` or `environment`"): + CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test", image="x/y:z") + lde.assert_not_called() + + @pytest.mark.parametrize( + "ids, first_command", + [ + (["debian"], "rm -f /etc/apt/sources.list.d/yarn.list"), + (["ubuntu", "debian"], "rm -f /etc/apt/sources.list.d/yarn.list"), + ([], "rm -f /etc/apt/sources.list.d/yarn.list"), + (["azurelinux"], "tdnf install -y curl ca-certificates tar xz > /dev/null"), + ], + ) + def test_copilot_cli_install_matches_sandbox_distro(self, mock_environment, ids, first_command): + import importlib + + bot_module = importlib.import_module("microbots.bot.CopilotBot") + bot = bot_module.CopilotBot.__new__(bot_module.CopilotBot) + bot.environment = mock_environment + mock_environment.get_distro_ids = MagicMock(return_value=ids) + + bot._install_copilot_cli() + + commands = [c.args[0] for c in mock_environment.execute_privileged.call_args_list] + assert commands[0] == first_command + assert commands[-1] == f"npm install -g {bot_module._COPILOT_CLI_PACKAGE} > /dev/null 2>&1" + if ids == ["azurelinux"]: + assert "sha256sum -c" in commands[1] and "latest-v22.x" in commands[1] + + def test_copilot_cli_install_rejects_unsupported_distro(self, mock_environment): + import importlib + + bot_module = importlib.import_module("microbots.bot.CopilotBot") + bot = bot_module.CopilotBot.__new__(bot_module.CopilotBot) + bot.environment = mock_environment + mock_environment.get_distro_ids = MagicMock(return_value=["alpine"]) + + with pytest.raises(RuntimeError, match="cannot install copilot-cli on distro alpine"): + bot._install_copilot_cli() + mock_environment.execute_privileged.assert_not_called() + + # --------------------------------------------------------------------------- # Unit tests — sudo_access # --------------------------------------------------------------------------- diff --git a/test/bot/test_copilot_bot_azurelinux_skills.py b/test/bot/test_copilot_bot_azurelinux_skills.py new file mode 100644 index 0000000..6240a3e --- /dev/null +++ b/test/bot/test_copilot_bot_azurelinux_skills.py @@ -0,0 +1,204 @@ +""" +Integration test: CopilotBot uses a third-party repository's agent skills. + +The Azure Linux repository ships ``AGENTS.md`` plus ``azldev-*`` skills under +``.agents/skills``. This test mounts the checkout into an Azure Linux +sandbox with sudo access (``mock`` needs it), installs the ``rpm``, ``mock`` and ``azldev`` +tool definitions, points the Copilot session at the repository's skills via +``session_options`` and asks the agent to upgrade the ``util-linux`` component. + +By default the checkout is mounted READ_ONLY: the agent works on an overlay and +its changes are discarded with the sandbox, so the host checkout is unchanged. +Set ``AZURELINUX_KEEP_CHANGES=1`` to mount it READ_WRITE and keep the agent's +changes in the checkout for review. + +Prerequisites: + - Docker daemon, ``pip install microbots[ghcp]`` + - Copilot auth: COPILOT_BYOK_* (see test_copilot_bot.py) or GitHub auth + - An Azure Linux checkout at ``AZURELINUX_REPO_PATH`` (default /mnt/azurelinux) + - Network access: the stock Azure Linux image (AZURELINUX_IMAGE) is prepared on + the fly. Set MICROBOTS_SHELL_IMAGE to try a different image. + +Usage: + pytest test/bot/test_copilot_bot_azurelinux_skills.py -m "integration and ghcp" -v +""" + +import logging +import os +import re +import shutil +import subprocess +import hashlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +) + +from microbots.constants import DOCKER_WORKING_DIR, PermissionLabels +from microbots.environment.local_docker.images import AZURELINUX_IMAGE +from microbots.tools.tool_yaml_parser import parse_tool_definition + +logger = logging.getLogger(__name__) + +AZURELINUX_REPO = Path(os.environ.get("AZURELINUX_REPO_PATH", "/mnt/azurelinux")) +COMPONENT = "util-linux" +RENDERED_SPEC = f"specs/u/{COMPONENT}/{COMPONENT}.spec" +LOCK_FILE = f"locks/{COMPONENT}.lock" +COMP_DIR = f"base/comps/{COMPONENT}" +KEEP_CHANGES = os.environ.get("AZURELINUX_KEEP_CHANGES", "").strip().lower() in ("1", "true", "yes") + +UPGRADE_TASK = f""" +You are working in the Azure Linux repository (your current working directory). +Upgrade the `{COMPONENT}` component to the newest {COMPONENT} version that Fedora +provides upstream (Fedora rawhide). Follow AGENTS.md and use the azldev-* skills +available to you (in particular azldev-comp-toml and azldev-update-component). + +Requirements: +- Change only what is needed under {COMP_DIR}/ to pick up the newer upstream + version, with a TOML comment explaining why. +- Refresh the lock with `azldev comp update -p {COMPONENT}` and re-render with + `azldev comp render -p {COMPONENT}`. +- Confirm {RENDERED_SPEC} now reports the new Version. If it still shows the old + Version, fix the component's source resolution (for example the upstream + snapshot it inherits) and re-run update and render until it does. +- This is an automated validation run on a machine with limited disk space: + do NOT run `azldev comp build`, mock smoke tests, `git commit` or `git push`. +- Finish with a short summary of the old and new versions and the files you changed. +""" + + +def _copilot_sdk_installed(): + try: + from importlib.metadata import version + version("github-copilot-sdk") + return True + except Exception: + return False + + +def _copilot_auth_configured(): + if os.environ.get("COPILOT_BYOK_BASE_URL") and os.environ.get("COPILOT_BYOK_PROVIDER_TYPE"): + return True + if any(os.environ.get(v) for v in ("COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN")): + return True + if shutil.which("gh"): + try: + return subprocess.run(["gh", "auth", "status"], capture_output=True, timeout=10).returncode == 0 + except Exception: + pass + return False + + +def _docker_available(): + try: + import docker + docker.from_env().ping() + return True + except Exception: + return False + + +def _use_real_copilot_sdk(): + """Drop the SDK mocks other test modules install in sys.modules.""" + for key in [k for k in sys.modules if k == "copilot" or k.startswith("copilot.")]: + del sys.modules[key] + sys.modules.pop("microbots.bot.CopilotBot", None) + + +def _spec_version(spec_text: str) -> tuple[int, ...]: + match = re.search(r"^Version:\s*(\S+)", spec_text, re.MULTILINE) + assert match, "rendered spec has no Version tag" + return tuple(int(part) for part in re.findall(r"\d+", match.group(1))) + + +def _tree_digest(root: Path, paths: list[str]) -> str: + digest = hashlib.sha256() + for rel in paths: + target = root / rel + files = sorted(target.rglob("*")) if target.is_dir() else [target] + for f in files: + if f.is_file(): + digest.update(str(f.relative_to(root)).encode()) + digest.update(f.read_bytes()) + return digest.hexdigest() + + +@pytest.mark.skipif(not _copilot_sdk_installed(), reason="github-copilot-sdk not installed (pip install microbots[ghcp])") +@pytest.mark.skipif(not _copilot_auth_configured(), reason="No Copilot auth (COPILOT_BYOK_* or GitHub auth)") +@pytest.mark.skipif( + not (AZURELINUX_REPO / "AGENTS.md").is_file() or not (AZURELINUX_REPO / ".agents" / "skills").is_dir(), + reason=f"Azure Linux checkout with AGENTS.md and .agents/skills not found at {AZURELINUX_REPO}", +) +@pytest.mark.skipif(not _docker_available(), reason="Docker daemon not available") +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.docker +@pytest.mark.ghcp +class TestCopilotBotAzureLinuxSkills: + """CopilotBot follows Azure Linux's AGENTS.md and azldev-* skills end to end.""" + + def test_upgrade_util_linux_with_azldev_skills(self): + _use_real_copilot_sdk() + from microbots.bot.CopilotBot import CopilotBot + + tracked = [COMP_DIR, LOCK_FILE, f"specs/u/{COMPONENT}"] + host_digest = _tree_digest(AZURELINUX_REPO, tracked) + original_version = _spec_version((AZURELINUX_REPO / RENDERED_SPEC).read_text()) + permission = PermissionLabels.READ_WRITE if KEEP_CHANGES else PermissionLabels.READ_ONLY + + sandbox_repo = f"{DOCKER_WORKING_DIR}/{AZURELINUX_REPO.name}" + + bot = CopilotBot( + model=os.environ.get("COPILOT_BYOK_MODEL", "gpt-4.1"), + folder_to_mount=str(AZURELINUX_REPO), + permission=permission, + image=os.environ.get("MICROBOTS_SHELL_IMAGE", AZURELINUX_IMAGE), + sudo_access=True, + additional_tools=[parse_tool_definition(name) for name in ("rpm.yaml", "mock.yaml", "azldev.yaml")], + session_options={ + "working_directory": sandbox_repo, + "skill_directories": [f"{sandbox_repo}/.agents/skills"], + }, + ) + + try: + result = bot.run(UPGRADE_TASK, timeout_in_seconds=2400) + logger.info("Agent summary:\n%s", result.result) + assert result.status is True, f"CopilotBot run failed: {result.error}" + + azldev_skills = [s for s in bot.invoked_skills if s.startswith("azldev-")] + assert azldev_skills, f"No azldev-* skill was invoked (invoked: {bot.invoked_skills})" + + changed = bot.environment.execute( + f"git -C {sandbox_repo} status --porcelain -- {COMP_DIR} {LOCK_FILE} {RENDERED_SPEC}" + ) + assert changed.return_code == 0, changed.stderr + assert LOCK_FILE in changed.stdout, f"Lock was not refreshed:\n{changed.stdout}" + assert RENDERED_SPEC in changed.stdout, f"Spec was not re-rendered:\n{changed.stdout}" + + spec = bot.environment.execute(f"cat {sandbox_repo}/{RENDERED_SPEC}") + assert spec.return_code == 0, spec.stderr + new_version = _spec_version(spec.stdout) + assert new_version > original_version, ( + f"{COMPONENT} was not upgraded: {original_version} -> {new_version}" + ) + finally: + bot.stop() + + if KEEP_CHANGES: + assert _tree_digest(AZURELINUX_REPO, tracked) != host_digest + # The agent runs azldev/mock as the host user, so it leaves no root-owned files. + if os.getuid() != 0: + root_owned = [ + p for rel in tracked + for p in [AZURELINUX_REPO / rel, *(AZURELINUX_REPO / rel).rglob("*")] + if p.exists() and p.lstat().st_uid == 0 + ] + assert not root_owned, f"Root-owned files left in the checkout: {root_owned[:5]}" + else: + # READ_ONLY: every change stayed in the sandbox overlay. + assert _tree_digest(AZURELINUX_REPO, tracked) == host_digest From a07968bd0bca6f236610ee11b1a973e077e4fbc3 Mon Sep 17 00:00:00 2001 From: bala Date: Mon, 2 Nov 2026 10:34:28 +0000 Subject: [PATCH 2/4] refactor(copilot): install copilot-cli from a tool definition Review follow-up: CopilotBot had its own per-distro Node.js/copilot-cli install table and matching logic, duplicating the distro-aware tool command resolution (#205). Move it into the normal tool model: - New tool_definitions/copilot-cli.yaml: Node.js 22 per distro (NodeSource on Debian; checksum-verified nodejs.org build on Azure Linux, which only packages Node 20) and the pinned @github/copilot@1.0.39 CLI, verified with `copilot --version`. It has no usage instructions, since it is infrastructure rather than a tool for the model. - CopilotBot._install_copilot_cli() installs and verifies that tool; _NODE_INSTALL_COMMANDS, _COPILOT_CLI_PACKAGE and _node_install_commands are removed. Unsupported distros now raise UnsupportedDistroError, and an undetectable distro falls back to the first (Debian) entry. - The headless server start, token injection and readiness check stay in CopilotBot; the host-side SDK stays the `ghcp` pip extra. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/microbots/bot/CopilotBot.py | 87 ++++--------------- .../tools/tool_definitions/copilot-cli.yaml | 40 +++++++++ test/bot/test_copilot_bot.py | 11 ++- test/tools/test_tool_yaml_parser.py | 17 ++++ 4 files changed, 82 insertions(+), 73 deletions(-) create mode 100644 src/microbots/tools/tool_definitions/copilot-cli.yaml diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index 019f654..31d5657 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -10,8 +10,9 @@ - Docker sandbox (LocalDockerEnvironment) for isolated execution - Mount system for folder access control - ToolAbstract lifecycle (install → verify → setup) for additional tools - - copilot-cli is installed **inside** the container and run in headless - server mode; the SDK on the host connects to it via TCP. + - copilot-cli is installed **inside** the container (from the + ``copilot-cli.yaml`` tool definition) and run in headless server mode; + the SDK on the host connects to it via TCP. Architecture: @@ -54,6 +55,7 @@ from microbots.MicroBot import BotRunResult from microbots.tools.external_tool import ExternalTool from microbots.tools.tool import ToolAbstract +from microbots.tools.tool_yaml_parser import parse_tool_definition from microbots.utils.copilot_auth import get_copilot_token from microbots.utils.network import get_free_port # still used for _create_environment @@ -131,31 +133,9 @@ def _validate_session_options( return dict(session_options) -# copilot-cli needs Node.js 22+; how to get it differs per sandbox distro. -# NOTE: Pinning to 1.0.39 which is the latest @github/copilot release compatible with github-copilot-sdk==0.3.0 as of May 2026. -_COPILOT_CLI_PACKAGE = "@github/copilot@1.0.39" -_NODE_INSTALL_COMMANDS = { - "debian": [ - # Remove stale third-party repos that may have expired GPG keys - "rm -f /etc/apt/sources.list.d/yarn.list", - "apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1", - "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1", - "apt-get install -y -qq nodejs > /dev/null 2>&1", - ], - # Azure Linux 3 only packages Node.js 20: install the official Node.js 22 - # build, verified against the release's SHASUMS256.txt. - "azurelinux": [ - "tdnf install -y curl ca-certificates tar xz > /dev/null", - "set -e; base=https://nodejs.org/dist/latest-v22.x; " - "case \"$(uname -m)\" in x86_64) arch=x64;; aarch64) arch=arm64;; *) arch=$(uname -m);; esac; " - "cd /tmp; curl -fsSL \"$base/SHASUMS256.txt\" -o SHASUMS256.txt; " - "tarball=$(grep -o \"node-v22[^ ]*-linux-$arch.tar.xz\" SHASUMS256.txt | head -1); " - "curl -fsSL \"$base/$tarball\" -o \"$tarball\"; " - "grep \" $tarball\\$\" SHASUMS256.txt | sha256sum -c - > /dev/null; " - "tar -xJf \"$tarball\" -C /usr/local --strip-components=1 --no-same-owner; " - "rm -f \"$tarball\" SHASUMS256.txt", - ], -} +# copilot-cli (with its Node.js runtime) is installed from this tool definition, +# which holds the per-distro install commands and the pinned CLI version. +_COPILOT_CLI_TOOL = "copilot-cli.yaml" # Environment variable names for BYOK configuration @@ -674,54 +654,23 @@ def _create_environment(self): ) def _install_copilot_cli(self): - """Install copilot-cli inside the Docker container.""" - logger.info("📦 Installing copilot-cli inside container...") - - install_commands = [ - *self._node_install_commands(), - f"npm install -g {_COPILOT_CLI_PACKAGE} > /dev/null 2>&1", - ] - - for cmd in install_commands: - result = self.environment.execute_privileged(cmd, timeout=300) - if result.return_code != 0: - raise RuntimeError( - f"Failed to install copilot-cli: {cmd}\n" - f"stdout: {result.stdout}\nstderr: {result.stderr}" - ) - - # Verify installation - verify = self.environment.execute("copilot --version") - if verify.return_code != 0: - raise RuntimeError( - f"copilot-cli installation verification failed: {verify.stderr}" - ) - logger.info("✅ copilot-cli installed: %s", verify.stdout.strip()) - - def _node_install_commands(self) -> list[str]: - """Return the commands that install Node.js 22+ on the sandbox's distro. + """Install copilot-cli inside the container from ``copilot-cli.yaml``. - Returns - ------- - list[str] - Commands for the first supported distro ID (``ID`` then - ``ID_LIKE``); Debian's when the distro cannot be detected. + The tool definition picks the Node.js install for the sandbox's distro + (from its ``/etc/os-release``) and pins the CLI version. Raises ------ RuntimeError - If the sandbox runs a detected but unsupported distro. + If installation or ``copilot --version`` fails, or (as + ``UnsupportedDistroError``) if the sandbox runs a detected but + unsupported distro. """ - ids = list(self.environment.get_distro_ids()) - for distro_id in ids: - if distro_id in _NODE_INSTALL_COMMANDS: - return _NODE_INSTALL_COMMANDS[distro_id] - if not ids: - return _NODE_INSTALL_COMMANDS["debian"] - raise RuntimeError( - f"CopilotBot cannot install copilot-cli on distro {'/'.join(ids)}; " - f"supported: {sorted(_NODE_INSTALL_COMMANDS)}." - ) + logger.info("📦 Installing copilot-cli inside container...") + tool = parse_tool_definition(_COPILOT_CLI_TOOL) + tool.install_tool(self.environment) + tool.verify_tool_installation(self.environment) + logger.info("✅ copilot-cli installed") def _start_copilot_cli_server(self): """Start copilot-cli in headless server mode inside the container. diff --git a/src/microbots/tools/tool_definitions/copilot-cli.yaml b/src/microbots/tools/tool_definitions/copilot-cli.yaml new file mode 100644 index 0000000..015aa98 --- /dev/null +++ b/src/microbots/tools/tool_definitions/copilot-cli.yaml @@ -0,0 +1,40 @@ +name: copilot-cli +tool_type: internal +description: GitHub Copilot CLI, which CopilotBot runs as a headless server inside the sandbox. + +# Infrastructure for CopilotBot rather than a tool for the model: CopilotBot installs +# it itself before starting the server, so it is never passed as an additional tool +# and has no usage instructions. +usage_instructions_to_llm: "" + +# copilot-cli needs Node.js 22+. Keep the CLI version in step with the +# github-copilot-sdk version pinned by the `ghcp` extra in pyproject.toml: +# 1.0.39 is the latest @github/copilot release compatible with +# github-copilot-sdk==0.3.0 (as of May 2026). +install_commands: + debian: + # Remove stale third-party repos that may have expired GPG keys. + - rm -f /etc/apt/sources.list.d/yarn.list + - apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 + - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 + - apt-get install -y -qq nodejs > /dev/null 2>&1 + - &install_cli "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1" + azurelinux: + - tdnf install -y curl ca-certificates tar xz > /dev/null + # Azure Linux 3 only packages Node.js 20: install the official Node.js 22 + # build, verified against the release's SHASUMS256.txt. + - | + set -e + base=https://nodejs.org/dist/latest-v22.x + case "$(uname -m)" in x86_64) arch=x64;; aarch64) arch=arm64;; *) arch=$(uname -m);; esac + cd /tmp + curl -fsSL "$base/SHASUMS256.txt" -o SHASUMS256.txt + tarball=$(grep -o "node-v22[^ ]*-linux-$arch.tar.xz" SHASUMS256.txt | head -1) + curl -fsSL "$base/$tarball" -o "$tarball" + grep " $tarball\$" SHASUMS256.txt | sha256sum -c - > /dev/null + tar -xJf "$tarball" -C /usr/local --strip-components=1 --no-same-owner + rm -f "$tarball" SHASUMS256.txt + - *install_cli + +verify_commands: + - copilot --version diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index 8dc59dc..70471ec 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -532,7 +532,7 @@ def test_install_cli_raises_on_failure(self, mock_environment): patch("copilot.CopilotClient", return_value=AsyncMock()), patch("copilot.ExternalServerConfig", return_value=MagicMock()), ): - with pytest.raises(RuntimeError, match="Failed to install copilot-cli"): + with pytest.raises(RuntimeError, match="Failed to install tool copilot-cli"): CopilotBot( model="gpt-4.1", environment=mock_environment, @@ -968,7 +968,7 @@ def side_effect(cmd, **kwargs): patch("copilot.CopilotClient", return_value=AsyncMock()), patch("copilot.ExternalServerConfig", return_value=MagicMock()), ): - with pytest.raises(RuntimeError, match="verification failed"): + with pytest.raises(RuntimeError, match="Failed to verify installation of tool copilot-cli"): CopilotBot( model="gpt-4.1", environment=mock_environment, @@ -1845,6 +1845,7 @@ def test_rejects_image_with_provided_environment(self, mock_environment, mock_co [ (["debian"], "rm -f /etc/apt/sources.list.d/yarn.list"), (["ubuntu", "debian"], "rm -f /etc/apt/sources.list.d/yarn.list"), + # Unknown distro: the tool falls back to its first entry (Debian). ([], "rm -f /etc/apt/sources.list.d/yarn.list"), (["azurelinux"], "tdnf install -y curl ca-certificates tar xz > /dev/null"), ], @@ -1861,19 +1862,21 @@ def test_copilot_cli_install_matches_sandbox_distro(self, mock_environment, ids, commands = [c.args[0] for c in mock_environment.execute_privileged.call_args_list] assert commands[0] == first_command - assert commands[-1] == f"npm install -g {bot_module._COPILOT_CLI_PACKAGE} > /dev/null 2>&1" + assert commands[-1] == "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1" if ids == ["azurelinux"]: assert "sha256sum -c" in commands[1] and "latest-v22.x" in commands[1] + mock_environment.execute.assert_called_once_with("copilot --version") def test_copilot_cli_install_rejects_unsupported_distro(self, mock_environment): import importlib + from microbots.tools.tool import UnsupportedDistroError bot_module = importlib.import_module("microbots.bot.CopilotBot") bot = bot_module.CopilotBot.__new__(bot_module.CopilotBot) bot.environment = mock_environment mock_environment.get_distro_ids = MagicMock(return_value=["alpine"]) - with pytest.raises(RuntimeError, match="cannot install copilot-cli on distro alpine"): + with pytest.raises(UnsupportedDistroError, match="no install commands for distro alpine"): bot._install_copilot_cli() mock_environment.execute_privileged.assert_not_called() diff --git a/test/tools/test_tool_yaml_parser.py b/test/tools/test_tool_yaml_parser.py index c758165..7c0953c 100644 --- a/test/tools/test_tool_yaml_parser.py +++ b/test/tools/test_tool_yaml_parser.py @@ -82,6 +82,23 @@ def test_azldev_yaml_shares_install_steps_across_distros(self): # azldev runs as the agent, so it needs no root override. assert not any("AZLDEV_ALLOW_ROOT" in c for c in azl + deb) + def test_copilot_cli_yaml_installs_node_and_pinned_cli_per_distro(self): + from microbots.tools.tool import UnsupportedDistroError + + tool = parse_tool_definition("copilot-cli.yaml") + assert isinstance(tool, Tool) + # Infrastructure for CopilotBot: nothing is added to the model's prompt. + assert tool.usage_instructions_to_llm == "" + assert list(tool.install_commands) == ["debian", "azurelinux"] + cli = "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1" + for distro in ("debian", "azurelinux"): + assert tool.resolve_commands("install", [distro])[-1] == cli + assert "nodesource.com/setup_22.x" in " ".join(tool.resolve_commands("install", ["debian"])) + assert "sha256sum -c" in " ".join(tool.resolve_commands("install", ["azurelinux"])) + assert tool.resolve_commands("verify", ["azurelinux"]) == ["copilot --version"] + with pytest.raises(UnsupportedDistroError): + tool.resolve_commands("install", ["alpine"]) + def test_parse_absolute_path(self, tmp_path): """Test parsing with absolute path works correctly.""" yaml_content = """ From 829f54c1517856f91448633f00cca233c660ec61 Mon Sep 17 00:00:00 2001 From: bala Date: Mon, 2 Nov 2026 11:20:36 +0000 Subject: [PATCH 3/4] build(copilot): upgrade github-copilot-sdk to 1.0.28 and copilot-cli to 1.0.105 github-copilot-sdk 1.x pins the copilot-cli release it targets (CLI_VERSION in copilot/_cli_version.py); 1.0.28 targets 1.0.105. The 0.3.0 / 1.0.39 pair is incompatible with either newer half (0.3.0 cannot parse 1.0.105 responses; 1.0.39 lacks the 1.x `connect` RPC). - Connect with CopilotClient(connection=UriRuntimeConnection(...)), which replaces ExternalServerConfig. - Import PermissionHandler from copilot.session only and SessionEventType from the public copilot.session_events module; a pre-1.x SDK now fails with an explicit upgrade message. - Cross-reference the two pins in pyproject.toml and copilot-cli.yaml, let the ghcp CI job install the `ghcp` extra instead of repeating the pin, and add test_copilot_version_pins.py to check the pins (and, under `ghcp`, that the installed SDK targets the pinned CLI). - Replace the pre-0.3.0 SDK-layout tests with 1.x import/connection tests. - Make the session-options test independent of COPILOT_BYOK_MODEL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- docs/advanced/bots/copilot-bot.md | 4 +- pyproject.toml | 5 +- src/microbots/bot/CopilotBot.py | 34 +- .../tools/tool_definitions/copilot-cli.yaml | 10 +- test/bot/test_copilot_bot.py | 342 +++++------------- test/bot/test_copilot_version_pins.py | 59 +++ test/tools/test_tool_yaml_parser.py | 2 +- 8 files changed, 176 insertions(+), 282 deletions(-) create mode 100644 test/bot/test_copilot_version_pins.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ebf76a..f0cafb4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -167,7 +167,7 @@ jobs: - name: Install GitHub Copilot SDK dependencies for GHCP tests if: matrix.test-type == 'ghcp' run: | - pip install "github-copilot-sdk==0.3.0" + pip install -e ".[ghcp]" - name: Install Azure AD / keyless auth dependencies run: | diff --git a/docs/advanced/bots/copilot-bot.md b/docs/advanced/bots/copilot-bot.md index 3fa3a7c..20a07fa 100644 --- a/docs/advanced/bots/copilot-bot.md +++ b/docs/advanced/bots/copilot-bot.md @@ -16,10 +16,10 @@ CopilotBot is a Microbots agent that delegates the entire agent loop to the **Gi pip install microbots[ghcp] ``` -This installs the `github-copilot-sdk` package alongside Microbots. +This installs the `github-copilot-sdk` package (1.x) alongside Microbots. !!! note - You do **not** need to install `copilot-cli` on your host machine. Microbots automatically installs and runs it inside the Docker container during initialization. + You do **not** need to install `copilot-cli` on your host machine. Microbots automatically installs and runs it inside the Docker container during initialization, using the `copilot-cli` version that the pinned `github-copilot-sdk` release targets. ## Quick Start diff --git a/pyproject.toml b/pyproject.toml index 77f746e..6766ff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,10 @@ classifiers = [ requires-python = ">=3.11" [project.optional-dependencies] -ghcp = ["github-copilot-sdk==0.3.0"] +# Each github-copilot-sdk release targets one copilot-cli version (CLI_VERSION in the +# SDK's copilot/_cli_version.py). When upgrading, also update the @github/copilot pin +# in src/microbots/tools/tool_definitions/copilot-cli.yaml. +ghcp = ["github-copilot-sdk==1.0.28"] azure_ad = ["azure-identity>=1.15.0"] dev = ["pre-commit>=3.7", "numpydoc>=1.8"] training = ["datasets==5.0.1", "swebench==4.1.0"] diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index 31d5657..b0c426b 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -21,7 +21,7 @@ CopilotBot copilot-cli --headless --port

│ │ ├── Copilot SDK ──TCP──────────┘ - │ (ExternalServerConfig) + │ (UriRuntimeConnection) │ ├── additional tools │ (define_tool → SDK session) @@ -417,12 +417,21 @@ def __init__( session_options: Optional[Mapping[str, Any]] = None, ): try: - from copilot import CopilotClient, ExternalServerConfig + import copilot # noqa: F401 except ImportError: raise ImportError( "CopilotBot requires the github-copilot-sdk package. " "Install with: pip install microbots[ghcp]" ) + try: + from copilot import CopilotClient, UriRuntimeConnection + from copilot.session import PermissionHandler + except ImportError as exc: + raise ImportError( + "CopilotBot requires github-copilot-sdk 1.x (UriRuntimeConnection, " + "copilot.session.PermissionHandler). " + "Upgrade with: pip install -U microbots[ghcp]" + ) from exc self._sdk_create_session = CopilotClient.create_session self.session_options = _validate_session_options( @@ -437,23 +446,6 @@ def __init__( ) self.image = image or DEFAULT_IMAGE - # ``PermissionHandler`` lives in ``copilot.session`` in - # github-copilot-sdk >= 0.3.0 and in ``copilot.types`` in older - # releases. Try the new location first and fall back to the old - # one so we work with both. - try: - from copilot.session import PermissionHandler - except ImportError: - try: - from copilot.types import PermissionHandler - except ImportError as exc: - raise ImportError( - "CopilotBot could not locate 'PermissionHandler' in the " - "installed github-copilot-sdk (looked in copilot.session " - "and copilot.types). Please install a compatible version, " - "e.g. pip install -U microbots[ghcp]." - ) from exc - if ( environment is not None and sudo_access @@ -520,7 +512,7 @@ def __init__( container_ip = self.environment.get_ipv4_address() logger.info("🔌 Connecting Copilot SDK to CLI server at %s:%d", container_ip, _CONTAINER_CLI_PORT) self._client = CopilotClient( - ExternalServerConfig(url=f"{container_ip}:{_CONTAINER_CLI_PORT}") + connection=UriRuntimeConnection(url=f"{container_ip}:{_CONTAINER_CLI_PORT}") ) self._run_async(self._client.start()) self._PermissionHandler = PermissionHandler @@ -750,7 +742,7 @@ async def _execute_session( session_options: Optional[Mapping[str, Any]] = None, ) -> str: """Create a Copilot session, send the task, and collect the result.""" - from copilot.generated.session_events import SessionEventType + from copilot.session_events import SessionEventType session_kwargs = { "model": self.model, diff --git a/src/microbots/tools/tool_definitions/copilot-cli.yaml b/src/microbots/tools/tool_definitions/copilot-cli.yaml index 015aa98..16d9e72 100644 --- a/src/microbots/tools/tool_definitions/copilot-cli.yaml +++ b/src/microbots/tools/tool_definitions/copilot-cli.yaml @@ -7,10 +7,10 @@ description: GitHub Copilot CLI, which CopilotBot runs as a headless server insi # and has no usage instructions. usage_instructions_to_llm: "" -# copilot-cli needs Node.js 22+. Keep the CLI version in step with the -# github-copilot-sdk version pinned by the `ghcp` extra in pyproject.toml: -# 1.0.39 is the latest @github/copilot release compatible with -# github-copilot-sdk==0.3.0 (as of May 2026). +# copilot-cli needs Node.js 22+. The CLI version must match the github-copilot-sdk +# pinned by the `ghcp` extra in pyproject.toml: github-copilot-sdk==1.0.28 targets +# @github/copilot 1.0.105 (CLI_VERSION in the SDK's copilot/_cli_version.py). +# When upgrading, also update the `ghcp` pin in pyproject.toml. install_commands: debian: # Remove stale third-party repos that may have expired GPG keys. @@ -18,7 +18,7 @@ install_commands: - apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 - apt-get install -y -qq nodejs > /dev/null 2>&1 - - &install_cli "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1" + - &install_cli "npm install -g @github/copilot@1.0.105 > /dev/null 2>&1" azurelinux: - tdnf install -y curl ca-certificates tar xz > /dev/null # Azure Linux 3 only packages Node.js 20: install the official Node.js 22 diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index 70471ec..1deec40 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -26,7 +26,7 @@ # --------------------------------------------------------------------------- _mock_copilot = MagicMock() _mock_copilot.CopilotClient = MagicMock -_mock_copilot.ExternalServerConfig = MagicMock +_mock_copilot.UriRuntimeConnection = MagicMock _mock_session = MagicMock() _mock_session.PermissionRequestResult = MagicMock @@ -45,15 +45,10 @@ _mock_tools.ToolResult = MagicMock _mock_tools.define_tool = MagicMock -_mock_types = MagicMock() -_mock_types.PermissionHandler = MagicMock() -_mock_types.PermissionHandler.approve_all = MagicMock() - sys.modules.setdefault("copilot", _mock_copilot) sys.modules.setdefault("copilot.session", _mock_session) -sys.modules.setdefault("copilot.generated.session_events", _mock_events) +sys.modules.setdefault("copilot.session_events", _mock_events) sys.modules.setdefault("copilot.tools", _mock_tools) -sys.modules.setdefault("copilot.types", _mock_types) # Reload to pick up mock if "microbots.bot.CopilotBot" in sys.modules: @@ -76,77 +71,6 @@ def _restore_real_copilot_modules(): del sys.modules["microbots.bot.CopilotBot"] -def _reinstall_default_copilot_mocks(): - """Reinstall the module-level copilot mocks and reload CopilotBot. - - Used to undo a temporary swap performed by ``_swap_copilot_sdk_layout`` - so subsequent tests in this file see the original mock fixtures. - """ - sys.modules["copilot"] = _mock_copilot - sys.modules["copilot.session"] = _mock_session - sys.modules["copilot.generated.session_events"] = _mock_events - sys.modules["copilot.tools"] = _mock_tools - sys.modules["copilot.types"] = _mock_types - if "microbots.bot.CopilotBot" in sys.modules: - importlib.reload(sys.modules["microbots.bot.CopilotBot"]) - - -@contextlib.contextmanager -def _swap_copilot_sdk_layout(*, session_has_handler: bool, types_has_handler: bool): - """Temporarily install fresh stub modules for ``copilot.session`` and - ``copilot.types`` to simulate a specific SDK layout, then reload - ``microbots.bot.CopilotBot`` so the new import logic re-runs. - - ``session_has_handler``/``types_has_handler`` control whether - ``PermissionHandler`` is exposed on the corresponding module. The - returned tuple yields the two ``PermissionHandler`` sentinels (or - ``None`` when not present) so tests can assert which one was picked. - """ - # Build a stub ``copilot`` package that still exposes CopilotClient and - # ExternalServerConfig so the first try/except in __init__ succeeds. - stub_copilot = types.ModuleType("copilot") - stub_copilot.CopilotClient = _mock_copilot.CopilotClient - stub_copilot.ExternalServerConfig = _mock_copilot.ExternalServerConfig - - stub_session = types.ModuleType("copilot.session") - stub_session.PermissionRequestResult = MagicMock - session_handler = MagicMock(name="session.PermissionHandler") if session_has_handler else None - if session_handler is not None: - session_handler.approve_all = MagicMock(name="session.approve_all") - stub_session.PermissionHandler = session_handler - - stub_types = types.ModuleType("copilot.types") - types_handler = MagicMock(name="types.PermissionHandler") if types_has_handler else None - if types_handler is not None: - types_handler.approve_all = MagicMock(name="types.approve_all") - stub_types.PermissionHandler = types_handler - - saved = { - name: sys.modules.get(name) - for name in ("copilot", "copilot.session", "copilot.types") - } - sys.modules["copilot"] = stub_copilot - sys.modules["copilot.session"] = stub_session - sys.modules["copilot.types"] = stub_types - # Other submodules (generated.session_events, tools) keep the existing - # module-level mocks so CopilotBot's other imports still work. - try: - if "microbots.bot.CopilotBot" in sys.modules: - importlib.reload(sys.modules["microbots.bot.CopilotBot"]) - yield session_handler, types_handler - finally: - for name, mod in saved.items(): - if mod is None: - sys.modules.pop(name, None) - else: - sys.modules[name] = mod - _reinstall_default_copilot_mocks() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - def _copilot_sdk_installed(): try: from importlib.metadata import version @@ -234,7 +158,7 @@ def copilot_bot(mock_environment, mock_copilot_client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -286,7 +210,7 @@ def test_rejects_external_tool(self, mock_environment, mock_copilot_client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot with pytest.raises(ValueError, match="does not support ExternalTool"): @@ -314,90 +238,44 @@ def test_import_error_without_sdk(self): if "microbots.bot.CopilotBot" in sys.modules: importlib.reload(sys.modules["microbots.bot.CopilotBot"]) - # ────────────────────────────────────────────────────────────────── - # PermissionHandler import path — covers the new logic in __init__ - # that supports both pre-0.3.0 and >=0.3.0 github-copilot-sdk layouts. - # ────────────────────────────────────────────────────────────────── + def test_import_error_with_pre_1x_sdk(self, mock_environment, mock_copilot_client): + """An installed SDK without ``UriRuntimeConnection`` (0.x) raises an + ImportError asking for an upgrade instead of failing later.""" + old_sdk = types.ModuleType("copilot") + old_sdk.CopilotClient = MagicMock + old_sdk.ExternalServerConfig = MagicMock + saved = sys.modules.get("copilot") + try: + sys.modules["copilot"] = old_sdk + from microbots.bot.CopilotBot import CopilotBot as CB + with patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment): + with pytest.raises(ImportError, match="github-copilot-sdk 1.x"): + CB(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + finally: + sys.modules["copilot"] = saved - def _build_bot_under_swap(self, mock_environment, mock_copilot_client): - """Construct a CopilotBot using the reloaded module under the - currently-active ``_swap_copilot_sdk_layout`` context.""" - from microbots.bot.CopilotBot import CopilotBot + def test_client_connects_to_container_cli_via_uri(self, mock_environment, mock_copilot_client): + """The SDK client attaches to the in-container CLI server through a + ``UriRuntimeConnection`` pointing at the container IP and CLI port.""" + from microbots.bot.CopilotBot import _CONTAINER_CLI_PORT, CopilotBot with ( patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), - patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), - ): - bot = CopilotBot( - model="gpt-4.1", - environment=mock_environment, - github_token="ghp_test", - ) - return bot - - def test_permission_handler_loaded_from_session( - self, mock_environment, mock_copilot_client - ): - """SDK >= 0.3.0: ``PermissionHandler`` is loaded from copilot.session - and is preferred over copilot.types when both expose it.""" - with _swap_copilot_sdk_layout( - session_has_handler=True, types_has_handler=True - ) as (session_handler, types_handler): - bot = self._build_bot_under_swap(mock_environment, mock_copilot_client) - try: - assert bot._PermissionHandler is session_handler - assert bot._PermissionHandler is not types_handler - finally: - bot._loop.call_soon_threadsafe(bot._loop.stop) - bot._thread.join(timeout=2) - bot.environment = None - - def test_permission_handler_falls_back_to_types( - self, mock_environment, mock_copilot_client - ): - """Pre-0.3.0 SDK: ``PermissionHandler`` is absent from copilot.session - and the import falls back to copilot.types.""" - with _swap_copilot_sdk_layout( - session_has_handler=False, types_has_handler=True - ) as (session_handler, types_handler): - assert session_handler is None - bot = self._build_bot_under_swap(mock_environment, mock_copilot_client) - try: - assert bot._PermissionHandler is types_handler - finally: - bot._loop.call_soon_threadsafe(bot._loop.stop) - bot._thread.join(timeout=2) - bot.environment = None - - def test_import_error_when_permission_handler_missing_everywhere( - self, mock_environment, mock_copilot_client - ): - """If neither copilot.session nor copilot.types exposes - ``PermissionHandler`` we raise a distinct, accurate ImportError - (and not the misleading "github-copilot-sdk is not installed" one).""" - with _swap_copilot_sdk_layout( - session_has_handler=False, types_has_handler=False + patch("copilot.CopilotClient", return_value=mock_copilot_client) as client_cls, + patch("copilot.UriRuntimeConnection") as connection_cls, ): - from microbots.bot.CopilotBot import CopilotBot - with pytest.raises(ImportError, match="PermissionHandler"): - with ( - patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), - patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), - patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), - patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), - patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), - patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), - ): - CopilotBot( - model="gpt-4.1", - environment=mock_environment, - github_token="ghp_test", - ) + bot = CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + try: + connection_cls.assert_called_once_with(url=f"172.17.0.2:{_CONTAINER_CLI_PORT}") + client_cls.assert_called_once_with(connection=connection_cls.return_value) + mock_copilot_client.start.assert_awaited_once() + finally: + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None @pytest.mark.unit @@ -448,7 +326,7 @@ def test_system_message_includes_mount_path(self, mock_environment, mock_copilot patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot from microbots.extras.mount import Mount @@ -497,7 +375,7 @@ def test_install_cli_calls_execute(self, mock_environment): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=AsyncMock()), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): bot = CopilotBot( model="gpt-4.1", @@ -530,7 +408,7 @@ def test_install_cli_raises_on_failure(self, mock_environment): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=AsyncMock()), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): with pytest.raises(RuntimeError, match="Failed to install tool copilot-cli"): CopilotBot( @@ -779,7 +657,7 @@ def test_byok_api_key_sets_provider_config(self, mock_environment, mock_copilot_ patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -801,7 +679,7 @@ def test_byok_token_provider_sets_provider_config(self, mock_environment, mock_c patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -833,7 +711,7 @@ def _standard_init_patches(mock_environment, mock_copilot_client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ) @@ -854,7 +732,7 @@ def test_folder_to_mount_creates_mount_object(self, mock_environment, mock_copil patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -880,7 +758,7 @@ def test_auto_creates_environment_when_none(self, mock_environment, mock_copilot patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -917,7 +795,7 @@ def test_non_external_tool_install_and_verify_called( patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -966,7 +844,7 @@ def side_effect(cmd, **kwargs): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=AsyncMock()), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): with pytest.raises(RuntimeError, match="Failed to verify installation of tool copilot-cli"): CopilotBot( @@ -992,7 +870,7 @@ def _make_bot_for_server_test(self, mock_environment, mock_copilot_client, githu patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot if github_token: @@ -1060,7 +938,7 @@ def side_effect(cmd, **kwargs): patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot with pytest.raises(RuntimeError, match="Failed to start copilot-cli server"): @@ -1177,7 +1055,7 @@ def test_execute_session_includes_provider_config(self, mock_environment, mock_c patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1219,7 +1097,7 @@ def test_execute_session_includes_system_message(self, mock_environment, mock_co patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1269,10 +1147,10 @@ def capture_on(callback): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot - from copilot.generated.session_events import SessionEventType + from copilot.session_events import SessionEventType bot = CopilotBot( model="gpt-4.1", @@ -1325,7 +1203,7 @@ def test_execute_session_returns_fallback_when_no_content( patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1400,7 +1278,7 @@ async def _send_and_wait_with_events(task, timeout): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1522,29 +1400,20 @@ def test_mount_additional_copy_succeeds(self, copilot_bot): # --------------------------------------------------------------------------- -# Integration tests — SDK compatibility (both pre-0.3.0 and >=0.3.0 layouts) +# Integration-level test — full ``CopilotBot.__init__`` → ``run()`` path # -# These exercise the full ``CopilotBot.__init__`` → ``run()`` → assistant- -# response path against simulated SDK module layouts so we can verify *both* -# supported ``github-copilot-sdk`` shapes in a single test run (only one -# version of the real SDK can be installed at a time). External I/O (Docker, -# CLI startup, network) is stubbed out; everything else — the new -# PermissionHandler import branch, the asyncio loop, session creation, -# event handling, result extraction — runs for real. +# External I/O (Docker, CLI startup, network) is stubbed out; everything +# else — the asyncio loop, session creation, event handling, result +# extraction — runs for real against the github-copilot-sdk 1.x API. # --------------------------------------------------------------------------- @pytest.mark.unit -class TestCopilotBotSDKLayoutIntegration: - """Integration-level tests that drive a full ``run()`` against each - supported SDK module layout.""" - - def _run_bot_with_layout(self, *, session_has_handler, types_has_handler): - """Build and run a CopilotBot under a simulated SDK layout. +class TestCopilotBotSDKIntegration: + """Drives a full ``run()`` through the SDK 1.x client/session API.""" - Returns ``(result, bot, session_handler, types_handler)`` where - ``result`` is the ``BotRunResult`` from ``bot.run()``. - """ + def _run_bot(self): + """Build and run a CopilotBot; return ``(result, bot, client)``.""" # Mock Docker environment env = MagicMock() env.port = 9000 @@ -1577,66 +1446,37 @@ def _run_bot_with_layout(self, *, session_has_handler, types_has_handler): client.stop = AsyncMock() client.create_session = AsyncMock(return_value=session) - with _swap_copilot_sdk_layout( - session_has_handler=session_has_handler, - types_has_handler=types_has_handler, - ) as (session_handler, types_handler): - from microbots.bot.CopilotBot import CopilotBot - with ( - patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=env), - patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), - patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), - patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), - patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), - patch("copilot.CopilotClient", return_value=client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), - ): - bot = CopilotBot( - model="gpt-4.1", - environment=env, - github_token="ghp_test", - ) - try: - result = bot.run("Do the thing") - finally: - # Tear down the asyncio loop thread cleanly - bot._loop.call_soon_threadsafe(bot._loop.stop) - bot._thread.join(timeout=2) - bot.environment = None - return result, bot, session_handler, types_handler, client - - def test_run_with_new_sdk_layout_session(self): - """SDK >= 0.3.0: PermissionHandler lives in ``copilot.session``. - - Verifies CopilotBot initialises, picks up the handler from - ``copilot.session``, and ``run()`` completes successfully passing - that handler as ``on_permission_request``.""" - result, bot, session_handler, types_handler, client = self._run_bot_with_layout( - session_has_handler=True, types_has_handler=False - ) - assert types_handler is None - assert bot._PermissionHandler is session_handler - assert result.status is True, f"run() failed: {result.error}" - assert result.error is None - # The new-SDK handler must be the one wired into the session. - kwargs = client.create_session.await_args.kwargs - assert kwargs["on_permission_request"] is session_handler.approve_all - - def test_run_with_old_sdk_layout_types(self): - """Pre-0.3.0 SDK: PermissionHandler lives in ``copilot.types``. + from microbots.bot.CopilotBot import CopilotBot + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=env), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=client), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), + ): + bot = CopilotBot( + model="gpt-4.1", + environment=env, + github_token="ghp_test", + ) + try: + result = bot.run("Do the thing") + finally: + # Tear down the asyncio loop thread cleanly + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None + return result, bot, client - Verifies CopilotBot falls back to ``copilot.types`` and ``run()`` - still completes successfully wiring that handler through to the - session.""" - result, bot, session_handler, types_handler, client = self._run_bot_with_layout( - session_has_handler=False, types_has_handler=True - ) - assert session_handler is None - assert bot._PermissionHandler is types_handler + def test_run_completes_and_wires_permission_handler(self): + result, bot, client = self._run_bot() assert result.status is True, f"run() failed: {result.error}" assert result.error is None + assert result.result == "Task completed successfully." kwargs = client.create_session.await_args.kwargs - assert kwargs["on_permission_request"] is types_handler.approve_all + assert kwargs["on_permission_request"] is bot._PermissionHandler.approve_all # --------------------------------------------------------------------------- @@ -1662,7 +1502,7 @@ def _bot_dependencies(mock_environment, client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", new=client_cls), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot yield CopilotBot, lde @@ -1732,7 +1572,7 @@ def test_options_forwarded_and_run_overrides_take_precedence( kwargs = mock_copilot_client.create_session.call_args.kwargs assert kwargs["skill_directories"] == ["/workdir/repo/.agents/skills"] assert kwargs["working_directory"] == "/workdir/other" - assert kwargs["model"] == "gpt-4.1" + assert kwargs["model"] == bot.model # Per-run overrides do not leak into the bot's defaults. assert bot.session_options["working_directory"] == "/workdir/repo" @@ -1862,7 +1702,7 @@ def test_copilot_cli_install_matches_sandbox_distro(self, mock_environment, ids, commands = [c.args[0] for c in mock_environment.execute_privileged.call_args_list] assert commands[0] == first_command - assert commands[-1] == "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1" + assert commands[-1] == "npm install -g @github/copilot@1.0.105 > /dev/null 2>&1" if ids == ["azurelinux"]: assert "sha256sum -c" in commands[1] and "latest-v22.x" in commands[1] mock_environment.execute.assert_called_once_with("copilot --version") @@ -1894,7 +1734,7 @@ def _root_bot_dependencies(mock_environment, client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot yield CopilotBot, lde diff --git a/test/bot/test_copilot_version_pins.py b/test/bot/test_copilot_version_pins.py new file mode 100644 index 0000000..01205a5 --- /dev/null +++ b/test/bot/test_copilot_version_pins.py @@ -0,0 +1,59 @@ +""" +Keep the github-copilot-sdk pin (``ghcp`` extra in pyproject.toml) and the +copilot-cli pin (copilot-cli.yaml) in step: each SDK release targets one CLI +version, recorded as ``CLI_VERSION`` in the SDK's ``copilot/_cli_version.py``. +""" + +import re +import tomllib +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path + +import pytest +import yaml + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PYPROJECT = _REPO_ROOT / "pyproject.toml" +_CLI_YAML = _REPO_ROOT / "src/microbots/tools/tool_definitions/copilot-cli.yaml" + + +def _sdk_pin() -> str: + extras = tomllib.loads(_PYPROJECT.read_text())["project"]["optional-dependencies"] + (requirement,) = extras["ghcp"] + match = re.fullmatch(r"github-copilot-sdk==(\d+\.\d+\.\d+)", requirement) + assert match, f"ghcp extra must pin an exact github-copilot-sdk version, got {requirement!r}" + return match.group(1) + + +def _cli_pins() -> set[str]: + install_commands = yaml.safe_load(_CLI_YAML.read_text())["install_commands"] + pins = set() + for commands in install_commands.values(): + for command in commands: + pins.update(re.findall(r"@github/copilot@(\S+)", command)) + return pins + + +@pytest.mark.unit +def test_sdk_and_cli_are_pinned_to_exact_versions(): + _sdk_pin() + pins = _cli_pins() + assert len(pins) == 1, f"every distro must install the same copilot-cli version, got {pins}" + assert re.fullmatch(r"\d+\.\d+\.\d+", pins.pop()) + + +@pytest.mark.ghcp +def test_installed_sdk_matches_pins(): + try: + sdk = distribution("github-copilot-sdk") + except PackageNotFoundError: + pytest.skip("github-copilot-sdk not installed (pip install microbots[ghcp])") + + assert sdk.version == _sdk_pin() + cli_version_file = Path(sdk.locate_file("copilot/_cli_version.py")) + match = re.search(r'^CLI_VERSION\b[^=\n]*=\s*"([^"]+)"', cli_version_file.read_text(), re.M) + assert match, f"CLI_VERSION not found in {cli_version_file}" + assert _cli_pins() == {match.group(1)}, ( + f"github-copilot-sdk {sdk.version} targets copilot-cli {match.group(1)}; " + "update the @github/copilot pin in copilot-cli.yaml" + ) diff --git a/test/tools/test_tool_yaml_parser.py b/test/tools/test_tool_yaml_parser.py index 7c0953c..59fdb5a 100644 --- a/test/tools/test_tool_yaml_parser.py +++ b/test/tools/test_tool_yaml_parser.py @@ -90,7 +90,7 @@ def test_copilot_cli_yaml_installs_node_and_pinned_cli_per_distro(self): # Infrastructure for CopilotBot: nothing is added to the model's prompt. assert tool.usage_instructions_to_llm == "" assert list(tool.install_commands) == ["debian", "azurelinux"] - cli = "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1" + cli = "npm install -g @github/copilot@1.0.105 > /dev/null 2>&1" for distro in ("debian", "azurelinux"): assert tool.resolve_commands("install", [distro])[-1] == cli assert "nodesource.com/setup_22.x" in " ".join(tool.resolve_commands("install", ["debian"])) From f6b8a995c4db401594ab58d10ac9279977c6c66e Mon Sep 17 00:00:00 2001 From: bala Date: Mon, 2 Nov 2026 11:48:46 +0000 Subject: [PATCH 4/4] feat(copilot): add model_id for BYOK deployments with custom names copilot-cli 1.0.105 picks request settings (temperature, reasoning_effort, prompts, token limits) from the model name. For a BYOK deployment with a custom name, such as CI's Azure `mini-swe-agent-gpt5`, it falls back to generic defaults and sends temperature 0, which GPT-5 rejects. - New `model_id` parameter and COPILOT_BYOK_MODEL_ID env var, mapped to the SDK's provider `model_id`; the deployment name is kept as `wire_model`. - ghcp CI job: use the Responses wire API and COPILOT_BYOK_MODEL_ID=gpt-5. - Docs and unit tests for the new option. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test.yml | 5 +- docs/advanced/bots/copilot-bot.md | 18 ++++++++ src/microbots/bot/CopilotBot.py | 33 +++++++++++++- test/bot/test_copilot_bot.py | 76 +++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0cafb4..f6bbc2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -238,7 +238,10 @@ jobs: COPILOT_BYOK_PROVIDER_TYPE: ${{ matrix.test-type == 'ghcp' && 'azure' || '' }} COPILOT_BYOK_MODEL: ${{ matrix.test-type == 'ghcp' && vars.AZURE_OPENAI_DEPLOYMENT_NAME || '' }} COPILOT_BYOK_AZURE_API_VERSION: ${{ matrix.test-type == 'ghcp' && vars.AZURE_OPENAI_API_VERSION || '' }} - COPILOT_BYOK_WIRE_API: ${{ matrix.test-type == 'ghcp' && 'completions' || '' }} + COPILOT_BYOK_WIRE_API: ${{ matrix.test-type == 'ghcp' && 'responses' || '' }} + # The deployment name (AZURE_OPENAI_DEPLOYMENT_NAME) is not a model copilot-cli + # knows: tell it the model behind it so it sends gpt-5-compatible requests. + COPILOT_BYOK_MODEL_ID: ${{ matrix.test-type == 'ghcp' && 'gpt-5' || '' }} run: | python -m pytest ${{ matrix.pytest-args }} \ -n auto \ diff --git a/docs/advanced/bots/copilot-bot.md b/docs/advanced/bots/copilot-bot.md index 20a07fa..3c5ffa5 100644 --- a/docs/advanced/bots/copilot-bot.md +++ b/docs/advanced/bots/copilot-bot.md @@ -132,6 +132,22 @@ bot = CopilotBot( ) ``` +#### Using `model_id` for custom deployment names + +copilot-cli adjusts its requests to the model, for example whether it sends `temperature` or `reasoning_effort`. It recognises the model by name. When a BYOK deployment has a custom name (common on Azure), set `model_id` to the model behind it. Otherwise the CLI falls back to generic defaults that reasoning models such as GPT-5 reject (`'temperature' does not support 0 with this model`): + +```python +bot = CopilotBot( + model="my-gpt5-deployment", # name sent to the provider + model_id="gpt-5", # model behind the deployment + folder_to_mount="/path/to/project", + bearer_token=token, + base_url="https://my-resource.openai.azure.com", + provider_type="azure", + wire_api="responses", +) +``` + ### 3. BYOK — Bearer Token If your provider uses bearer token authentication instead of an API key: @@ -181,6 +197,7 @@ export COPILOT_BYOK_BASE_URL="https://api.openai.com/v1" export COPILOT_BYOK_API_KEY="sk-your-key" export COPILOT_BYOK_PROVIDER_TYPE="openai" # optional, defaults to "openai" export COPILOT_BYOK_MODEL="gpt-4.1" # optional, overrides the model param +export COPILOT_BYOK_MODEL_ID="gpt-4.1" # optional, model behind a custom deployment name export COPILOT_BYOK_WIRE_API="completions" # optional export COPILOT_BYOK_AZURE_API_VERSION="2024-10-21" # optional, for Azure only ``` @@ -225,6 +242,7 @@ When multiple auth methods are configured simultaneously, CopilotBot resolves th | `wire_api` | `str` | `None` | API format: `"completions"` or `"responses"`. | | `azure_api_version` | `str` | `None` | Azure API version (for `provider_type="azure"` only). | | `token_provider` | `Callable[[], str]` | `None` | Callable returning a bearer token string. | +| `model_id` | `str` | `None` | BYOK only: the model behind a custom deployment name, e.g. `"gpt-5"`. Falls back to `COPILOT_BYOK_MODEL_ID`. See [Using `model_id`](#using-model_id-for-custom-deployment-names). | | `sudo_access` | `bool` | `False` | Give the agent passwordless `sudo` inside the sandbox. See [Sudo access](../sandbox-images.md#sudo-access). | | `image` | `str` | `None` | Shell-server image of the auto-created sandbox, e.g. `AZURELINUX_IMAGE`. See [Sandbox Images](../sandbox-images.md). | | `session_options` | `Mapping[str, Any]` | `None` | Extra keyword arguments forwarded to the SDK's `create_session` on every run. See [Copilot SDK session options](#copilot-sdk-session-options). | diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index b0c426b..cd46871 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -146,6 +146,7 @@ def _validate_session_options( _BYOK_ENV_WIRE_API = "COPILOT_BYOK_WIRE_API" # completions / responses _BYOK_ENV_AZURE_API_VERSION = "COPILOT_BYOK_AZURE_API_VERSION" _BYOK_ENV_MODEL = "COPILOT_BYOK_MODEL" +_BYOK_ENV_MODEL_ID = "COPILOT_BYOK_MODEL_ID" def resolve_auth_config( @@ -158,6 +159,7 @@ def resolve_auth_config( wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, token_provider: Optional[Callable[[], str]] = None, + model_id: Optional[str] = None, ) -> tuple[str, Optional[str], Optional[dict]]: """Resolve authentication and provider configuration for CopilotBot. @@ -193,6 +195,12 @@ def resolve_auth_config( token provider). The token is fetched once at config resolution time. For long-running sessions, create a new session with a refreshed token. + model_id : Optional[str] + Well-known model behind a BYOK deployment (e.g. ``"gpt-5"`` for an + Azure deployment named ``"my-gpt5"``). copilot-cli uses it to pick + the model's prompts, tools, reasoning behaviour and token limits, + while ``model`` is still sent to the provider as the wire model. + Falls back to ``COPILOT_BYOK_MODEL_ID``. Returns ------- @@ -221,6 +229,8 @@ def resolve_auth_config( bearer_token=bearer_token, wire_api=wire_api, azure_api_version=azure_api_version, + model=model, + model_id=model_id or os.environ.get(_BYOK_ENV_MODEL_ID), ) logger.info("🔑 BYOK auth resolved via explicit credentials (type=%s)", provider["type"]) return model, None, provider @@ -239,6 +249,8 @@ def resolve_auth_config( bearer_token=env_bearer_token, wire_api=os.environ.get(_BYOK_ENV_WIRE_API), azure_api_version=os.environ.get(_BYOK_ENV_AZURE_API_VERSION), + model=env_model, + model_id=model_id or os.environ.get(_BYOK_ENV_MODEL_ID), ) logger.info("🔑 BYOK auth resolved via environment variables (type=%s)", provider["type"]) return env_model, None, provider @@ -275,15 +287,18 @@ def resolve_auth_config( if not isinstance(token, str) or not token: raise ValueError("token_provider must return a non-empty string token.") + resolved_model = os.environ.get(_BYOK_ENV_MODEL, model) provider = _build_provider_config( provider_type=provider_type or os.environ.get(_BYOK_ENV_PROVIDER_TYPE, "openai"), base_url=resolved_url, bearer_token=token, wire_api=wire_api or os.environ.get(_BYOK_ENV_WIRE_API), azure_api_version=azure_api_version or os.environ.get(_BYOK_ENV_AZURE_API_VERSION), + model=resolved_model, + model_id=model_id or os.environ.get(_BYOK_ENV_MODEL_ID), ) logger.info("🔑 BYOK auth resolved via token_provider (type=%s)", provider["type"]) - return os.environ.get(_BYOK_ENV_MODEL, model), None, provider + return resolved_model, None, provider # ── 4. Native GitHub Copilot auth ──────────────────────────────── resolved_github_token = ( @@ -304,6 +319,8 @@ def _build_provider_config( bearer_token: Optional[str] = None, wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, + model: Optional[str] = None, + model_id: Optional[str] = None, ) -> dict: """Build the ``provider`` dict accepted by ``create_session``.""" config: dict = { @@ -322,6 +339,12 @@ def _build_provider_config( if provider_type == "azure" and azure_api_version: config["azure"] = {"api_version": azure_api_version} + if model_id: + config["model_id"] = model_id + # The CLI sends model_id on the wire unless wire_model is set. + if model: + config["wire_model"] = model + return config @@ -371,6 +394,12 @@ class CopilotBot: token_provider : Optional[Callable[[], str]] A callable returning a bearer token (e.g. Azure AD token provider). Requires ``base_url``. + model_id : Optional[str] + For BYOK, the well-known model behind a custom deployment name + (e.g. ``"gpt-5"`` for a deployment named ``"my-gpt5"``), so + copilot-cli applies that model's request settings such as reasoning + and temperature. ``model`` is still the name sent to the provider. + Falls back to ``COPILOT_BYOK_MODEL_ID``. sudo_access : bool Give the agent passwordless ``sudo`` inside the sandbox. Needed by tools such as ``mock``. Commands still run as the host user unless @@ -412,6 +441,7 @@ def __init__( wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, token_provider: Optional[Callable[[], str]] = None, + model_id: Optional[str] = None, sudo_access: bool = False, image: Optional[str] = None, session_options: Optional[Mapping[str, Any]] = None, @@ -470,6 +500,7 @@ def __init__( wire_api=wire_api, azure_api_version=azure_api_version, token_provider=token_provider, + model_id=model_id, ) # ── Mount setup ───────────────────────────────────────────── diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index 1deec40..11ec13b 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -484,6 +484,60 @@ def test_wire_api_included_when_set(self): ) assert provider["wire_api"] == "responses" + def test_model_id_absent_by_default(self, monkeypatch): + from microbots.bot.CopilotBot import resolve_auth_config + + monkeypatch.delenv("COPILOT_BYOK_MODEL_ID", raising=False) + _, _, provider = resolve_auth_config( + model="my-deployment", api_key="key", base_url="https://endpoint.com/v1" + ) + assert "model_id" not in provider + assert "wire_model" not in provider + + def test_model_id_sets_wire_model_to_deployment(self): + """``model_id`` names the model for the CLI; the deployment stays the wire model.""" + from microbots.bot.CopilotBot import resolve_auth_config + + model, _, provider = resolve_auth_config( + model="my-gpt5-deployment", + api_key="key", + base_url="https://endpoint.com/v1", + model_id="gpt-5", + ) + assert model == "my-gpt5-deployment" + assert provider["model_id"] == "gpt-5" + assert provider["wire_model"] == "my-gpt5-deployment" + + def test_model_id_from_env_with_env_credentials(self, monkeypatch): + from microbots.bot.CopilotBot import resolve_auth_config + + monkeypatch.setenv("COPILOT_BYOK_BASE_URL", "https://env-endpoint.com/v1") + monkeypatch.setenv("COPILOT_BYOK_API_KEY", "env-key") + monkeypatch.setenv("COPILOT_BYOK_MODEL", "env-deployment") + monkeypatch.setenv("COPILOT_BYOK_MODEL_ID", "gpt-5") + + model, _, provider = resolve_auth_config(model="gpt-4.1") + assert model == "env-deployment" + assert provider["model_id"] == "gpt-5" + assert provider["wire_model"] == "env-deployment" + + def test_model_id_with_token_provider(self, monkeypatch): + from microbots.bot.CopilotBot import resolve_auth_config + + for var in ("COPILOT_BYOK_BASE_URL", "COPILOT_BYOK_API_KEY", + "COPILOT_BYOK_BEARER_TOKEN", "COPILOT_BYOK_MODEL", "COPILOT_BYOK_MODEL_ID"): + monkeypatch.delenv(var, raising=False) + model, _, provider = resolve_auth_config( + model="my-deployment", + base_url="https://endpoint.com/v1", + token_provider=lambda: "token", + model_id="gpt-5", + ) + assert model == "my-deployment" + assert provider["bearer_token"] == "token" + assert provider["model_id"] == "gpt-5" + assert provider["wire_model"] == "my-deployment" + def test_env_vars_resolve_byok(self, monkeypatch): from microbots.bot.CopilotBot import resolve_auth_config @@ -693,6 +747,28 @@ def test_byok_token_provider_sets_provider_config(self, mock_environment, mock_c assert bot.github_token is None bot.stop() + def test_byok_model_id_forwarded_to_provider_config(self, mock_environment, mock_copilot_client): + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=mock_copilot_client), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), + ): + from microbots.bot.CopilotBot import CopilotBot + bot = CopilotBot( + model="my-gpt5-deployment", + environment=mock_environment, + api_key="sk-byok-key", + base_url="https://api.openai.com/v1", + model_id="gpt-5", + ) + assert bot._provider_config["model_id"] == "gpt-5" + assert bot._provider_config["wire_model"] == "my-gpt5-deployment" + bot.stop() + def test_native_auth_has_no_provider_config(self, copilot_bot): assert copilot_bot._provider_config is None assert copilot_bot.github_token == "ghp_test_token_123"