diff --git a/MIGRATION.md b/MIGRATION.md index 0746397..bee2181 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,3 +1,79 @@ +# Migration Guide: v2.7.x → v2.8.0 + +## Overview + +v2.8.0 advances the pinned `github-copilot-sdk` from `1.0.7` to `1.0.15` (stable; +bundled CLI 1.0.89) and changes the packaged default model to `auto`. The SDK move is +**wire-compatible for tool forwarding**: no public API, config key, env var, or CLI +flag changes. + +SDK surface changes absorbed by the provider: + +- **New `Tool.is_terminal` field.** SDK 1.0.15's `copilot.tools.Tool` dataclass gains + a trailing `is_terminal: bool = False` field, which `copilot/client.py` reads by + attribute in **both** `create_session` and `resume` + (`if tool.is_terminal: definition["isTerminal"] = True`). The provider hands the + SDK a duck-typed `SDKToolWrapper`, so the wrapper now mirrors that field with a + `False` default, which omits the `isTerminal` key and keeps the tool-forwarding + request byte-identical to 1.0.7. Same shape as the v1.0.2 `defer` and v1.0.7 + `metadata` additions. Without it, every tool-forwarding turn would raise + `AttributeError`. The provider never marks a tool terminal: Amplifier's + orchestrator owns tool execution. +- **Two new mode-gated session defaults pinned** (sdk-boundary MinimalMode:MUST:17-18): + `custom_agents_local_only=True` and `enable_experimental_mode=False`, mirroring + the SDK's own empty-mode defaults. Because `custom_agents_local_only` is non-None, + the SDK applies it with one extra `session.options.update` RPC after each session + is created. +- **`ReasoningEffort` Literal gains `"max"`.** The provider's fallback allowlist + already accepted `"max"`, so behavior is unchanged; `"none"` is now the only + allowlist value outside the SDK Literal. +- **40 new `SessionEventType` members**, all classified **DROP** (no kernel domain + mapping). 30 are listed explicitly in `config/data/events.yaml` (fusion, + auto-tier, workflow-run, model-call, MCP-server, sandbox, and UI telemetry, plus + `assistant.turn_retry`, `agent.interrupted`, `session.completion_receipt`); the + other 10 fall under the existing `permission.*`, `skill.*`, and `subagent.*` + wildcards. + +### Also in this release: development dependency on `amplifier-core>=2.0.0` + +This affects contributors, not users: the provider does not install `amplifier-core` +as a runtime dependency. The `dev` extra now requires 2.x, and `uv.lock` resolves +2.0.1 rather than 1.3.3, so routine tests use the kernel shipped by the current +Amplifier CLI. Contributors with an existing development environment should run +`uv sync --extra dev`. No user action is required. + +--- + +## What Changed: default model is now `auto` + +- **What:** The packaged default model (used when neither the request nor the + `default_model` config sets one) changes from `claude-opus-4.5` to `auto`, + Copilot's server-side router and the Copilot CLI's own default. Each turn is + dispatched to a concrete model chosen by the service. Because `auto` + advertises no limits, `get_info()` now reports `context_window=128000` and + `max_output_tokens=16384` for the default (was `200000` / `32000`), matching + the policy fallbacks the provider already applied to `auto` at runtime. + `auto` advertises no reasoning levels, so a `reasoning_effort` (caller value + or provider default) is dropped with a log once the model list is cached. +- **Action required:** none for configurations that set `default_model` or pass + a model per request. To use a fixed model, set `default_model` to an explicit + model ID that `list_models()` reports. + +--- + +## What Changed: SDK requirement is now `github-copilot-sdk==1.0.15` + +- **What:** The provider now pins `github-copilot-sdk==1.0.15` (was `==1.0.7`). The + import-time support floor is unchanged (`>=1.0.0`; pre-GA beta SDKs remain + unsupported). +- **Action required:** none. A clean `pip install` of `2.8.0` pulls `1.0.15` + automatically via the `pyproject.toml` pin; `amplifier provider install --force + github-copilot` reinstalls the pinned SDK for an existing environment. +- **Rollback:** if the previous SDK pin is required, pin + `amplifier-module-provider-github-copilot<2.8.0`. + +--- + # Migration Guide: v2.6.x → v2.7.0 ## Overview diff --git a/README.md b/README.md index 63e8dee..ce0ebd0 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ providers: - module: provider-github-copilot source: git+https://github.com/microsoft/amplifier-module-provider-github-copilot@main config: - default_model: claude-opus-4.5 + default_model: claude-opus-4.8 ``` ## Usage @@ -127,7 +127,7 @@ amplifier provider models github-copilot ## Supported Models -Models are discovered dynamically from the SDK at runtime — the list reflects your GitHub Copilot plan. The tables below show the current public set as of SDK 1.0.7; run `amplifier provider models github-copilot` for the live list. The Context column is the default-tier prompt window; models that advertise a larger long-context tier (shown as `long=` in the live list) are reached by enabling `enable_long_context`. +Models are discovered dynamically from the SDK at runtime — the list reflects your GitHub Copilot plan. The tables below show the current public set as of SDK 1.0.15; run `amplifier provider models github-copilot` for the live list. The Context column is the default-tier prompt window; models that advertise a larger long-context tier (shown as `long=` in the live list) are reached by enabling `enable_long_context`. **Routing:** @@ -187,12 +187,12 @@ providers: - module: provider-github-copilot name: github-copilot config: - default_model: claude-opus-4.5 + default_model: auto ``` | Key | Default | Description | | --- | --- | --- | -| `default_model` | `"claude-opus-4.5"` | Model used when the caller does not specify one. Any ID from `list_models()` is valid. | +| `default_model` | `"auto"` | Model used when the caller does not specify one. Any ID from `list_models()` is valid. `auto` is Copilot's server-side router (the Copilot CLI's default): each turn goes to a model the service picks. It advertises no limits or reasoning levels, so the provider reports a conservative 128k context / 16k output, and a `reasoning_effort` is dropped for it once the model list is cached. Set an explicit model ID for a fixed window, reasoning control, or reproducible routing. | | `reasoning_effort` | `"model default"` | Reasoning effort forwarded to models that support it (e.g. `low`, `medium`, `high`). Both this default and a caller-supplied value are best-effort: forwarded when the model supports it, dropped (field omitted, server uses its own default) when it does not — so a global or inherited effort never breaks delegation to a non-reasoning model. A malformed value (typo, mixed-case) still raises `ConfigurationError`. `"model default"` or unset defers to the model. Run `amplifier provider models github-copilot` for each model's levels. | | `enable_long_context` | `false` | Default to the model's long-context tier when the caller does not set a `context_tier`. Forwarded to the SDK as `long_context`; ignored by models without a long tier. | | `raw` | `false` | Include raw SDK payloads as a `"raw"` field in `llm:request` / `llm:response` events. See [Raw Payload Logging](#raw-payload-logging). | @@ -388,7 +388,7 @@ For module structure, design decisions, and contract index see [docs/ARCHITECTUR metadata: the provider obtains the selected model's default-tier (or opted-in long-tier) prompt budget from SDK model discovery. They are not a per-request admission decision or a backend acceptance guarantee. -- GitHub Copilot SDK 1.0.7 exposes experimental `context_info` and +- GitHub Copilot SDK 1.0.15 exposes experimental `context_info` and `recompute_context_tokens` operations only for an already-created SDK session. They do not accept arbitrary next-request content, so this provider does not advertise a native `provider_count` preflight measurement. @@ -548,7 +548,7 @@ Running `amplifier init` before authentication: ## Dependencies - `amplifier-core` (provided by Amplifier runtime, not installed separately) -- `github-copilot-sdk==1.0.7` +- `github-copilot-sdk==1.0.15` - `pyyaml>=6.0` > **Note:** `github-copilot-sdk` is installed automatically when you install or initialize diff --git a/amplifier_module_provider_github_copilot/__init__.py b/amplifier_module_provider_github_copilot/__init__.py index c471d09..0d6beb6 100644 --- a/amplifier_module_provider_github_copilot/__init__.py +++ b/amplifier_module_provider_github_copilot/__init__.py @@ -62,7 +62,7 @@ def _check_sdk_version(version_str: str) -> None: the technical symbol-availability minimum sits *below* this floor. The floor is deliberately set higher, at GA, to drop beta-SDK support entirely. - The exact pyproject pin (``==1.0.7``) is enforced separately by + The exact pyproject pin (``==1.0.15``) is enforced separately by ``tests/_sdk_version_gate.py``; this guard only enforces the GA floor. Extracted for testability — module-level code that runs under @@ -75,8 +75,8 @@ def _check_sdk_version(version_str: str) -> None: raise ImportError( f"github-copilot-sdk=={version_str} is below the minimum supported " "version (>=1.0.0 required; pre-GA beta SDKs are no longer supported). " - "Pinned target: ==1.0.7 (pyproject.toml). " - "Reinstall with: pip install 'github-copilot-sdk==1.0.7' " + "Pinned target: ==1.0.15 (pyproject.toml). " + "Reinstall with: pip install 'github-copilot-sdk==1.0.15' " f"or: amplifier provider install --force {PROVIDER_ID}" ) @@ -105,7 +105,7 @@ def _parse_sdk_version(version_str: str) -> _Version: # SDK required; tests only run with SDK installed raise ImportError( "Required dependency 'github-copilot-sdk' is not installed. " - "Install with: pip install 'github-copilot-sdk==1.0.7'" + "Install with: pip install 'github-copilot-sdk==1.0.15'" ) from _e # Contract: sdk-boundary:Membrane:MUST:5 — fail at import time on wrong version. _check_sdk_version(_sdk_version) @@ -127,7 +127,7 @@ def _parse_sdk_version(version_str: str) -> _Version: # Contract: provider-protocol:public_api:MUST:1 — must match pyproject.toml [project].version # Verified by tests/test_behaviors.py::TestPackageVersionConsistency -__version__ = "2.7.0" +__version__ = "2.8.0" # Amplifier module metadata __amplifier_module_type__ = "provider" diff --git a/amplifier_module_provider_github_copilot/config/_models.py b/amplifier_module_provider_github_copilot/config/_models.py index 71c6263..6b83b1b 100644 --- a/amplifier_module_provider_github_copilot/config/_models.py +++ b/amplifier_module_provider_github_copilot/config/_models.py @@ -22,15 +22,19 @@ # NOTE: Per-model capabilities (vision, thinking) are set dynamically in models.py # based on SDK's supports_vision/supports_reasoning_effort flags "capabilities": ["streaming", "tools"], - # Updated defaults to use claude-opus-4.5 with SDK-verified limits - # SDK limits: max_context_window=200000, max_prompt_tokens=168000 - # max_output_tokens = context_window - max_prompt_tokens = 32000 + # Default model "auto" is Copilot's server-side router (the Copilot CLI's own + # default): each turn is dispatched to a concrete model chosen by the + # service. list_models() reports "auto" with empty limits and no reasoning + # efforts, so its window is unknown until routing happens. context_window / + # max_output_tokens therefore mirror FALLBACKS below, which is also exactly + # what model_translation derives for "auto" at runtime; get_info() reports + # the same values on a cold and a warm model cache. "defaults": { - "model": "claude-opus-4.5", + "model": "auto", "max_tokens": 4096, "timeout": 3600, - "context_window": 200000, - "max_output_tokens": 32000, + "context_window": 128000, + "max_output_tokens": 16384, }, } diff --git a/amplifier_module_provider_github_copilot/config/_sdk_protection.py b/amplifier_module_provider_github_copilot/config/_sdk_protection.py index 03bb737..0497991 100644 --- a/amplifier_module_provider_github_copilot/config/_sdk_protection.py +++ b/amplifier_module_provider_github_copilot/config/_sdk_protection.py @@ -26,7 +26,7 @@ class MinimalModeConfig: """Minimal mode session configuration policy. - Contract: sdk-boundary:MinimalMode:MUST:1-16 + Contract: sdk-boundary:MinimalMode:MUST:1-18 Disables SDK features that Amplifier handles, ensuring Amplifier is the sole orchestrator. Evidence: 57% wall-clock improvement (12.5s → 5.4s) confirmed @@ -113,6 +113,26 @@ class MinimalModeConfig: # sdk-boundary:MinimalMode SDK-introspection closure). memory: dict[str, Any] = field(default_factory=lambda: {"enabled": False}) + # MUST:17: Restrict custom-agent loading to local definitions only; Amplifier + # orchestrates agents (complements MUST:5 `custom_agents=[]`). Mode-gated + # create_session kwarg with empty-mode default helper + # `_custom_agents_local_only_default` (v1.0.15 `_mode.py:254-259`) that + # returns True ONLY when `mode == "empty"`. Under `mode="copilot-cli"` the + # helper returns None and the bundled-CLI default would otherwise apply. + # The SDK emits the value in the create payload and, because it is non-None, + # also applies it via one post-create `session.options.update` RPC + # (`_post_create_options_patch`, v1.0.15 `_mode.py:295-340`). Value mirrors + # the SDK empty-mode default. + custom_agents_local_only: bool = True + + # MUST:18: Disable SDK experimental features; Amplifier needs a stable, + # reproducible runtime surface. Mode-gated create_session kwarg with + # empty-mode default helper `_enable_experimental_mode_default` (v1.0.15 + # `_mode.py:262-267`) that returns False ONLY when `mode == "empty"`; under + # `mode="copilot-cli"` the runtime decides when omitted. Value mirrors the + # SDK empty-mode default. + enable_experimental_mode: bool = False + @dataclass(frozen=True) class ToolCaptureConfig: diff --git a/amplifier_module_provider_github_copilot/config/data/errors.yaml b/amplifier_module_provider_github_copilot/config/data/errors.yaml index 17a90b7..8ba2e8a 100644 --- a/amplifier_module_provider_github_copilot/config/data/errors.yaml +++ b/amplifier_module_provider_github_copilot/config/data/errors.yaml @@ -91,8 +91,12 @@ error_mappings: field: conflict_type # P4: Model/provider configuration errors + # "is not supported for model": SDK v1.0.15 (bundled CLI 1.0.89) rejects an + # unsupported reasoning_effort at send time via a session.error whose message + # is "Reasoning effort '' is not supported for model ''." + # (earlier SDKs rejected at session.create with "does not support ..."). - sdk_patterns: ["ConfigurationError"] - string_patterns: ["does not support", "configuration error", "not configured"] + string_patterns: ["does not support", "is not supported for model", "configuration error", "not configured"] kernel_error: ConfigurationError retryable: false # Context extraction for better debugging diff --git a/amplifier_module_provider_github_copilot/config/data/events.yaml b/amplifier_module_provider_github_copilot/config/data/events.yaml index 1733d73..5f3804c 100644 --- a/amplifier_module_provider_github_copilot/config/data/events.yaml +++ b/amplifier_module_provider_github_copilot/config/data/events.yaml @@ -245,6 +245,89 @@ event_classifications: - mcp.tools.list_changed - mcp.resources.list_changed - mcp.prompts.list_changed + # SDK v1.0.15 (bundled CLI 1.0.89): 40 new SessionEventType enum members + # since v1.0.7. All DROP: the provider's domain surface is single-completion + # + tool-call + usage + error + content-deltas; none of these carry a kernel + # domain mapping. Ten are already covered by the existing wildcards above: + # permission.assentDetected / .carriedForward / .contextualAuthorization / + # .messageAuthorization / .messageAuthorizationDegraded / + # .messageAuthorizationRead (permission.*), skill.context_delivered / + # .context_delivered_ref / .invoked_ref (skill.*), subagent.configured + # (subagent.*). The remaining 30 are listed explicitly: + # assistant.turn_retry: extra model inference attempt inside an existing + # turn (turn_id/model/reason). The retried output still arrives via the + # bridged message/usage events; bridging this marker would add nothing. + # agent.interrupted: user-interrupt telemetry (activity/elapsed/tool + # names). The provider aborts sessions itself after tool capture; turn + # completion and failures travel via session.idle / session.error. + # model.call_start / model.call_finished: per-dispatch model lifecycle + # telemetry; siblings of model.call_failure (DROP above). Usage rides + # assistant.usage -> USAGE_UPDATE. + # prompt_cache_break: prompt-cache loss diagnostics; no domain value. + # session.completion_receipt: behavior-neutral record of runtime facts at + # completion; TURN_COMPLETE already comes from assistant.message / + # session.idle, so bridging would double-emit. + # session.context_cleared: host-initiated history clear; the provider never + # clears SDK history (ephemeral per-complete() sessions). + # assistant.fusion_phase_started / _activity / _completed / _failed, + # session.fusion_route_started / _route_failed / _resolved / _completed: + # EXPERIMENTAL HydraFusion multi-model routing telemetry; the provider + # selects models explicitly. + # session.auto_tier_recommendation / session.auto_tier_switch_failed: + # Auto-tier preference signals; siblings of auto_mode_switch.* and + # session.auto_mode_resolved (DROP above). + # session.model_deselected: explicit model withdrawn by its publishing host; + # the provider passes the model on every create_session. + # session.mode_notice_delivered: mode-transition notice bookkeeping for + # resume; sibling of session.mode_changed (DROP above). + # session.permission_recovery: Autopilot permission-recovery snapshot; + # sibling of session.permissions_changed (DROP above). The provider + # denies every permission request (deny-destroy). + # sandbox.decision: process-sandbox governance record; the SDK executes no + # tools under deny-destroy, so there is nothing to surface. + # session.indexed_search: transient indexed-search diagnostics; Amplifier + # owns retrieval (skip_embedding_retrieval=True). + # session.managed_settings_enforced: enterprise-policy enforcement notice; + # sibling of session.managed_settings_resolved (DROP above). + # session.mcp_server_removed / session.mcp_server_needs_reconnect: MCP + # server lifecycle; siblings of session.mcp_server_status_changed (DROP + # above). mcp_servers={} (MinimalMode:MUST:3). + # tool_search.activated: lazy tool-search activations restored on resume; + # the provider sets defer=None on every tool and never resumes sessions. + # ui.ephemeral_query: transient side-query output that does not modify + # conversation history; no UI host. + # workflow.run_started / _updated / _settled: ephemeral workflow-run + # lifecycle signals; Amplifier orchestrates its own workflows. + - assistant.turn_retry + - agent.interrupted + - model.call_start + - model.call_finished + - prompt_cache_break + - session.completion_receipt + - session.context_cleared + - assistant.fusion_phase_started + - assistant.fusion_phase_activity + - assistant.fusion_phase_completed + - assistant.fusion_phase_failed + - session.fusion_route_started + - session.fusion_route_failed + - session.fusion_resolved + - session.fusion_completed + - session.auto_tier_recommendation + - session.auto_tier_switch_failed + - session.model_deselected + - session.mode_notice_delivered + - session.permission_recovery + - sandbox.decision + - session.indexed_search + - session.managed_settings_enforced + - session.mcp_server_removed + - session.mcp_server_needs_reconnect + - tool_search.activated + - ui.ephemeral_query + - workflow.run_started + - workflow.run_updated + - workflow.run_settled finish_reason_map: # Values MUST be lowercase to match amplifier-core proto: diff --git a/amplifier_module_provider_github_copilot/request_adapter.py b/amplifier_module_provider_github_copilot/request_adapter.py index fbd5381..99ffb25 100644 --- a/amplifier_module_provider_github_copilot/request_adapter.py +++ b/amplifier_module_provider_github_copilot/request_adapter.py @@ -33,10 +33,10 @@ # Provider reasoning-effort levels in ascending intensity order. Single source # of truth shared by the request-path fallback allowlist below and the # provider's reasoning_effort ConfigField choices (kept in sync by -# test_reasoning_effort_choices_match_levels). Superset of the v1.0.7 SDK -# ReasoningEffort Literal {low,medium,high,xhigh}: the live list_models endpoint -# additionally advertises "none" and "max", neither of which the v1.0.7 SDK -# Literal enumerates. Membership is pinned by the SDK-superset test in +# test_reasoning_effort_choices_match_levels). Superset of the v1.0.15 SDK +# ReasoningEffort Literal {low,medium,high,xhigh,max}: the live list_models +# endpoint additionally advertises "none", which the v1.0.15 SDK Literal does +# not enumerate. Membership is pinned by the SDK-superset test in # tests/test_sdk_assumptions.py. REASONING_EFFORT_LEVELS: tuple[str, ...] = ("none", "low", "medium", "high", "xhigh", "max") @@ -46,7 +46,7 @@ # Static allowlist for context_tier. Mirrors the public SDK annotation # ``copilot.session.ContextTier = typing.Literal["default", "long_context"]`` -# (verified against installed github-copilot-sdk 1.0.7). Unlike reasoning_effort +# (verified against installed github-copilot-sdk 1.0.15). Unlike reasoning_effort # there is NO per-model capability descriptor, so this membership set is the only # validation possible. Evergreen note: if the SDK Literal grows a tier, update # this frozenset — pinned by test_context_tier.TestSDKSourceShape. @@ -80,6 +80,19 @@ "Please acknowledge this and continue." ) +# The SDK delivers the whole serialized conversation as one user message. Without +# this framing, models read the leading user request as the live instruction and +# repeat tool calls whose results are already in the history. +# Contract: provider-protocol:complete:MUST:15 +HISTORY_PREAMBLE = ( + "The conversation so far follows, one block per message, each block starting " + "with a role marker. Tool Call entries are tool calls you already made, and " + "Tool Result entries are their completed results. Continue the conversation " + "as the assistant from the last block." +) + +_HISTORY_ROLES: frozenset[str] = frozenset({"assistant", "tool"}) + _SUPPORTED_MESSAGE_ROLES: frozenset[str] = frozenset( {"user", "assistant", "system", "developer", "tool"} ) @@ -509,7 +522,7 @@ def _resolve_effort( # The resolved model's advertised capability is authoritative and may WIDEN # ALLOWLIST acceptance beyond the static fallback set: a token the live # endpoint advertises via list_models (e.g. "minimal" for gemini-3.5-flash, - # outside the SDK v1.0.7 literal) is accepted verbatim. Trust boundary: + # outside the SDK v1.0.15 literal) is accepted verbatim. Trust boundary: # supported_reasoning_efforts is backend-sourced (SDK list_models, persisted # through the on-disk model_cache), never caller-derived. Widening relaxes # only membership, NEVER lexical shape: the value must still match the @@ -780,6 +793,7 @@ def _extract_prompt_from_messages(messages: list[Any]) -> str: return "" formatted_parts: list[str] = [] + has_history = False for msg in messages: role = _message_role(msg) @@ -814,7 +828,10 @@ def _extract_prompt_from_messages(messages: list[Any]) -> str: if message_parts: message_text = "\n".join(message_parts) formatted_parts.append(f"{role_marker}\n{message_text}") + has_history = has_history or role in _HISTORY_ROLES + if has_history: + formatted_parts.insert(0, HISTORY_PREAMBLE) return "\n\n".join(formatted_parts) diff --git a/amplifier_module_provider_github_copilot/sdk_adapter/client.py b/amplifier_module_provider_github_copilot/sdk_adapter/client.py index 0eace0c..3d47cf2 100644 --- a/amplifier_module_provider_github_copilot/sdk_adapter/client.py +++ b/amplifier_module_provider_github_copilot/sdk_adapter/client.py @@ -102,6 +102,10 @@ def _minimal_mode_session_config() -> dict[str, Any]: # MUST:16 — v1.0.2 mode-gated 'memory' kwarg; pinned off (mirrors the # SDK empty-mode default {"enabled": False}). "memory": dict(minimal_mode.memory), + # MUST:17-18: v1.0.15 mode-gated kwargs; pinned to the SDK empty-mode + # defaults (local-only custom agents, experimental mode off). + "custom_agents_local_only": minimal_mode.custom_agents_local_only, + "enable_experimental_mode": minimal_mode.enable_experimental_mode, } diff --git a/amplifier_module_provider_github_copilot/sdk_adapter/types.py b/amplifier_module_provider_github_copilot/sdk_adapter/types.py index 72825d4..3c6dd50 100644 --- a/amplifier_module_provider_github_copilot/sdk_adapter/types.py +++ b/amplifier_module_provider_github_copilot/sdk_adapter/types.py @@ -231,6 +231,18 @@ class SDKToolWrapper: # on every tool-forwarding turn. # Contract: sdk-boundary:ToolForwarding:MUST:2 metadata: dict[str, Any] | None = None + # SDK v1.0.15 reads tool.is_terminal when building tool definitions in BOTH + # create_session and resume (copilot/client.py: + # `if tool.is_terminal: definition["isTerminal"] = True`, immediately after + # the `metadata` block). Mirrors the SDK's own copilot.tools.Tool.is_terminal + # field type. False = the key is omitted from the wire payload, which is the + # exact pre-v1.0.15 behavior. A terminal tool asks the runtime to end the + # agent turn after a successful call; the provider never asks for that, + # because Amplifier's orchestrator owns tool execution and the SDK never + # executes a forwarded tool (deny-destroy:NoExecution:MUST:3). Without this + # attribute the SDK raises AttributeError on every tool-forwarding turn. + # Contract: sdk-boundary:ToolForwarding:MUST:2 + is_terminal: bool = False def convert_tools_for_sdk(tools: list[Any]) -> list[SDKToolWrapper]: diff --git a/contracts/behaviors.md b/contracts/behaviors.md index 8212841..3609e23 100644 --- a/contracts/behaviors.md +++ b/contracts/behaviors.md @@ -96,7 +96,9 @@ streaming: ```yaml provider: defaults: - model: "claude-opus-4.5" # Default model + model: "auto" # Default model: Copilot's server-side router + context_window: 128000 # Policy fallback: "auto" advertises no limits + max_output_tokens: 16384 # Policy fallback: "auto" advertises no limits models: - id: claude-opus-4.5 diff --git a/contracts/provider-protocol.md b/contracts/provider-protocol.md index b28978d..585429d 100644 --- a/contracts/provider-protocol.md +++ b/contracts/provider-protocol.md @@ -6,6 +6,8 @@ - **Amplifier Contract:** amplifier-core PROVIDER_CONTRACT.md - **Status:** Specification - **Updated:** 2026-07-17 — `complete:MUST:11`/`complete:MUST:14`: a `reasoning_effort` the resolved model cannot consume (`supports_reasoning_effort=False`, or absent from a non-empty `supported_reasoning_efforts`) is DROPPED to `None` with a log (WARNING for a caller value, INFO for the operator default) and the `reasoningEffort` field omitted on the wire, instead of raising `ConfigurationError` — fixing broken delegation when a parent session's effort is inherited by a sub-agent routed to a non-reasoning model (e.g. a `fast` role — `claude-haiku-4.5`). The resolved model's advertised `supported_reasoning_efforts` is authoritative and WIDENS acceptance beyond the static fallback allowlist `{none,low,medium,high,xhigh,max}` (e.g. `minimal`, advertised for `gemini-3.5-flash`, is forwarded rather than raising). A malformed/unknown token still raises for every model (shared `_check_effort_shape`, before any SDK call); cache-miss forwards and defers to the SDK Layer-2 backstop. The operator default only applies when the caller supplied nothing, so a dropped caller value does not re-trigger it. Caller precedence, dual-call-site forwarding, and `ChatRequest` immutability are unchanged. Prior: 2026-06-30 — Extended the provider reasoning-effort fallback allowlist and the `reasoning_effort` ConfigField choices to admit `"none"` (now `{"none","low","medium","high","xhigh","max"}`), reconciling `complete:MUST:11`, the Layer-1 narrative cache-miss clause, and `get_info:MUST:6` to the 6-value set. Like `"max"`, `"none"` is advertised by live `list_models` for some models but is not enumerated by the v1.0.2 SDK `ReasoningEffort` Literal `{low,medium,high,xhigh}`; it is forwarded verbatim through the SDK truthiness pass-through and gated per-model by `supported_reasoning_efforts` (rejected fail-loud for a model that does not advertise it). No behavioural change for models that do not advertise `"none"`. Prior: 2026-06-29 — Defined `complete:MUST:14` (provider-level `reasoning_effort` default: `__init__` normalises `"model default"` case-insensitively after trimming surrounding whitespace and the empty string to `None`, else stores the value verbatim; `complete()` applies the stored default only when the caller's MUST:11-resolved value is `None`, the caller value wins, the stored default is validated through the same `validate_reasoning_effort` gate before any SDK call, forwarded on both call sites, with no `ChatRequest` mutation) and recorded the already-present `get_info:MUST:6` (choice `reasoning_effort` ConfigField). Reconciled the `complete:MUST:11` row and the Layer-1 narrative cache-miss clause to the 5-value provider fallback allowlist `{"low","medium","high","xhigh","max"}` (a strict superset of the v1.0.2 SDK `ReasoningEffort` Literal that additionally admits `"max"`). Tightened the `get_info:MUST:6` `"model default"` normalisation wording to case-insensitive/trimmed. Prior: 2026-06-24 — Corrected `get_info:MUST:5`/`get_info:MUST:2`: `defaults.max_output_tokens` is the resolved model's single tier-invariant `CopilotModelInfo.max_output_tokens` (derived per `sdk-boundary` §Limit Derivation), NOT tier-selected — the SDK exposes per-tier PROMPT budgets only, with no per-tier `max_output_tokens`, so the same value is reported for both tiers (matches `tests/test_context_tier.py` asserting `64_000` in both tiers). Only `defaults.context_window` is tier-selected. Wording-only; no code/test change. Prior: 2026-06-22 — Added `get_info:MUST:5` (tier-aware budget reporting: when the configured default model's `CopilotModelInfo` is resolvable from cache, `defaults.context_window` and `defaults.max_output_tokens` report the effective tier-selected prompt-budget window — long-tier when `enable_long_context` parses truthy and the model exposes a long tier, else default-tier — read live from SDK billing; cold cache => static fallback, MUST NOT raise/fetch/block the wizard; MUST copy the lru-cached `cfg.defaults` before mutation) and strengthened `get_info:MUST:2` from presence to concrete value-shape. Reporting-only; preserves `complete:MUST:13` "does not unlock capacity". Prior: 2026-06-22 — Added MUST:13 (provider-level `enable_long_context` default: when the flag parses truthy and the caller omits `context_tier`, `complete()` resolves an effective `context_tier` of `"long_context"` and runs it through the MUST:12 gate; a caller-supplied value takes precedence; selects the tier, does not unlock capacity) and `get_info:MUST:4` (boolean `enable_long_context` ConfigField for the init wizard). Prior: 2026-06-22 — Added MUST:12 (context_tier forward-only plumbing with a static SDK-literal membership gate; no per-model capability descriptor exists; verbatim string forwarded, never the SDK enum; emit/visibility deferred to a separate change-set). Prior: 2026-05-12 — Added MUST:11 (reasoning_effort plumbing) and QualityGates section. Cross-referenced `observability:Events:MUST:6` (pre-flight ConfigurationError exempt from llm:request/llm:response emission). +- **Updated:** 2026-09-29 - Added `complete:MUST:15`: a serialized prompt that contains assistant or tool turns starts with a fixed history preamble. The SDK delivers the whole conversation as one user message, and without the preamble gpt-6-sol, gpt-6-luna, and gpt-5.6-terra repeated a completed tool call instead of answering from its result. +- **Updated:** 2026-09-29 - `complete:MUST:11`: SDK v1.0.15 adds `"max"` to the `ReasoningEffort` Literal, so `"none"` is now the only fallback-allowlist value outside the SDK Literal. The allowlist membership is unchanged. --- @@ -323,9 +325,14 @@ that the model actually reasons. Three layers are in scope: `tests/test_reasoning_effort.py`. 2. **Provider Layer 2 (SDK-error backstop)** — when a shape-valid value is forwarded without a local capability verdict (cache-miss, or a model whose - capability flipped server-side), the SDK raises `JsonRpcError` with - `"does not support"` text. `errors.yaml:P4` substring rule maps this to - `ConfigurationError` via `error_translation.py`. This backstop raises the same + capability flipped server-side), the SDK rejects the value. Through SDK + v1.0.7 the rejection is a `JsonRpcError` from `session.create` with + `"does not support"` text; from SDK v1.0.15 the session is created and the + turn fails with a `session.error` whose message is + `"Reasoning effort '' is not supported for model ''."`. + `errors.yaml:P4` substring rules (`"does not support"`, + `"is not supported for model"`) map both shapes to `ConfigurationError` via + `error_translation.py`. This backstop raises the same class Layer 1 raises for a malformed token — a genuine server rejection stays loud rather than being silently masked. 3. **Backend / model runtime** — decides whether to actually allocate @@ -343,7 +350,7 @@ that the model actually reasons. Three layers are in scope: given (model, effort) pair; the runtime decides. - Empty string `""` is treated as None (no effort requested). - Mixed-case values (e.g., `"Medium"`) are rejected; SDK contract is strictly - lowercase per `Literal["low","medium","high","xhigh"]`. + lowercase per `Literal["low","medium","high","xhigh","max"]`. **Provider obligations under MUST:11:** when not None and not empty, validate against the target model's capability descriptor: forward the verbatim string to @@ -455,10 +462,11 @@ is provider runtime config (mirroring `self._raw` / `use_streaming` / | `provider-protocol:complete:MUST:8` | Forwards images as BlobAttachments to SDK | | `provider-protocol:complete:MUST:9` | When malformed tool sequences are detected (tool call without matching tool result in current request), MUST insert synthetic tool-result messages before prompt extraction and MUST log one WARNING per repair event; MUST NOT raise | | `provider-protocol:complete:MUST:10` | When `ChatRequest.max_output_tokens` is not None, MUST forward it to SDK `create_session()` as `model_capabilities=ModelCapabilitiesOverride(limits=ModelLimitsOverride(max_output_tokens=))`; MUST NOT raise; relies on `github-copilot-sdk>=0.3.0` | -| `provider-protocol:complete:MUST:11` | When `ChatRequest.reasoning_effort` is not None and not empty, MUST resolve the value through the capability-aware `validate_reasoning_effort` gate before any SDK call: when the resolved model's `CopilotModelInfo` is cached, `supports_reasoning_effort` is True, the value is listed in `supported_reasoning_efforts`, AND the value matches the universal safe-token shape (`_REASONING_EFFORT_TOKEN_RE`, lowercase `[a-z][a-z0-9_]{0,15}`), the value MUST be accepted verbatim — the model's advertised set is authoritative and WIDENS allowlist acceptance beyond the static fallback set (e.g. `minimal`, advertised by live `list_models` for `gemini-3.5-flash` yet absent from the static list); widening relaxes only allowlist membership, never the lexical safe-token shape, so a malformed or secret-shaped token that a poisoned on-disk model cache advertises is NOT early-accepted and instead falls through to the shape check (rejected, value redacted). Otherwise the shared fallback-allowlist shape check applies (`_check_effort_shape`, case-sensitive against `{"none","low","medium","high","xhigh","max"}` — a strict superset of the SDK `ReasoningEffort` Literal that additionally admits `"none"` and `"max"`, which live `list_models` advertises for some models though the SDK Literal does not enumerate them; a malformed, mixed-case, overlong, or unknown token that the model does not early-accept (not cache-confirmed `supports_reasoning_effort=True` and listed in `supported_reasoning_efforts`) MUST raise `kernel_errors.ConfigurationError`, identically for every such model, before any SDK call). When the model's `CopilotModelInfo` is cached and the model cannot consume a shape-valid value (`supports_reasoning_effort` is False, OR `supported_reasoning_efforts` is non-empty and does not list the value) the caller value MUST be DROPPED to `None` (NOT raised), emitting one WARNING log naming the model and the drop reason so the field is omitted and the server falls back to its default; on cache-miss (`CopilotModelInfo` unavailable) MUST emit an INFO log and forward, deferring final per-model validation to the SDK Layer-2 backstop; otherwise MUST forward verbatim to `client.session(reasoning_effort=)` on BOTH the main and fake-tool-retry call sites. This drop-not-raise leniency for a capability mismatch mirrors `complete:MUST:14` (operator default); only a malformed token stays fail-loud. Relies on `github-copilot-sdk>=0.3.0` | +| `provider-protocol:complete:MUST:11` | When `ChatRequest.reasoning_effort` is not None and not empty, MUST resolve the value through the capability-aware `validate_reasoning_effort` gate before any SDK call: when the resolved model's `CopilotModelInfo` is cached, `supports_reasoning_effort` is True, the value is listed in `supported_reasoning_efforts`, AND the value matches the universal safe-token shape (`_REASONING_EFFORT_TOKEN_RE`, lowercase `[a-z][a-z0-9_]{0,15}`), the value MUST be accepted verbatim — the model's advertised set is authoritative and WIDENS allowlist acceptance beyond the static fallback set (e.g. `minimal`, advertised by live `list_models` for `gemini-3.5-flash` yet absent from the static list); widening relaxes only allowlist membership, never the lexical safe-token shape, so a malformed or secret-shaped token that a poisoned on-disk model cache advertises is NOT early-accepted and instead falls through to the shape check (rejected, value redacted). Otherwise the shared fallback-allowlist shape check applies (`_check_effort_shape`, case-sensitive against `{"none","low","medium","high","xhigh","max"}` — a strict superset of the SDK `ReasoningEffort` Literal that additionally admits `"none"`, which live `list_models` advertises for some models though the SDK Literal does not enumerate it (`"max"` joined the SDK Literal in v1.0.15); a malformed, mixed-case, overlong, or unknown token that the model does not early-accept (not cache-confirmed `supports_reasoning_effort=True` and listed in `supported_reasoning_efforts`) MUST raise `kernel_errors.ConfigurationError`, identically for every such model, before any SDK call). When the model's `CopilotModelInfo` is cached and the model cannot consume a shape-valid value (`supports_reasoning_effort` is False, OR `supported_reasoning_efforts` is non-empty and does not list the value) the caller value MUST be DROPPED to `None` (NOT raised), emitting one WARNING log naming the model and the drop reason so the field is omitted and the server falls back to its default; on cache-miss (`CopilotModelInfo` unavailable) MUST emit an INFO log and forward, deferring final per-model validation to the SDK Layer-2 backstop; otherwise MUST forward verbatim to `client.session(reasoning_effort=)` on BOTH the main and fake-tool-retry call sites. This drop-not-raise leniency for a capability mismatch mirrors `complete:MUST:14` (operator default); only a malformed token stays fail-loud. Relies on `github-copilot-sdk>=0.3.0` | | `provider-protocol:complete:MUST:12` | When the request supplies a `context_tier` attribute (via `getattr(request, "context_tier", None)`) that is not None and not empty, MUST pre-validate the value against the static SDK literal allowlist `{"default","long_context"}` (case-sensitive); the empty string is treated as None; there is NO per-model capability gate (the SDK exposes no context-tier descriptor); on any mismatch MUST raise `kernel_errors.ConfigurationError` (rejected value redacted) before any SDK call; otherwise MUST forward the verbatim string to `client.session(context_tier=)` on BOTH the main and fake-tool-retry call sites (`_execute_sdk_completion` accepts `context_tier` as an explicit keyword argument, no `**kwargs` smuggle); MUST forward the string, never the SDK `ContextTier` enum; relies on `github-copilot-sdk>=1.0.2` | | `provider-protocol:complete:MUST:13` | The provider reads runtime config `enable_long_context` once in `__init__` via `_parse_raw_flag` (the shared boolean-config parser, reused from the `raw` flag: bool True, or string ∈ {`"true"`,`"1"`,`"yes"`} after `.lower()` → True; every other string (e.g. `"maybe"`, whitespace-padded `" true "`, `""`, `"false"`, `"0"`, `"no"`) and `None` → False; a non-bool/non-str value delegates to Python truthiness, consistent with the `raw` flag and pinned by `test_provider_branches`; MUST NOT raise — fail-safe for the bool/str/None values a boolean config field can hold). In `complete()`, BEFORE the MUST:12 gate, when the request's `context_tier` attribute is absent, None, or empty AND the parsed flag is True, MUST resolve an effective `context_tier` of `"long_context"` (a transient local; MUST NOT mutate the request); a caller-supplied non-empty `context_tier` MUST take precedence; the effective tier MUST pass through `validate_context_tier` before any SDK call and MUST forward identically on BOTH the main and fake-tool-retry call sites; there is NO per-model window gate (forwards even on a small-window model); the flag selects the tier and MUST NOT be described as unlocking capacity; relies on `github-copilot-sdk>=1.0.2` | | `provider-protocol:complete:MUST:14` | The provider reads runtime config `reasoning_effort` once in `__init__`; the literal `"model default"` (matched case-insensitively after trimming surrounding whitespace) and the empty string both normalise to `None` (no stored default), and any other string value is stripped of surrounding whitespace (case preserved) and stored, while a non-str config value normalises to `None`. In `complete()`, when the caller supplied no `reasoning_effort` (`ChatRequest.reasoning_effort` is `None` or empty) AND a stored default is configured (not `None`), MUST resolve the stored default through the capability-aware `resolve_provider_default_effort` gate before any SDK call: when the resolved model's `CopilotModelInfo` is cached, `supports_reasoning_effort` is True, the value is listed in `supported_reasoning_efforts`, AND the value matches the universal safe-token shape (`_REASONING_EFFORT_TOKEN_RE`), the value MUST be accepted verbatim — the model's advertised set is authoritative and WIDENS allowlist acceptance beyond the static fallback set (symmetric with `complete:MUST:11`; e.g. `minimal` for `gemini-3.5-flash`); widening relaxes only allowlist membership, never the lexical safe-token shape, so a malformed or secret-shaped advertised token is NOT early-accepted and instead falls through to the shape check (rejected, value redacted). Otherwise the shared fallback-allowlist shape check applies (`_check_effort_shape`, case-sensitive — a malformed or unknown token that the model does not early-accept (not cache-confirmed `supports_reasoning_effort=True` and listed in `supported_reasoning_efforts`) MUST raise `kernel_errors.ConfigurationError`, identically for every such model, before any SDK call). When the model's `CopilotModelInfo` is cached and the model cannot consume a shape-valid value (`supports_reasoning_effort` is False, OR `supported_reasoning_efforts` is non-empty and does not list the value) the operator default MUST be DROPPED to `None` (NOT raised), emitting one INFO log naming the model and the drop reason so the field is omitted and the server falls back to its default; on cache-miss (`CopilotModelInfo` unavailable) MUST forward the value and emit an INFO log deferring final per-model validation to the SDK Layer-2 backstop; otherwise MUST forward the value. This drop-not-raise leniency for a capability mismatch is symmetric with `complete:MUST:11` (a caller-supplied `reasoning_effort` the resolved model cannot consume is likewise dropped to `None`), differing only in log scope — the caller drop logs at WARNING, the operator-default drop at INFO — and both raise only on a malformed token. The operator default fills in ONLY when the caller supplied no `reasoning_effort`; a caller value dropped for an incapable model does NOT re-trigger the operator default on the same model. A caller-supplied non-empty `reasoning_effort` MUST take precedence (the stored default is not consulted when the caller supplies one); the resolved effective value MUST forward identically on BOTH the main and fake-tool-retry call sites; MUST NOT mutate `ChatRequest`; relies on `github-copilot-sdk>=0.3.0`. Note: the stored `reasoning_effort` is the operator-configured provider default (from config); a model's advertised `defaults["reasoning_effort"]` (from the SDK) is informational for display only and is NOT auto-injected — when no provider default is configured, or when the configured default is dropped for an incapable model, the field is omitted and the server falls back to its default. | +| `provider-protocol:complete:MUST:15` | When the serialized prompt contains at least one `assistant` or `tool` block, MUST prefix it with `request_adapter.HISTORY_PREAMBLE`, which states that the blocks are the conversation so far, that Tool Call entries were already made and Tool Result entries are their completed results, and that the model continues as the assistant from the last block. A prompt with only `user`/`developer` blocks MUST NOT carry the preamble. The preamble MUST contain no `[ALL_CAPS]` role-marker sequence | --- diff --git a/contracts/sdk-boundary.md b/contracts/sdk-boundary.md index 7f07398..9d6e5f5 100644 --- a/contracts/sdk-boundary.md +++ b/contracts/sdk-boundary.md @@ -1,15 +1,17 @@ # Contract: SDK Boundary (The Membrane) ## Version -- **Current:** 1.14 (SDK v1.0.7 — ToolForwarding hardened: `SDKToolWrapper.metadata` added to mirror the SDK's new `copilot.tools.Tool.metadata` field, read during tool-definition serialization on both the create and resume paths) +- **Current:** 1.15 (SDK v1.0.15 - ToolForwarding hardened: `SDKToolWrapper.is_terminal` added to mirror the SDK's new `copilot.tools.Tool.is_terminal` field; MinimalMode extended to MUST:1-18 for two new mode-gated `create_session` defaults) - **Module Reference:** amplifier_module_provider_github_copilot/sdk_adapter/ - **Status:** Non-Negotiable Constraint +- **Update (v1.15):** 2026-09-29 - SDK bumped v1.0.7 -> v1.0.15 (bundled CLI 1.0.89). (1) ToolForwarding: the SDK's `copilot.tools.Tool` gained `is_terminal: bool = False` (installed v1.0.15 `tools.py:93`: a successful call to a terminal tool ends the agent turn); the SDK reads it when building tool wire-definitions on BOTH paths, create (`client.py:2617-2618`) and resume (`client.py:3407-3408`): `if tool.is_terminal: definition["isTerminal"] = True`. The duck-typed `SDKToolWrapper` lacked it, so every tool-forwarding turn would raise `AttributeError` (the `defer` / `metadata` failure mode). `SDKToolWrapper.is_terminal` added, defaulting to `False` (omits the wire key = pre-v1.0.15 payload). The provider never marks a tool terminal: Amplifier's orchestrator owns tool execution and the SDK never executes a forwarded tool (deny-destroy:NoExecution:MUST:3). (2) MinimalMode: v1.0.15 adds mode-gated empty-mode default helpers `_custom_agents_local_only_default` (`_mode.py:254-259`, empty default `True`) and `_enable_experimental_mode_default` (`_mode.py:262-267`, empty default `False`). Under `mode="copilot-cli"` both return `None`, so they are pinned to the SDK empty-mode defaults (MUST:17-18). (3) SDKSurface:MUST:6: `CopilotSession.send` gained keyword-only `source` and `response_schema`; the provider passes neither. (4) New `CopilotClient.__init__` kwargs (`builtin_plugin_directories`, `extension_launch_provider`, `installation_confirmation_handler`, `client_info`) are left unset: with `None` nothing is registered at startup, and an unhandled `installations.confirm` request is answered with an error by the SDK (`installation_confirmation.py`), which fails closed. SDKSurface:MUST:8 is an "at least" set, so no clause change. - **Update (v1.14):** 2026-07-22 — ToolForwarding hardened for v1.0.7. The SDK's `copilot.tools.Tool` gained a `metadata: dict[str, Any] | None` field (installed v1.0.7 `tools.py:78`, docstring `tools.py:186`: "Opaque, host-defined metadata associated with the tool definition"); the SDK reads it when building tool wire-definitions on BOTH paths — create (`client.py:2182-2183`) and resume (`client.py:2858-2859`): `if tool.metadata is not None: definition["metadata"] = tool.metadata`. The provider's duck-typed `SDKToolWrapper` lacked it, so every tool-forwarding turn would raise `AttributeError: 'SDKToolWrapper' object has no attribute 'metadata'` — the exact `defer` failure mode from v1.12, reachable only by an E2E that exercises the real tool-definition builder (not the mocked unit suite or tool-less live smoke). `SDKToolWrapper.metadata` added, defaulting to `None` (omits the wire key = byte-identical pre-v1.0.7 payload); Amplifier attaches no host-defined tool metadata. The existing Tier-6 structural guard (`test_sdk_assumptions.py`) already fails RED on any unmirrored SDK `Tool` field; a v1.0.7 wire-omission regression test (`tests/test_sdk_boundary_contract.py::test_wrapper_survives_sdk_v107_tool_definition_build`) additionally pins that `metadata is None` omits the key. Also: 6 new v1.0.7 SDK session-event types classified as DROP (config-driven), pinned to exact DROP in `tests/test_event_classifications.py`. - **Update:** 2026-06-21 — Client Lifecycle annotated with the v1.0.2 graceful-shutdown teardown-latency observation. A real-world WSL E2E probe of the provider's `close()`→SDK `stop()` path shows a clean teardown takes ~12s ONCE PER LIFECYCLE: the graceful runtime-shutdown RPC acks in <1s (`client.py:1455` success branch, not `:1462` fail), then the drained CLI process does not self-exit so the bounded `wait(timeout=10)` (`client.py:1491-1496`) elapses and `terminate()` (`:1498`) reaps it — bounded, deterministic, zero orphans, no SIGKILL, NOT per-turn (runtime reused across turns). SDK-owned behavior; provider only calls `stop()`. No source change. - **Update (v1.12):** 2026-06-21 — ToolForwarding hardened for v1.0.2. The SDK's `copilot.tools.Tool` gained a `defer: Literal["auto","never"] | None` field (installed v1.0.2 `tools.py:65`); the SDK reads it when building tool wire-definitions (`client.py:1810-1811` and `:2391-2392`: `if tool.defer is not None: definition["defer"] = tool.defer`). The provider's duck-typed `SDKToolWrapper` lacked it, so every tool-forwarding turn raised `AttributeError: 'SDKToolWrapper' object has no attribute 'defer'` — a real production blocker not reached by the mocked unit suite or the tool-less live smoke suite (neither exercises the real tool-definition builder). `SDKToolWrapper.defer` added, defaulting to `None` (omits the wire key = byte-identical pre-v1.0.2 payload); Amplifier pre-loads all tools at the kernel layer, so the SDK's lazy tool-search deferral stays off. A new Tier-6 structural guard introspects the real `copilot.tools.Tool` field set against the wrapper so a FUTURE SDK tool-field addition fails RED before E2E. - **Update (v1.11):** 2026-06-21 — SDK bumped b10 → v1.0.2 (GA, 1.0.1, 1.0.2). MinimalMode extended from MUST:1-15 to MUST:1-16: v1.0.2 adds a mode-gated `memory` session capability whose empty-mode default helper `_memory_default` (installed v1.0.2 `_mode.py:264-276`) returns `{"enabled": False}` only when `mode == "empty"`; our adapter ships `mode="copilot-cli"`, so leaving `memory` unset hands session-memory control to the bundled CLI. Pinned to `{"enabled": False}` (MUST:16). The b10→v1.0.2 span is non-breaking per the SDK diff analysis + freeze-diff battery: the only production wire-shape change is the `memory` emit plus three config-driven event drops (see event-vocabulary 1.4). v1.0.2 also lands a graceful runtime shutdown on the once-per-lifecycle `stop()` path, bounded by `_RUNTIME_SHUTDOWN_TIMEOUT_SECONDS=10` (NOT per-turn). - **Update (v1.10):** 2026-06-01 — MinimalMode extended from MUST:1-6 to MUST:1-15. b10 added 8 new `create_session` kwargs gating SDK-internal capabilities (session store, skills loader, file hooks, host git, on-demand instruction discovery, embedding retrieval, embedding cache storage, MCP OAuth token storage), plus the pre-existing b9 `enable_session_telemetry` consolidated here. Helpers at b10 `_mode.py:185-258` only collapse `None` to defaults when `mode == "empty"`; our adapter ships `mode="copilot-cli"` (`sdk_adapter/client.py:419-424`), so leaving any kwarg unset hands control to the bundled CLI. Explicit pin is a real wire-shape change, not intent-pinning. v1.10 pins the 9th mode-gated default (`mcp_oauth_token_storage`) and corrects the v1.9 claim that `mcp_servers={}` foreclosed its wire-emit — it does not; the emit is independent at b10 `client.py:1863-1865`. v1.8 SDKSurface clauses verified byte-compatible against b10 — no production source change beyond the 9 MinimalMode emits. - **History:** + - **1.15** - SDK v1.0.15: ToolForwarding:MUST:2 extended (`SDKToolWrapper.is_terminal: bool = False`, mirroring `copilot.tools.Tool.is_terminal`, read at `client.py:2617-2618` create and `:3407-3408` resume; `False` omits the `"isTerminal"` wire key). MinimalMode:MUST:17 (`custom_agents_local_only=True`) and MUST:18 (`enable_experimental_mode=False`) added for the two new v1.0.15 mode-gated helpers. Pinning `custom_agents_local_only` makes the SDK send one post-create `session.options.update` RPC per session (`_post_create_options_patch` returns a non-empty patch in copilot-cli mode when any option is non-None). SDKSurface:MUST:6 extended with `source` / `response_schema`. Guards: the Tier-6 field-set introspection (`tests/test_sdk_assumptions.py::TestSDKToolWrapperCoversSDKToolSurface`) fails RED without `is_terminal`; `tests/test_sdk_boundary_contract.py::test_wrapper_survives_sdk_v1015_tool_definition_build` replays the v1.0.15 definition loop and pins `is_terminal is False => key omitted`; `test_no_unpinned_sdk_mode_gated_capability` enforces MUST:17-18. Companion event change: 30 new explicit DROP entries in `events.yaml` (10 more covered by the existing `permission.*` / `skill.*` / `subagent.*` wildcards). - **1.14** — SDK v1.0.7: ToolForwarding:MUST:2 extended — `SDKToolWrapper` gains `metadata: dict[str, Any] | None = None`, mirroring the SDK's own `copilot.tools.Tool.metadata` (installed v1.0.7 `tools.py:78`). The SDK reads `tool.metadata` building tool wire-definitions on BOTH the create (`client.py:2182-2183`) and resume (`client.py:2858-2859`) paths; the duck-typed wrapper lacking it would raise `AttributeError` on every tool-forwarding turn — the identical failure mode to `defer` (v1.12), unreachable by the mocked unit suite or tool-less live smoke, only by an E2E exercising the real tool-definition builder. `None` default omits the `"metadata"` wire key (byte-identical pre-v1.0.7 payload); Amplifier attaches no opaque host-defined tool metadata. Guards: the durable Tier-6 field-set introspection (`tests/test_sdk_assumptions.py`) fails RED on any future unmirrored SDK `Tool` field; `tests/test_sdk_boundary_contract.py::test_wrapper_survives_sdk_v107_tool_definition_build` replays the v1.0.7 definition loop and pins `metadata is None ⇒ key omitted`. Companion event change: 6 new v1.0.7 session-event types classified DROP (config-driven, event-vocabulary), pinned to exact DROP in `tests/test_event_classifications.py`. - **1.13** — SDK v1.0.2: Client Lifecycle annotated (no source change) with the observed graceful-shutdown teardown cost from the real-world WSL E2E. Provider `close()`→SDK `stop()` takes ~12s ONCE PER LIFECYCLE: graceful runtime-shutdown RPC acked <1s (`client.py:1455` success branch, `runtime shutdown complete`; the `:1462` fail branch never fires), then the drained CLI process does not self-exit so the bounded `wait(timeout=_RUNTIME_SHUTDOWN_TIMEOUT_SECONDS=10)` (`client.py:1491-1496`) elapses and `terminate()` (`:1498`) reaps it. Bounded, deterministic, zero orphans, no SIGKILL. Runtime reused across turns so the cost is lifecycle-end only, never per-turn. SDK-owned; the provider only invokes `stop()`. - **1.12** — SDK v1.0.2: ToolForwarding:MUST:2 extended — `SDKToolWrapper` gains `defer: Literal["auto","never"] | None = None`, mirroring the SDK's own `copilot.tools.Tool.defer` (installed v1.0.2 `tools.py:65`). The SDK reads `tool.defer` building tool wire-definitions (`client.py:1810-1811`, `:2391-2392`); the duck-typed wrapper lacking it raised `AttributeError` on every tool-forwarding turn — a real blocker not reachable by the mocked unit suite or the tool-less live smoke suite, only by an E2E that exercises the real tool-definition builder. `None` default omits the `"defer"` wire key (byte-identical pre-v1.0.2 payload); Amplifier pre-loads tools at the kernel layer so lazy tool-search deferral has no meaning here. Durable guard added: `tests/test_sdk_assumptions.py::TestSDKToolWrapperCoversSDKToolSurface` introspects the real `Tool` dataclass field set (⊆ wrapper, `require_sdk`-gated, allowlist+hygiene), so a future SDK tool-field addition fails RED before E2E rather than crashing a live turn. @@ -335,6 +337,8 @@ for tool in tools: definition["defer"] = tool.defer if tool.metadata is not None: # ← SDK v1.0.7 reads tool.metadata (client.py:2182-2183, :2858-2859) definition["metadata"] = tool.metadata + if tool.is_terminal: # ← SDK v1.0.15 reads tool.is_terminal (client.py:2617-2618, :3407-3408) + definition["isTerminal"] = True ``` **Required attributes on each tool object:** @@ -346,6 +350,7 @@ for tool in tools: - `handler: None` — **MUST exist** (SDK checks handler attribute); set to `None` so SDK skips handler registration (Amplifier handles tools at kernel layer) - `defer: Literal["auto", "never"] | None` — **MUST exist** (SDK v1.0.2 reads `tool.defer` when building tool definitions); set to `None` so the `defer` key is omitted from the wire payload (exact pre-v1.0.2 behavior). Amplifier pre-loads all tools at the kernel layer, so the SDK's lazy tool-search deferral stays off. Mirrors the SDK's own `copilot.tools.Tool.defer` field. - `metadata: dict[str, Any] | None` — **MUST exist** (SDK v1.0.7 reads `tool.metadata` when building tool definitions — `client.py:2182-2183` create, `:2858-2859` resume); set to `None` so the `metadata` key is omitted from the wire payload (exact pre-v1.0.7 behavior). Opaque host-defined tool metadata; Amplifier attaches none. Mirrors the SDK's own `copilot.tools.Tool.metadata` field (installed v1.0.7 `tools.py:78`). +- `is_terminal: bool` - **MUST exist** (SDK v1.0.15 reads `tool.is_terminal` when building tool definitions: `client.py:2617-2618` create, `:3407-3408` resume); set to `False` so the `isTerminal` key is omitted from the wire payload (exact pre-v1.0.15 behavior). A terminal tool ends the agent turn after a successful call; the provider never requests that, because Amplifier's orchestrator owns tool execution (deny-destroy:NoExecution:MUST:3). Mirrors the SDK's own `copilot.tools.Tool.is_terminal` field (installed v1.0.15 `tools.py:93`). **Implementation:** Use `SDKToolWrapper` dataclass from `sdk_adapter/types.py`: ```python @@ -360,6 +365,7 @@ class SDKToolWrapper: handler: Any = None # SDK checks this; None skips handler registration defer: Literal["auto", "never"] | None = None # SDK v1.0.2 reads tool.defer; None = not deferred (omitted from wire) metadata: dict[str, Any] | None = None # SDK v1.0.7 reads tool.metadata; None = omitted from wire (pre-v1.0.7 payload) + is_terminal: bool = False # SDK v1.0.15 reads tool.is_terminal; False = omitted from wire (pre-v1.0.15 payload) def convert_tools_for_sdk(tools: list[Any]) -> list[SDKToolWrapper]: # Handles both ToolSpec objects (attribute access) and dicts @@ -480,6 +486,8 @@ The dict passed to `client.create_session()` MUST satisfy these constraints: | sdk-boundary:MinimalMode:MUST:14 | `enable_session_telemetry` MUST be set to `False` | Disables SDK-internal session telemetry — Amplifier owns observability. Pinned because b10 `client.py:1651-1656` documents telemetry as ON-by-default for GitHub-authenticated sessions and our `COPILOT_AGENT_TOKEN` path IS that path; without pin, leaving kwarg `None` lets the bundled CLI emit telemetry by default. | | sdk-boundary:MinimalMode:MUST:15 | `mcp_oauth_token_storage` MUST be set to `"in-memory"` | Keeps MCP OAuth tokens in RAM — no on-disk token residue across the ephemeral session boundary. Pinned because `mode="copilot-cli"` does not invoke the empty-mode default helper at b10 `_mode.py:251-258`; the wire-emit (b10 `client.py:1863-1865`) is INDEPENDENT of the `mcpServers` emit (`client.py:1860-1861`), so `mcp_servers={}` (MUST:3) does NOT foreclose it. | | sdk-boundary:MinimalMode:MUST:16 | `memory` MUST be set to `{"enabled": False}` | Disables the SDK v1.0.2 mode-gated session-memory capability — Amplifier owns all context/memory; aligns with deny-destroy / ephemeral per-`complete()` sessions. Pinned because `mode="copilot-cli"` does not invoke the empty-mode default helper at installed v1.0.2 `_mode.py:264-276` (`_memory_default` returns `{"enabled": False}` only when `mode == "empty"`). | +| sdk-boundary:MinimalMode:MUST:17 | `custom_agents_local_only` MUST be set to `True` | Restricts custom-agent loading to local definitions; Amplifier orchestrates agents (complements MUST:5). Pinned because `mode="copilot-cli"` does not invoke the empty-mode default helper at installed v1.0.15 `_mode.py:254-259` (`_custom_agents_local_only_default` returns `True` only when `mode == "empty"`). The non-None value is also applied by one post-create `session.options.update` RPC. | +| sdk-boundary:MinimalMode:MUST:18 | `enable_experimental_mode` MUST be set to `False` | Disables SDK experimental features; Amplifier needs a stable runtime surface. Pinned because `mode="copilot-cli"` does not invoke the empty-mode default helper at installed v1.0.15 `_mode.py:262-267` (`_enable_experimental_mode_default` returns `False` only when `mode == "empty"`); otherwise the runtime decides. | ### Test Anchors @@ -501,14 +509,18 @@ The dict passed to `client.create_session()` MUST satisfy these constraints: | `sdk-boundary:MinimalMode:MUST:14` | session telemetry disabled | `tests/test_sdk_boundary_contract.py::TestMinimalModeConfig::test_enable_session_telemetry_disabled` | | `sdk-boundary:MinimalMode:MUST:15` | mcp oauth token storage in-memory | `tests/test_sdk_boundary_contract.py::TestMinimalModeConfig::test_mcp_oauth_token_storage_in_memory` | | `sdk-boundary:MinimalMode:MUST:16` | memory disabled | `tests/test_sdk_boundary_contract.py::TestMinimalModeConfig::test_memory_disabled` | +| `sdk-boundary:MinimalMode:MUST:17` | custom agents local-only | `tests/test_sdk_boundary_contract.py::TestMinimalModeConfig::test_custom_agents_local_only_enabled` | +| `sdk-boundary:MinimalMode:MUST:18` | experimental mode disabled | `tests/test_sdk_boundary_contract.py::TestMinimalModeConfig::test_enable_experimental_mode_disabled` | ### Out of scope (mode-gated defaults not pinned) -The 10 pins above cover every SDK mode-gated capability default reached through +The 12 pins above cover every SDK mode-gated capability default reached through `create_session` — the `__default(mode, supplied)` helpers in b10 `_mode.py:185-258`, plus v1.0.2's `_memory_default` (`_mode.py:264-276`; v1.0.2 shifted the `_mode.py` line ranges, so these offsets are version-tagged and not -contiguous with the b10 block). Two related SDK defaults are deliberately NOT pinned: +contiguous with the b10 block), plus v1.0.15's `_custom_agents_local_only_default` +and `_enable_experimental_mode_default` (`_mode.py:254-267`). Two related SDK +defaults are deliberately NOT pinned: - **`manage_schedule_enabled` / `coauthor_enabled`** — these ARE `create_session` kwargs (b10 `client.py:1576-1577`) but have no @@ -516,13 +528,13 @@ contiguous with the b10 block). Two related SDK defaults are deliberately NOT pi They flow through `_post_create_options_patch` (b10 `_mode.py:261-298`), applied internally by `create_session` via `_apply_post_create_options_patch` (b10 `client.py:2099`). The provider passes neither, so both default to - `None`; under `mode="copilot-cli"` the patch builder returns `None` - (`_mode.py:298`, `return patch or None`) and `session.options.update` is - never called — neither option reaches the wire. `coauthor_enabled` is + `None` and neither option reaches the wire. `coauthor_enabled` is additionally neutralized by MUST:10 (`enable_host_git_operations=False`). - Pinning either to `False` would add an `options.update` round-trip the - provider does not currently make; tracked as a follow-up if the provider - adopts that path. + Since v1.0.15 the MUST:17 `custom_agents_local_only` pin makes the patch + non-empty, so the SDK already sends one `session.options.update` per session + (carrying only `customAgentsLocalOnly`); pinning these two would add fields + to that RPC, not a new round-trip. They remain unpinned because neither has + a mode-gated `__default` helper. - **`mcp_oauth_token_storage` on `resume_session`** — `resume_session` (b10 `client.py:2117`) carries the same independent emit at `client.py:2429-2431`, but the provider creates sessions only via @@ -563,7 +575,7 @@ and turns red if the live SDK surface drifts. | `sdk-boundary:SDKSurface:MUST:3` | `copilot.ModelSupportsOverride` is a dataclass with field tuple `("vision", "reasoning_effort")`, all defaults `None` | | `sdk-boundary:SDKSurface:MUST:4` | `copilot.ModelVisionLimitsOverride` is a dataclass with field tuple `("supported_media_types", "max_prompt_images", "max_prompt_image_size")`, all defaults `None` | | `sdk-boundary:SDKSurface:MUST:5` | `copilot.session.CopilotSession.workspace_path` is a `functools.cached_property` | -| `sdk-boundary:SDKSurface:MUST:6` | `copilot.session.CopilotSession.send` accepts exactly the kwargs `{"prompt", "attachments", "mode", "agent_mode", "request_headers", "display_prompt"}` (positional `prompt`, five keyword-only: `attachments`, `mode`, `agent_mode`, `request_headers`, `display_prompt`) per b10 `session.py:L1185-L1194`, so the test mock signature in `tests/fixtures/sdk_mocks.py` and the provider call sites in `sdk_adapter/client.py` stay synchronised with the live SDK. `agent_mode` is the `Literal["interactive", "plan", "autopilot", "shell"] | None` UI-mode kwarg added in b9; `display_prompt` is the `str | None` UI-display-only kwarg added in b10 (lets callers send a separate display string from the actual prompt). | +| `sdk-boundary:SDKSurface:MUST:6` | `copilot.session.CopilotSession.send` accepts exactly the kwargs `{"prompt", "attachments", "source", "mode", "agent_mode", "request_headers", "display_prompt", "response_schema"}` (positional `prompt`, seven keyword-only: `attachments`, `source`, `mode`, `agent_mode`, `request_headers`, `display_prompt`, `response_schema`) per v1.0.15 `session.py:L1759-L1770`, so the test mock signature in `tests/fixtures/sdk_mocks.py` and the provider call sites in `sdk_adapter/client.py` stay synchronised with the live SDK. `agent_mode` is the `Literal["interactive", "plan", "autopilot", "shell"] | None` UI-mode kwarg added in b9; `display_prompt` is the `str | None` UI-display-only kwarg added in b10 (lets callers send a separate display string from the actual prompt); `source` (message provenance) and `response_schema` (per-run structured-output schema) were added in v1.0.15, and the provider passes neither (both default to `None`, omitted from the wire). | | `sdk-boundary:SDKSurface:MUST:7` | `copilot.client.ModelBilling.from_dict` tolerates the live GitHub server shape — a payload with `restricted_to` and `token_prices` but no `multiplier` — without raising. `multiplier` MUST remain optional (`float \| None`, no invented default), so `list_models()` does not abort the whole batch on the first billing-carrying model. | | `sdk-boundary:SDKSurface:MUST:8` | `copilot.CopilotClient.__init__` is keyword-only and accepts at least the keywords `{connection, working_directory, log_level, env, github_token, base_directory, use_logged_in_user, telemetry, session_fs, session_idle_timeout_seconds, enable_remote_sessions, on_list_models, mode}` (b10 `client.py:1073-1089`). An introspection test in `tests/test_sdk_assumptions.py::TestSDKImportAssumptions` MUST fail loudly on drift. | diff --git a/pyproject.toml b/pyproject.toml index 1234e98..eb4bfb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amplifier-module-provider-github-copilot" -version = "2.7.0" +version = "2.8.0" description = "GitHub Copilot provider for Amplifier - Two-Medium Architecture" readme = "README.md" requires-python = ">=3.11" @@ -51,7 +51,18 @@ dependencies = [ # every tool-forwarding turn raises AttributeError — same shape as the # v1.0.2 `defer` regression. Also adds 6 new SessionEventType members # (all DROP; see config/data/events.yaml). No other import shape changed. - "github-copilot-sdk==1.0.7", + # - v1.0.15 (stable; bundled CLI 1.0.89): the SDK `copilot.tools.Tool` + # dataclass gains a trailing `is_terminal: bool = False` field, which + # `copilot/client.py` reads by attribute in BOTH create_session and resume + # (`if tool.is_terminal: definition["isTerminal"] = True`). Duck-typed + # `SDKToolWrapper` MUST mirror it (see sdk_adapter/types.py) or every + # tool-forwarding turn raises AttributeError (same shape as the v1.0.2 + # `defer` / v1.0.7 `metadata` regressions). Two new mode-gated + # create_session defaults (`custom_agents_local_only`, + # `enable_experimental_mode`) are pinned (MinimalMode:MUST:17-18). + # `ReasoningEffort` Literal gains "max". Adds 40 new SessionEventType + # members (all DROP; see config/data/events.yaml). + "github-copilot-sdk==1.0.15", # PyYAML for config loading "pyyaml>=6.0", # PEP 440 version parser used by the import-time SDK floor check @@ -77,13 +88,10 @@ dev = [ "pytest>=9.0.3", "pytest-asyncio>=0.21", "pytest-cov>=7.0.0", - # amplifier-core from PyPI (binary wheels, no Rust compilation needed) - # Floor bumped from 1.0.7 → 1.3.0 to align with the runtime version actually - # injected by the Amplifier CLI (1.4.x). The 1.0.7 floor predated several - # kernel iterations and was no longer representative of the tested surface. - # Note: amplifier-core public message_models are byte-identical across - # 1.2.6→1.4.1; the bump reflects policy alignment, not new field usage. - "amplifier-core>=1.3.0", + # Development-only: the Amplifier runtime supplies amplifier-core when the + # provider is loaded. Align type checking and tests with the current CLI's + # 2.x kernel instead of the older 1.x version previously resolved by uv.lock. + "amplifier-core>=2.0.0", ] [project.entry-points."amplifier.modules"] diff --git a/tests/fixtures/sdk_mocks.py b/tests/fixtures/sdk_mocks.py index 092d0c8..313ccea 100644 --- a/tests/fixtures/sdk_mocks.py +++ b/tests/fixtures/sdk_mocks.py @@ -186,17 +186,21 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: Any | None = None, mode: Any | None = None, agent_mode: Any | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: Any | None = None, ) -> str: """Send message and trigger events via handlers. - Signature mirrors the live SDK ``CopilotSession.send`` (v1.0.0b10): - ``mode``, ``agent_mode``, ``request_headers``, and ``display_prompt`` - are accepted so tests that exercise the full call surface do not - silently lose data. ``display_prompt`` is the new b10 kwarg. + Signature mirrors the live SDK ``CopilotSession.send`` (v1.0.15): + ``source``, ``mode``, ``agent_mode``, ``request_headers``, + ``display_prompt``, and ``response_schema`` are accepted so tests that + exercise the full call surface do not silently lose data. + ``display_prompt`` was added in b10; ``source`` and ``response_schema`` + in v1.0.15. SDK v0.2.0 API: send(prompt, attachments=...) replaces send({"prompt": ...}) @@ -360,17 +364,20 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: Any | None = None, mode: Any | None = None, agent_mode: Any | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: Any | None = None, ) -> str: """Send message but raise error after some events. - Signature mirrors the live SDK ``CopilotSession.send`` (v1.0.0b10); - ``mode`` / ``agent_mode`` / ``request_headers`` / ``display_prompt`` - are accepted but unused by this fault injector — included so - error-path tests can pass the full kwarg set. + Signature mirrors the live SDK ``CopilotSession.send`` (v1.0.15); + ``source`` / ``mode`` / ``agent_mode`` / ``request_headers`` / + ``display_prompt`` / ``response_schema`` are accepted but unused by + this fault injector, included so error-path tests can pass the full + kwarg set. Args: prompt: The message prompt text. diff --git a/tests/test_behaviors.py b/tests/test_behaviors.py index ea7feb7..2010400 100644 --- a/tests/test_behaviors.py +++ b/tests/test_behaviors.py @@ -459,14 +459,14 @@ def test_rejects_pre_ga_one_x_versions(self) -> None: _check_sdk_version(stale) msg = str(exc_info.value) assert stale in msg, f"Error must echo installed version {stale!r}" - assert "1.0.7" in msg, "Error must state the pinned target version" + assert "1.0.15" in msg, "Error must state the pinned target version" def test_accepts_ga_and_above(self) -> None: """sdk-boundary:Membrane:MUST:5 — GA (1.0.0) and forward satisfy the floor. The floor is a support-policy floor at the GA line (1.0.0); the symbol-availability minimum (b10) sits below it, and the pyproject pin - is 1.0.7. GA and every later release must satisfy the floor. + is 1.0.15. GA and every later release must satisfy the floor. """ from amplifier_module_provider_github_copilot import ( _check_sdk_version, # type: ignore[reportPrivateUsage] @@ -1094,7 +1094,7 @@ def test_multiple_providers_dont_share_config(self) -> None: cached_config = load_models_config() # Cached config should still have original YAML default # (not mutated by either provider) - assert cached_config.defaults["model"] == "claude-opus-4.5" + assert cached_config.defaults["model"] == "auto" def test_get_info_reports_runtime_default_model(self) -> None: """get_info() defaults["model"] MUST reflect the runtime-configured model. @@ -1202,10 +1202,10 @@ def test_get_info_does_not_mutate_shared_cached_defaults(self) -> None: # The process-wide lru_cached singleton must be untouched by provider1's # get_info() call. cached_config = load_models_config() - assert cached_config.defaults["model"] == "claude-opus-4.5" + assert cached_config.defaults["model"] == "auto" info2 = provider2.get_info() - assert info2.defaults["model"] == "claude-opus-4.5" + assert info2.defaults["model"] == "auto" # ============================================================================= diff --git a/tests/test_config_driven_timeout.py b/tests/test_config_driven_timeout.py index 6976418..0b76ddb 100644 --- a/tests/test_config_driven_timeout.py +++ b/tests/test_config_driven_timeout.py @@ -134,10 +134,12 @@ async def send( prompt: str, *, attachments: list[dict] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: self.last_prompt = prompt await asyncio.sleep(60) # cancelled by asyncio.timeout() diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py index 293c3be..1d63de5 100644 --- a/tests/test_config_loading.py +++ b/tests/test_config_loading.py @@ -53,7 +53,7 @@ def test_load_models_config_returns_provider_config(self) -> None: config = _load_models_config() assert config.provider_id == "github-copilot" - assert config.defaults["model"] == "claude-opus-4.5" + assert config.defaults["model"] == "auto" assert config.defaults["timeout"] == 3600 # NOTE: Fallback tests removed - config validation now uses fail-fast pattern. @@ -67,14 +67,14 @@ class TestProviderUsesYamlConfig: def test_get_info_sourced_from_yaml(self) -> None: """Provider.get_info() values come from YAML, not hardcoded strings. - Updated to expect claude-opus-4.5 as default model. + The packaged default model is "auto" (Copilot's server-side router). """ from amplifier_module_provider_github_copilot.provider import GitHubCopilotProvider provider = GitHubCopilotProvider() info = provider.get_info() assert info.id == "github-copilot" - assert info.defaults.get("model") == "claude-opus-4.5" + assert info.defaults.get("model") == "auto" @pytest.mark.asyncio async def test_list_models_sourced_from_sdk(self) -> None: @@ -196,6 +196,6 @@ def test_load_models_config_reads_yaml(self) -> None: config = load_models_config() # Values should match config/_models.py - assert config.defaults["model"] == "claude-opus-4.5" + assert config.defaults["model"] == "auto" assert config.defaults["timeout"] == 3600 - assert config.defaults["context_window"] == 200000 + assert config.defaults["context_window"] == 128000 diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 6b6a772..4b7ea05 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -46,7 +46,7 @@ def test_empty_models_list_is_allowed(self) -> None: with patch(f"{_MODELS_MODULE}.MODELS", [], create=True): cfg = load_models_config() assert cfg.provider_id == "github-copilot" - assert cfg.defaults["model"] == "claude-opus-4.5" + assert cfg.defaults["model"] == "auto" load_models_config.cache_clear() def test_configuration_error_includes_provider_id(self) -> None: diff --git a/tests/test_context_tier.py b/tests/test_context_tier.py index 8c39072..537d56d 100644 --- a/tests/test_context_tier.py +++ b/tests/test_context_tier.py @@ -783,7 +783,7 @@ class TestGetInfoTierAwareWindow: Contract: provider-protocol:get_info:MUST:5 Mutation check: reverting get_info() to return cfg.defaults verbatim makes - the long-tier assertion read 200000 (the static literal) instead of 936000 — red. + the long-tier assertion read 128000 (the static literal) instead of 936000, so it goes red. """ def test_default_tier_reports_default_budget(self) -> None: @@ -805,8 +805,8 @@ def test_unknown_default_model_falls_back_to_static(self) -> None: enable_long_context=True, default_model="model-not-in-cache" ) info = provider.get_info() - assert info.defaults["context_window"] == 200_000 - assert info.defaults["max_output_tokens"] == 32_000 + assert info.defaults["context_window"] == 128_000 + assert info.defaults["max_output_tokens"] == 16_384 def test_old_cache_zero_sentinel_reports_static_not_ceiling(self) -> None: # Regression guard: a pre-tier (old) cache yields 0 tier budgets while the @@ -835,7 +835,7 @@ def test_old_cache_zero_sentinel_reports_static_not_ceiling(self) -> None: ) ] info = provider.get_info() - assert info.defaults["context_window"] == 200_000 + assert info.defaults["context_window"] == 128_000 assert info.defaults["context_window"] != 1_000_000 # max_output_tokens stays the model's real value (never inflated). assert info.defaults["max_output_tokens"] == 64_000 @@ -852,8 +852,8 @@ def test_get_info_does_not_mutate_cached_defaults_singleton(self) -> None: # The lru_cached singleton defaults must be pristine — get_info copies # before injecting the tier window. singleton = load_models_config().defaults - assert singleton["context_window"] == 200_000 - assert singleton["max_output_tokens"] == 32_000 + assert singleton["context_window"] == 128_000 + assert singleton["max_output_tokens"] == 16_384 def test_two_providers_isolated_by_tier(self) -> None: from amplifier_module_provider_github_copilot.config_loader import ( @@ -869,4 +869,4 @@ def test_two_providers_isolated_by_tier(self) -> None: assert short_info.defaults["context_window"] == 200_000 assert long_info.defaults["context_window"] == 936_000 # Neither provider poisoned the shared singleton for the other. - assert load_models_config().defaults["context_window"] == 200_000 + assert load_models_config().defaults["context_window"] == 128_000 diff --git a/tests/test_contract_protocol.py b/tests/test_contract_protocol.py index 5c093eb..4f8a77a 100644 --- a/tests/test_contract_protocol.py +++ b/tests/test_contract_protocol.py @@ -82,8 +82,8 @@ def test_get_info_includes_context_window(self, provider: GitHubCopilotProvider) "get_info() defaults must include 'context_window' for kernel budget calculation. " "See provider-protocol:get_info:MUST:2" ) - assert info.defaults["context_window"] == 200000, ( - f"context_window is {info.defaults['context_window']!r} but should be 200000. " + assert info.defaults["context_window"] == 128000, ( + f"context_window is {info.defaults['context_window']!r} but should be 128000. " "Value comes from config/_models.py PROVIDER['defaults']['context_window']." ) diff --git a/tests/test_entry_point.py b/tests/test_entry_point.py index 12587fe..7151325 100644 --- a/tests/test_entry_point.py +++ b/tests/test_entry_point.py @@ -245,7 +245,7 @@ def test_correct_sdk_version_installed(self) -> None: """SDK installed in the test environment MUST be >= 1.0.0. If this fails, the test environment has a stale SDK. Upgrade with: - pip install 'github-copilot-sdk==1.0.7' + pip install 'github-copilot-sdk==1.0.15' SKIP_SDK_CHECK bypasses the SDK subprocess at runtime but never exempts the test environment from having the correct SDK package installed. @@ -262,7 +262,7 @@ def test_correct_sdk_version_installed(self) -> None: version = importlib.metadata.version("github-copilot-sdk") assert _parse_sdk_version(version) >= _SDK_FLOOR, ( f"Test environment has SDK {version} which is < 1.0.0. " - "Install 'github-copilot-sdk==1.0.7' to run these tests." + "Install 'github-copilot-sdk==1.0.15' to run these tests." ) def test_version_check_error_message_is_actionable(self) -> None: @@ -280,7 +280,7 @@ def test_version_check_error_message_is_actionable(self) -> None: error_msg = str(exc_info.value) assert "0.1.28" in error_msg, "Error must include the installed version" - assert "1.0.7" in error_msg, "Error must state the required version" + assert "1.0.15" in error_msg, "Error must state the required version" assert "github-copilot-sdk" in error_msg, "Error must name the package" assert "amplifier provider install" in error_msg, ( "Error must include the amplifier provider install command" diff --git a/tests/test_error_types.py b/tests/test_error_types.py index 463ed4d..1b3fd34 100644 --- a/tests/test_error_types.py +++ b/tests/test_error_types.py @@ -210,6 +210,10 @@ class TestF035P4ConfigurationError: "gpt-3.5-turbo does not support reasoning effort configuration", "Model configuration error: invalid parameter", "does not support extended thinking", + # SDK v1.0.15 send-time rejection, as surfaced by the SDK and by + # event_router's session.error handling. + "Session error: Execution failed: Reasoning effort 'high' is not " + "supported for model 'claude-haiku-4.5'.", ], ) def test_config_error_patterns( diff --git a/tests/test_event_classifications.py b/tests/test_event_classifications.py index 2fa6145..c276446 100644 --- a/tests/test_event_classifications.py +++ b/tests/test_event_classifications.py @@ -208,6 +208,54 @@ def test_commands_changed_drops_without_unknown_warning( "mcp.tools.list_changed", "mcp.resources.list_changed", "mcp.prompts.list_changed", + # SDK v1.0.15 (bundled CLI 1.0.89) enum members added to the + # events.yaml DROP block. All lack a kernel domain mapping; see + # events.yaml for per-event rationale. turn_retry / + # completion_receipt / model.call_* are DROP so they do NOT + # double-emit TURN_COMPLETE / USAGE_UPDATE / ERROR; the rest are + # experimental fusion, auto-tier, MCP, sandbox, workflow, and UI + # telemetry. The wildcard-covered permission.* / skill.* / + # subagent.configured members are pinned by the last ten entries. + "assistant.turn_retry", + "agent.interrupted", + "model.call_start", + "model.call_finished", + "prompt_cache_break", + "session.completion_receipt", + "session.context_cleared", + "assistant.fusion_phase_started", + "assistant.fusion_phase_activity", + "assistant.fusion_phase_completed", + "assistant.fusion_phase_failed", + "session.fusion_route_started", + "session.fusion_route_failed", + "session.fusion_resolved", + "session.fusion_completed", + "session.auto_tier_recommendation", + "session.auto_tier_switch_failed", + "session.model_deselected", + "session.mode_notice_delivered", + "session.permission_recovery", + "sandbox.decision", + "session.indexed_search", + "session.managed_settings_enforced", + "session.mcp_server_removed", + "session.mcp_server_needs_reconnect", + "tool_search.activated", + "ui.ephemeral_query", + "workflow.run_started", + "workflow.run_updated", + "workflow.run_settled", + "permission.assentDetected", + "permission.carriedForward", + "permission.contextualAuthorization", + "permission.messageAuthorization", + "permission.messageAuthorizationDegraded", + "permission.messageAuthorizationRead", + "skill.context_delivered", + "skill.context_delivered_ref", + "skill.invoked_ref", + "subagent.configured", ], ) def test_sdk_030_event_types_classified_without_warning( diff --git a/tests/test_live_smoke.py b/tests/test_live_smoke.py index 2c7c6bc..9264de9 100644 --- a/tests/test_live_smoke.py +++ b/tests/test_live_smoke.py @@ -47,6 +47,12 @@ _MODEL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+\-]{0,127}$") _KNOWN_MOCK_MODEL_IDS = frozenset({"claude-opus-4.5", "claude-sonnet-4"}) _VALID_FINISH_REASONS = frozenset({"stop", "tool_calls", "length", "content_filter"}) +# Comma-separated model IDs for the live tool round trip. +_ROUND_TRIP_MODELS = tuple( + model.strip() + for model in os.environ.get("COPILOT_LIVE_ROUND_TRIP_MODELS", "gpt-6-sol").split(",") + if model.strip() +) def _get_token() -> str: @@ -262,7 +268,7 @@ def _bind_real_sdk_override_types(monkeypatch: pytest.MonkeyPatch) -> None: those types this raises ``TypeError: 'NoneType' object is not callable`` — a test-harness artifact, NOT a production defect. In production ``SKIP_SDK_CHECK`` is unset and ``_imports`` already holds the real classes - (verified against github-copilot-sdk==1.0.7). ``client.py`` looks these up + (verified against github-copilot-sdk==1.0.15). ``client.py`` looks these up via the membrane at call time, so patching ``_imports`` is sufficient. Fail-closed: uses ``require_sdk()`` (which fails, never skips, on a missing @@ -342,6 +348,144 @@ async def test_provider_complete_returns_content_finish_reason_and_usage( ) _assert_response_usage(response.usage) + @pytest.mark.asyncio + async def test_provider_complete_forwards_tool_and_captures_tool_call( + self, + live_client: Any, + monkeypatch: pytest.MonkeyPatch, + real_model_discovery: None, + ) -> None: + """Provider.complete() with a tool MUST build real SDK tool definitions + and return the captured tool call to the orchestrator. + + This is the only live path that drives the SDK's real tool-definition + builder (``copilot/client.py`` reads every ``SDKToolWrapper`` attribute, + including ``defer`` / ``metadata`` / ``is_terminal``), so a wrapper + missing an SDK-read attribute fails here with ``AttributeError``. + + Contract: sdk-boundary:ToolForwarding:MUST:2 + Contract: deny-destroy:NoExecution:MUST:2 + """ + provider = _make_live_provider(live_client, monkeypatch) + models = await provider.list_models() + _assert_real_model_list(models) + model_id = models[0].id + + request = SimpleNamespace( + model=model_id, + messages=[ + SimpleNamespace( + role="user", + content="Call get_weather with city set to Paris. Do not reply with text.", + ) + ], + tools=[ + SimpleNamespace( + name="get_weather", + description="Get the current weather for a city.", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + ) + ], + max_output_tokens=256, + reasoning_effort=None, + context_tier=None, + metadata={"stream": False}, + ) + + response = await provider.complete(request, model=model_id, _timeout_seconds=60.0) + + assert response.tool_calls, "Live completion with a forwarded tool returned no tool calls" + assert response.tool_calls[0].name == "get_weather", ( + f"Unexpected tool call name: {response.tool_calls[0].name!r}" + ) + assert response.finish_reason == "tool_calls", ( + f"Tool-call completion must finish with 'tool_calls'; got {response.finish_reason!r}" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("model_id", _ROUND_TRIP_MODELS) + async def test_provider_tool_round_trip_ends_with_text( + self, + model_id: str, + live_client: Any, + monkeypatch: pytest.MonkeyPatch, + real_model_discovery: None, + ) -> None: + """A tool result fed back through complete() MUST yield a final answer. + + Mirrors the orchestrator: the first leg returns a bash call, the caller + runs it, and the second leg sends the call and its result as history. + With the history unframed, gpt-6-sol repeated the completed call on + every leg. + + Contract: provider-protocol:complete:MUST:15 + """ + from amplifier_core import ChatRequest, Message, ToolCallBlock, ToolSpec + + provider = _make_live_provider(live_client, monkeypatch) + available = {model.id for model in await provider.list_models()} + assert model_id in available, ( + f"{model_id!r} is not available to this token; set " + f"COPILOT_LIVE_ROUND_TRIP_MODELS to models from {sorted(available)!r}" + ) + + tools = [ + ToolSpec( + name="bash", + description="Run a bash command and return its output.", + parameters={ + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + ) + ] + user = Message( + role="user", + content=( + "Use the bash tool to run exactly this command: echo packaged-42 " + "Then reply with only the command's output." + ), + ) + + first = await provider.complete( + ChatRequest(model=model_id, messages=[user], tools=tools), _timeout_seconds=60.0 + ) + assert first.tool_calls, f"{model_id}: first leg returned no tool call" + call = first.tool_calls[0] + assert call.name == "bash", f"{model_id}: unexpected tool {call.name!r}" + + history = [ + user, + Message( + role="assistant", + content=[ToolCallBlock(id=call.id, name=call.name, input=call.arguments)], + ), + Message( + role="tool", + tool_call_id=call.id, + content='{"stdout": "packaged-42\\n", "stderr": "", "returncode": 0}', + ), + ] + second = await provider.complete( + ChatRequest(model=model_id, messages=history, tools=tools), _timeout_seconds=60.0 + ) + + assert not second.tool_calls, ( + f"{model_id}: repeated a completed tool call: " + f"{[(c.name, c.arguments) for c in second.tool_calls]!r}" + ) + assert second.finish_reason == "stop", ( + f"{model_id}: final leg must finish with 'stop'; got {second.finish_reason!r}" + ) + assert "packaged-42" in (second.text or ""), ( + f"{model_id}: final text lacks the tool output: {second.text!r}" + ) + # ============================================================================= # Session Lifecycle Tests (Most Critical) diff --git a/tests/test_models.py b/tests/test_models.py index 6b75b7e..22ad2f1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -957,7 +957,7 @@ def test_models_yaml_contains_policy_not_catalog(self) -> None: models_config = load_models_config() # Should have defaults (policy) with correct default model - assert models_config.defaults["model"] == "claude-opus-4.5" + assert models_config.defaults["model"] == "auto" # ProviderConfig no longer exposes a .models field — catalog comes from SDK assert not hasattr(models_config, "models"), ( diff --git a/tests/test_multi_turn_context.py b/tests/test_multi_turn_context.py index 6c7c0cf..d8dda57 100644 --- a/tests/test_multi_turn_context.py +++ b/tests/test_multi_turn_context.py @@ -850,3 +850,88 @@ def test_tool_result_includes_tool_call_id(self) -> None: f"Prompt was: {prompt!r}" ) assert "file contents here" in prompt + + +class TestHistoryPreamble: + """A prompt carrying prior assistant or tool turns is framed as history. + + The SDK delivers the serialized conversation as one user message. Unframed, + gpt-6-sol, gpt-6-luna, and gpt-5.6-terra read the leading user request as the + live instruction and repeated a tool call whose result was already present. + + Contract: provider-protocol:complete:MUST:15 + """ + + @pytest.mark.asyncio + async def test_tool_round_trip_prompt_starts_with_history_preamble(self) -> None: + """The orchestrator's second leg reaches the SDK framed as completed history.""" + from amplifier_core import ChatRequest, Message, ToolCallBlock + + from amplifier_module_provider_github_copilot.provider import GitHubCopilotProvider + from amplifier_module_provider_github_copilot.request_adapter import HISTORY_PREAMBLE + from tests.fixtures.sdk_mocks import MockCopilotClientWrapper, text_delta_event + + client = MockCopilotClientWrapper(events=[text_delta_event("packaged-42")]) + provider = GitHubCopilotProvider(client=client) # type: ignore[arg-type] + request = ChatRequest( + model="gpt-6-sol", + messages=[ + Message(role="user", content="Run echo packaged-42 and reply with its output."), + Message( + role="assistant", + content=[ + ToolCallBlock( + id="call-1", name="bash", input={"command": "echo packaged-42"} + ) + ], + ), + Message(role="tool", tool_call_id="call-1", content='{"stdout": "packaged-42"}'), + ], + ) + + await provider.complete(request) + + session = client.session_instance + assert session is not None + prompt = session.last_prompt + assert prompt is not None + assert prompt.startswith(HISTORY_PREAMBLE + "\n\n[USER]\n") + assert prompt.count(HISTORY_PREAMBLE) == 1 + assert prompt.index("[Tool Call (id=call-1") < prompt.index("[Tool Result (id=call-1)") + + @pytest.mark.parametrize("role", ["assistant", "tool"]) + def test_any_assistant_or_tool_block_adds_preamble(self, role: str) -> None: + """Either history role alone is enough to frame the prompt.""" + from amplifier_module_provider_github_copilot.request_adapter import HISTORY_PREAMBLE + + message: dict[str, Any] = {"role": role, "content": "earlier"} + if role == "tool": + message["tool_call_id"] = "call-1" + request = MockChatRequest( + messages=[MockMessage(role="user", content="first"), message] # type: ignore[list-item] + ) + + assert _extract_prompt_from_chat_request(request).startswith(HISTORY_PREAMBLE + "\n\n") + + def test_user_and_developer_only_prompt_has_no_preamble(self) -> None: + """A first-leg prompt is unchanged: nothing to frame without prior turns.""" + request = MockChatRequest( + messages=[ + MockMessage(role="system", content="System"), + MockMessage(role="developer", content="Guidance"), + MockMessage(role="user", content="Hello"), + ] + ) + + prompt = _extract_prompt_from_chat_request(request) + + assert prompt == "[DEVELOPER]\nGuidance\n\n[USER]\nHello" + + def test_preamble_contains_no_role_marker(self) -> None: + """The preamble cannot be mistaken for a role block.""" + from amplifier_module_provider_github_copilot.request_adapter import ( + _ROLE_INJECTION_PATTERN, # type: ignore[reportPrivateUsage] + HISTORY_PREAMBLE, + ) + + assert not _ROLE_INJECTION_PATTERN.search(HISTORY_PREAMBLE) diff --git a/tests/test_provider_branches.py b/tests/test_provider_branches.py index ddf9492..74f4426 100644 --- a/tests/test_provider_branches.py +++ b/tests/test_provider_branches.py @@ -70,10 +70,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: self.last_prompt = prompt fake_event = SessionEvent( @@ -101,10 +103,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: self._send_count += 1 if self._send_count == 1: @@ -453,10 +457,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: self.last_prompt = prompt # Fire assistant.usage → populates usage_holder, goes into queue @@ -547,10 +553,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: self.last_prompt = prompt # 1. Tool request event (populates tool_capture_handler.captured_tools diff --git a/tests/test_provider_coverage.py b/tests/test_provider_coverage.py index a253960..eb5d21f 100644 --- a/tests/test_provider_coverage.py +++ b/tests/test_provider_coverage.py @@ -127,10 +127,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: nonlocal attempt_count attempt_count += 1 @@ -186,10 +188,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: nonlocal attempt_count attempt_count += 1 @@ -670,10 +674,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: self.last_prompt = prompt for event in flood_events: @@ -738,10 +744,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: nonlocal correction_attempt correction_attempt += 1 @@ -805,10 +813,12 @@ async def send( prompt: str, *, attachments: list[dict[str, Any]] | None = None, + source: object | None = None, mode: object | None = None, agent_mode: object | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: object | None = None, ) -> str: nonlocal attempt attempt += 1 diff --git a/tests/test_reasoning_effort.py b/tests/test_reasoning_effort.py index e41b0e7..1de7bc3 100644 --- a/tests/test_reasoning_effort.py +++ b/tests/test_reasoning_effort.py @@ -440,9 +440,9 @@ def test_model_info_none_with_bogus_value_raises(self) -> None: assert "brand-new-model" in msg assert "provider fallback allowlist" in msg # Must enumerate accepted values so the caller can self-correct. - # "none" and "max" are included because the fallback allowlist extends - # the v1.0.7 SDK literal with both (advertised by the live list_models - # endpoint; absent from the v1.0.7 SDK literal). + # "max" is a v1.0.15 SDK literal member; "none" is included because the + # fallback allowlist extends the v1.0.15 SDK literal with it (advertised + # by the live list_models endpoint; absent from the v1.0.15 SDK literal). for v in ("none", "low", "medium", "high", "xhigh", "max"): assert f"'{v}'" in msg @@ -639,7 +639,7 @@ def test_malformed_shape_raises_on_cache_miss(self) -> None: class TestReasoningEffortNoneLevel: """The "none" effort level (advertised by some live models, absent from the - v1.0.7 SDK ``ReasoningEffort`` Literal) passes the universal shape gate and + v1.0.15 SDK ``ReasoningEffort`` Literal) passes the universal shape gate and is then governed by the per-model allowlist, exactly like "max". Contract: provider-protocol:complete:MUST:11 @@ -1172,17 +1172,23 @@ async def test_dropped_caller_effort_does_not_retrigger_operator_default( class TestLayer2SDKRejectMatchesErrorTranslation: """When Layer-1 is bypassed (cache miss with value in fallback allowlist, or stale cache for a model whose capability flipped server-side), the live - SDK rejects ``reasoning_effort`` with a ``JsonRpcError`` whose message - contains ``"does not support reasoning effort"``. The provider's + SDK rejects ``reasoning_effort``. Under SDK v1.0.15 ``create_session`` + accepts the value and the first turn fails with a ``session.error`` whose + message is ``"Reasoning effort '' is not supported for model + ''."``; ``CopilotSession.send_and_wait`` surfaces it as + ``Exception("Session error: ...")``, the same shape the provider's + ``EventRouter`` builds from ``session.error``. The provider's ``error_translation`` (``errors.yaml:P4``) must map this to ``ConfigurationError``. - Drift detection: if the SDK changes the error wording, this test fails - and forces a synchronized update of the substring rule in ``errors.yaml``. + Drift detection: if the SDK changes the error wording or moves the + rejection back to ``create_session``, this test fails and forces a + synchronized update of the substring rule in ``errors.yaml``. - Mutation check: change the P4 ``substring_match`` to a different phrase - and this test goes red — the JsonRpcError falls through to the default - mapping (``ProviderUnavailableError``) instead of ``ConfigurationError``. + Mutation check: remove ``"is not supported for model"`` from the P4 + ``string_patterns`` and this test goes red: the error falls through to the + default mapping (``ProviderUnavailableError``) instead of + ``ConfigurationError``. Contract: provider-protocol:complete:MUST:11 (Layer-2 backstop); error-hierarchy.md (translation policy). @@ -1207,7 +1213,7 @@ async def test_live_sdk_reject_translates_to_configuration_error( if not token: pytest.fail( "No GITHUB_TOKEN available; live Layer-2 test requires real " - "SDK auth. Tests run, not skip — set GITHUB_TOKEN." + "SDK auth. Tests run, not skip; set GITHUB_TOKEN." ) from amplifier_core import llm_errors as kernel_errors @@ -1231,53 +1237,42 @@ async def test_live_sdk_reject_translates_to_configuration_error( await client.start() captured_exc: Exception | None = None try: + # Provoke Layer-2: pass reasoning_effort to a model the backend + # rejects. claude-haiku-4.5 advertises supports_reasoning_effort=False. + # A create-time rejection here means the SDK moved the check back + # to session.create; let it propagate so the drift is visible. + session = await client.create_session( + model="claude-haiku-4.5", + streaming=True, + available_tools=[], + on_permission_request=deny_permission_request, + hooks=_make_deny_hook_config(), + reasoning_effort="high", + ) try: - # Provoke Layer-2: pass reasoning_effort to a model the - # backend rejects. claude-haiku-4.5 advertises - # supports_reasoning_effort=False; this round-trips to the - # server which raises JsonRpcError. - session = await client.create_session( - model="claude-haiku-4.5", - streaming=True, - available_tools=[], - on_permission_request=deny_permission_request, - hooks=_make_deny_hook_config(), - reasoning_effort="high", - ) - # If we got here the contract assumption is broken. - await session.disconnect() + await session.send_and_wait("Reply with one short word.", timeout=60.0) pytest.fail( - "Live SDK accepted reasoning_effort='high' on " + "Live SDK completed a turn with reasoning_effort='high' on " "claude-haiku-4.5; the backend behavior changed and the " "Layer-2 backstop rule may be stale. Re-probe and update " "errors.yaml:P4." ) except Exception as e: captured_exc = e + finally: + await session.disconnect() finally: await client.stop() - # Live SDK raises ``copilot._jsonrpc.JsonRpcError``. The class is not - # re-exported at ``copilot`` root in b10, so the test imports from the - # underscored module directly and pins the exact type with isinstance — - # avoids the fragile-string-compare anti-pattern and makes a future - # rename or hierarchy change fail loud at this assertion. - from copilot._jsonrpc import JsonRpcError # type: ignore[import-untyped] - - assert isinstance(captured_exc, JsonRpcError), ( - f"Live SDK raised {type(captured_exc).__name__} (msg: " - f"{captured_exc!r}); expected JsonRpcError. Either the SDK error " - f"hierarchy changed or the backend started rejecting via a " - f"different transport — investigate before updating this test." - ) + assert captured_exc is not None original_msg = str(captured_exc) # Pin the substring our errors.yaml:P4 rule keys on. If the backend # rewords this message, this assertion fails BEFORE the translation # step, telling us exactly what to update. - assert "does not support reasoning effort" in original_msg, ( + assert "is not supported for model" in original_msg, ( f"Live SDK error message no longer contains the substring " f"errors.yaml:P4 keys on. Current message: {original_msg!r}. " - f"Update the substring_match rule and this assertion together." + f"Update the string_patterns rule and this assertion together." ) # End-to-end Layer-2 translation: this is the round-trip the user @@ -1289,12 +1284,13 @@ async def test_live_sdk_reject_translates_to_configuration_error( model="claude-haiku-4.5", ) assert isinstance(translated, kernel_errors.ConfigurationError), ( - f"errors.yaml:P4 substring rule failed to map live SDK " - f"JsonRpcError to ConfigurationError; got " + f"errors.yaml:P4 substring rule failed to map the live SDK " + f"send-time rejection to ConfigurationError; got " f"{type(translated).__name__} instead. This breaks the " f"Layer-1/Layer-2 same-class contract documented in " f"contracts/provider-protocol.md MUST:11." ) + assert translated.retryable is False assert translated.__cause__ is captured_exc, ( "ConfigurationError must chain the original SDK exception via " "`raise ... from exc` so traces preserve root cause." diff --git a/tests/test_request_adapter.py b/tests/test_request_adapter.py index 500f0c5..459c2f8 100644 --- a/tests/test_request_adapter.py +++ b/tests/test_request_adapter.py @@ -182,6 +182,7 @@ def test_developer_messages_remain_escaped_history_not_system_configuration( def test_valid_dict_messages_preserve_system_separation(self) -> None: """Documented dict roles retain the established prompt and system paths.""" from amplifier_module_provider_github_copilot.request_adapter import ( + HISTORY_PREAMBLE, extract_prompt_from_chat_request, extract_system_message, ) @@ -196,7 +197,8 @@ def test_valid_dict_messages_preserve_system_separation(self) -> None: ) assert extract_prompt_from_chat_request(request) == ( - "[USER]\nHello\n\n[ASSISTANT]\nHi\n\n[DEVELOPER]\nHistory guidance" + f"{HISTORY_PREAMBLE}\n\n[USER]\nHello\n\n[ASSISTANT]\nHi" + "\n\n[DEVELOPER]\nHistory guidance" ) assert extract_system_message(request) == "System instructions" diff --git a/tests/test_sdk_assumptions.py b/tests/test_sdk_assumptions.py index 3ab2042..c1afa3e 100644 --- a/tests/test_sdk_assumptions.py +++ b/tests/test_sdk_assumptions.py @@ -109,6 +109,10 @@ def test_create_session_accepts_provider_kwargs(self, sdk_module: Any) -> None: # pinning it here makes a future SDK rename/removal FAIL at this # signature guard instead of silently escaping to live/prod. "memory", + # v1.0.15 MinimalMode:MUST:17-18: the mode-gated + # `custom_agents_local_only` and `enable_experimental_mode` pins. + "custom_agents_local_only", + "enable_experimental_mode", } ) @@ -603,8 +607,8 @@ def test_fallback_allowlist_is_superset_of_sdk_reasoning_effort_literal( ``frozenset(get_args(ReasoningEffort))``: every SDK Literal member must be present, and the only permitted extras are in ``_KNOWN_EXTRAS``. - The provider allowlist extends the v1.0.7 SDK Literal with ``"max"`` - (advertised by the live list_models endpoint; absent from the v1.0.7 + The provider allowlist extends the v1.0.15 SDK Literal with ``"none"`` + (advertised by the live list_models endpoint; absent from the v1.0.15 SDK Literal). Only values documented in ``_KNOWN_EXTRAS`` are allowed to differ from the SDK Literal — any unreviewed addition is a bug. @@ -625,8 +629,8 @@ def test_fallback_allowlist_is_superset_of_sdk_reasoning_effort_literal( _REASONING_EFFORT_FALLBACK_ALLOWLIST, ) - # Values the provider forwards beyond the v1.0.7 SDK Literal. - _KNOWN_EXTRAS: frozenset[str] = frozenset({"none", "max"}) + # Values the provider forwards beyond the v1.0.15 SDK Literal. + _KNOWN_EXTRAS: frozenset[str] = frozenset({"none"}) sdk_members = frozenset(get_args(ReasoningEffort)) @@ -666,10 +670,10 @@ def test_reasoning_effort_levels_are_token_safe(self, sdk_module: Any) -> None: f"'/'-joined reasoning= token is unambiguous; offenders: {bad!r}" ) - def test_reasoning_effort_type_is_literal_4_values(self, sdk_module: Any) -> None: - """SDK ``ReasoningEffort`` MUST be a Literal with exactly 4 members. + def test_reasoning_effort_type_is_literal_5_values(self, sdk_module: Any) -> None: + """SDK ``ReasoningEffort`` MUST be a Literal with exactly 5 members. - Pins the v1.0.7 SDK surface {low, medium, high, xhigh}. The test goes + Pins the v1.0.15 SDK surface {low, medium, high, xhigh, max}. The test goes red if the installed SDK's ``ReasoningEffort`` Literal differs from that set, signalling that the provider allowlist and Layer-1 gate need re-evaluation against the changed SDK surface. @@ -680,7 +684,7 @@ def test_reasoning_effort_type_is_literal_4_values(self, sdk_module: Any) -> Non from copilot.client import ReasoningEffort # type: ignore[import-untyped] - expected = frozenset({"low", "medium", "high", "xhigh"}) + expected = frozenset({"low", "medium", "high", "xhigh", "max"}) actual = frozenset(get_args(ReasoningEffort)) assert get_origin(ReasoningEffort) is Literal, ( f"ReasoningEffort is no longer a Literal; got {ReasoningEffort!r}. " @@ -694,38 +698,9 @@ def test_reasoning_effort_type_is_literal_4_values(self, sdk_module: Any) -> Non f"Follow the migration steps in this test's docstring." ) - def test_max_support_in_live_sdk_but_not_in_literal(self, sdk_module: Any) -> None: - """Pins that ``"max"`` is advertised by the live GitHub Copilot endpoint - and is absent from the v1.0.7 SDK ``ReasoningEffort`` Literal. - - The provider's ``_REASONING_EFFORT_FALLBACK_ALLOWLIST`` includes ``"max"`` - so cache-miss paths forward it to the SDK's Layer-2 backstop instead of - raising a ``ConfigurationError``. - - Contract: provider-protocol:complete:MUST:11 (provider fallback allowlist) - """ - from typing import get_args - - from copilot.client import ReasoningEffort # type: ignore[import-untyped] - - from amplifier_module_provider_github_copilot.request_adapter import ( - _REASONING_EFFORT_FALLBACK_ALLOWLIST, - ) - - sdk_members = frozenset(get_args(ReasoningEffort)) - assert "max" not in sdk_members, ( - "SDK ReasoningEffort now includes 'max'. Delete this test, remove " - "'max' from _KNOWN_EXTRAS in test_fallback_allowlist_is_superset*, " - "and sync contracts/provider-protocol.md MUST:11." - ) - assert "max" in _REASONING_EFFORT_FALLBACK_ALLOWLIST, ( - "_REASONING_EFFORT_FALLBACK_ALLOWLIST must contain 'max'; the v1.0.7 " - "fallback path forwards it to the SDK Layer-2 backstop." - ) - def test_none_support_in_live_sdk_but_not_in_literal(self, sdk_module: Any) -> None: """Pins that ``"none"`` is advertised by the live GitHub Copilot endpoint - and is absent from the v1.0.7 SDK ``ReasoningEffort`` Literal. + and is absent from the v1.0.15 SDK ``ReasoningEffort`` Literal. The provider's ``_REASONING_EFFORT_FALLBACK_ALLOWLIST`` includes ``"none"`` so cache-miss paths forward it to the SDK's Layer-2 backstop instead of @@ -748,7 +723,7 @@ def test_none_support_in_live_sdk_but_not_in_literal(self, sdk_module: Any) -> N "and sync contracts/provider-protocol.md MUST:11." ) assert "none" in _REASONING_EFFORT_FALLBACK_ALLOWLIST, ( - "_REASONING_EFFORT_FALLBACK_ALLOWLIST must contain 'none'; the v1.0.7 " + "_REASONING_EFFORT_FALLBACK_ALLOWLIST must contain 'none'; the v1.0.15 " "fallback path forwards it to the SDK Layer-2 backstop." ) @@ -861,8 +836,9 @@ def test_workspace_path_is_cached_property(self) -> None: @pytest.mark.sdk_assumption class TestCopilotSessionSendSignaturePin: """``CopilotSession.send`` MUST accept exactly one positional argument - (``prompt``) and five keyword-only arguments (``attachments``, ``mode``, - ``agent_mode``, ``request_headers``, ``display_prompt``). The provider's + (``prompt``) and seven keyword-only arguments (``attachments``, + ``source``, ``mode``, ``agent_mode``, ``request_headers``, + ``display_prompt``, ``response_schema``). The provider's ``CopilotClientWrapper`` calls ``session.send(prompt, attachments=...)``; the in-tree mock at ``tests/fixtures/sdk_mocks.py`` mirrors the full surface so unit tests don't drift from production behaviour. If the SDK @@ -905,7 +881,15 @@ def test_send_signature_matches_v030_surface(self) -> None: keyword_names = frozenset(p.name for p in keyword_only) expected = frozenset( - {"agent_mode", "attachments", "display_prompt", "mode", "request_headers"} + { + "agent_mode", + "attachments", + "display_prompt", + "mode", + "request_headers", + "response_schema", + "source", + } ) assert keyword_names == expected, ( f"CopilotSession.send keyword-only parameters drifted.\n" diff --git a/tests/test_sdk_boundary_contract.py b/tests/test_sdk_boundary_contract.py index d7e29ee..fe73b68 100644 --- a/tests/test_sdk_boundary_contract.py +++ b/tests/test_sdk_boundary_contract.py @@ -299,6 +299,7 @@ async def test_toolspec_objects_converted_correctly(self) -> None: assert tool.handler is None, "handler MUST be None for SDK to skip registration" assert tool.defer is None, "defer MUST default to None (v1.0.1 wire parity)" assert tool.metadata is None, "metadata MUST default to None (v1.0.7 wire parity)" + assert tool.is_terminal is False, "is_terminal MUST default to False (v1.0.15 wire parity)" def test_wrapper_survives_sdk_v102_tool_definition_build(self) -> None: """Reproduce the EXACT attribute-read loop SDK v1.0.2 uses to build tool @@ -417,6 +418,66 @@ def test_wrapper_survives_sdk_v107_tool_definition_build(self) -> None: "metadata=None MUST omit the 'metadata' wire key (v1.0.6 wire parity)" ) + def test_wrapper_survives_sdk_v1015_tool_definition_build(self) -> None: + """Reproduce the EXACT attribute-read loop SDK v1.0.15 uses to build tool + definitions, proving SDKToolWrapper exposes ``is_terminal`` and preserves + the pre-v1.0.15 wire shape when it is False. + + Contract: sdk-boundary:ToolForwarding:MUST:2 + + SDK v1.0.15 ``copilot/client.py`` extends the v1.0.7 loop with a trailing + ``tool.is_terminal`` read, in BOTH create_session and resume, immediately + after the ``metadata`` read:: + + if tool.metadata is not None: definition["metadata"] = tool.metadata + if tool.is_terminal: definition["isTerminal"] = True + + v1.0.15 ADDED the ``tool.is_terminal`` access. A wrapper missing + ``is_terminal`` raises ``AttributeError`` on every tool-forwarding turn, + the same failure ``defer`` (v1.0.2) and ``metadata`` (v1.0.7) caused. This + test pins the wrapper against the v1.0.15 access sequence and asserts no + ``isTerminal`` key is emitted: the provider never asks the runtime to end + the turn on a tool call, because Amplifier's orchestrator owns tool + execution (deny-destroy:NoExecution:MUST:3). + """ + from amplifier_module_provider_github_copilot.sdk_adapter.types import ( + convert_tools_for_sdk, + ) + + tools: list[dict[str, object]] = [ + {"name": "bash", "description": "Run shell commands", "parameters": {"type": "object"}}, + {"name": "read_file", "description": "Read a file", "parameters": None}, + ] + wrappers = convert_tools_for_sdk(tools) + + for tool in wrappers: + # Verbatim replication of the SDK v1.0.15 definition-build loop. If the + # wrapper is missing any read attribute this raises AttributeError, + # exactly as the live runtime would without `is_terminal`. + definition: dict[str, object] = { + "name": tool.name, + "description": tool.description, + } + if tool.parameters: + definition["parameters"] = tool.parameters + if tool.overrides_built_in_tool: + definition["overridesBuiltInTool"] = True + if tool.skip_permission: + definition["skipPermission"] = True + if tool.defer is not None: + definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata + if tool.is_terminal: + definition["isTerminal"] = True + + # v1.0.7 wire parity: is_terminal defaults to False, so the key is + # omitted and the tool-forwarding payload is byte-identical to + # pre-v1.0.15. + assert "isTerminal" not in definition, ( + "is_terminal=False MUST omit the 'isTerminal' wire key (v1.0.7 wire parity)" + ) + @pytest.mark.asyncio async def test_execute_sdk_completion_accepts_tools_param(self) -> None: """_execute_sdk_completion MUST forward tools to SDK session. @@ -622,6 +683,9 @@ async def test_no_unexpected_keys_in_config(self) -> None: "mcp_oauth_token_storage", # MUST:16 — v1.0.2 mode-gated 'memory' pin (sdk-boundary). "memory", + # MUST:17-18: v1.0.15 mode-gated pins (sdk-boundary). + "custom_agents_local_only", + "enable_experimental_mode", } mock_client = ConfigCapturingMock() @@ -944,6 +1008,43 @@ async def test_memory_disabled(self) -> None: config = mock_client.last_config assert config["memory"] == {"enabled": False} + @pytest.mark.asyncio + async def test_custom_agents_local_only_enabled(self) -> None: + """Contract: sdk-boundary:MinimalMode:MUST:17 (v1.0.15). + + Custom-agent loading pinned to local definitions only; Amplifier + orchestrates agents (complements MUST:5 ``custom_agents=[]``). + ``custom_agents_local_only`` gained the mode-gated empty-mode default + helper ``_custom_agents_local_only_default`` in v1.0.15, which returns + ``True`` ONLY when ``mode == "empty"``. Our adapter ships + ``mode="copilot-cli"``, where the helper returns ``None``, so this + explicit pin IS the wire shape. Identity assertion (``is True``). + """ + mock_client = ConfigCapturingMock() + wrapper = CopilotClientWrapper(sdk_client=mock_client) + async with wrapper.session(model="gpt-4o"): + pass + config = mock_client.last_config + assert config["custom_agents_local_only"] is True + + @pytest.mark.asyncio + async def test_enable_experimental_mode_disabled(self) -> None: + """Contract: sdk-boundary:MinimalMode:MUST:18 (v1.0.15). + + SDK experimental features pinned OFF. ``enable_experimental_mode`` is a + v1.0.15 mode-gated ``create_session`` kwarg whose empty-mode default + helper ``_enable_experimental_mode_default`` returns ``False`` ONLY when + ``mode == "empty"``; under ``mode="copilot-cli"`` the runtime decides + when omitted, so this explicit pin IS the wire shape. Identity + assertion (``is False``). + """ + mock_client = ConfigCapturingMock() + wrapper = CopilotClientWrapper(sdk_client=mock_client) + async with wrapper.session(model="gpt-4o"): + pass + config = mock_client.last_config + assert config["enable_experimental_mode"] is False + @pytest.mark.asyncio async def test_enable_session_telemetry_disabled(self) -> None: """Contract: sdk-boundary:MinimalMode:MUST:14 (b10). @@ -962,7 +1063,7 @@ async def test_enable_session_telemetry_disabled(self) -> None: @pytest.mark.asyncio async def test_minimal_mode_pin_set_complete(self) -> None: - """Contract: sdk-boundary:MinimalMode:MUST:1-16 superset closure. + """Contract: sdk-boundary:MinimalMode:MUST:1-18 superset closure. Every MUST clause MUST be represented in the emitted session config — guards against accidental field removal or rename silently dropping a pin. @@ -990,11 +1091,14 @@ async def test_minimal_mode_pin_set_complete(self) -> None: "mcp_oauth_token_storage", # MUST:16 — v1.0.2 mode-gated 'memory' pin (sdk-boundary). "memory", + # MUST:17-18: v1.0.15 mode-gated pins (sdk-boundary). + "custom_agents_local_only", + "enable_experimental_mode", } missing = required - config_keys assert missing == set(), ( f"MinimalMode pins missing from session config: {missing}. " - f"Every MUST:1-16 clause must round-trip through the emit dict." + f"Every MUST:1-18 clause must round-trip through the emit dict." ) @pytest.mark.asyncio diff --git a/tests/test_sdk_error_translation_real_path.py b/tests/test_sdk_error_translation_real_path.py index e7b5000..98a8dbc 100644 --- a/tests/test_sdk_error_translation_real_path.py +++ b/tests/test_sdk_error_translation_real_path.py @@ -235,8 +235,8 @@ def test_yaml_config_has_context_window(self) -> None: # Key assertion: context_window must exist assert "context_window" in config.defaults - # claude-opus-4.5 with 200000 context window - assert config.defaults["context_window"] == 200000 + # Default model "auto" reports the 128000 policy fallback window + assert config.defaults["context_window"] == 128000 def test_yaml_config_budget_calculation_succeeds(self) -> None: """Budget calculation should work with YAML config values. diff --git a/tests/test_truncation_visibility.py b/tests/test_truncation_visibility.py index b62ec93..795afe9 100644 --- a/tests/test_truncation_visibility.py +++ b/tests/test_truncation_visibility.py @@ -237,7 +237,7 @@ async def _fake_ensure(caller: str = "session") -> Any: # noqa: ARG001 async def test_session_forwards_max_tokens_with_real_sdk_capability_types( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """Pin the max_tokens forward path against the REAL SDK 1.0.7 shapes. + """Pin the max_tokens forward path against the REAL SDK 1.0.15 shapes. The sibling test above uses stand-in dataclasses, which would silently keep passing if the SDK renamed/removed ``max_output_tokens`` or changed diff --git a/typings/copilot/session.pyi b/typings/copilot/session.pyi index 36a6a1c..ae337e7 100644 --- a/typings/copilot/session.pyi +++ b/typings/copilot/session.pyi @@ -61,27 +61,32 @@ class CopilotSession: def on( self, handler: Callable[[Any], None] ) -> Callable[[], None]: ... - # from b10 session.py:L1185-L1194 — five keyword-only kwargs (display_prompt - # added in b10; SDKSurface:MUST:6 pins the full set). + # from v1.0.15 session.py:L1759-L1770: seven keyword-only kwargs + # (display_prompt added in b10; source and response_schema added in v1.0.15; + # SDKSurface:MUST:6 pins the full set). async def send( self, prompt: str, *, attachments: list[Any] | None = None, + source: Any | None = None, mode: Any | None = None, agent_mode: Any | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: Any | None = None, ) -> str: ... async def send_and_wait( self, prompt: str, *, attachments: list[Any] | None = None, + source: Any | None = None, mode: Any | None = None, agent_mode: Any | None = None, request_headers: dict[str, str] | None = None, display_prompt: str | None = None, + response_schema: Any | None = None, timeout: float = 60.0, ) -> Any: ... async def disconnect(self) -> None: ... diff --git a/uv.lock b/uv.lock index dfbb358..1a521af 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-core" -version = "1.3.3" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -14,19 +14,17 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/9b/c1831daf9f19f819c552fe8a9fd1c11366413e543b1d2b41c60f910c4f1b/amplifier_core-1.3.3-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:eef372360758b28966bde5488422ce097b1c12cae2bdd5f0736dbc5eb19e6f6c", size = 8101195, upload-time = "2026-03-23T14:44:41.21Z" }, - { url = "https://files.pythonhosted.org/packages/3b/19/0348e6b9e7731f5d17ec15cfc155118f12602bd13be264f511d7d262d973/amplifier_core-1.3.3-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:514edd6fcb59299049730ecf7e5fc1760ac8be73b8ac35c72aafbebb1caf4e92", size = 7220842, upload-time = "2026-03-23T14:44:43.829Z" }, - { url = "https://files.pythonhosted.org/packages/3b/bc/80abe390d5023b2bedb0e79235075c43dc6dfd93fa5d6819f674157a71fb/amplifier_core-1.3.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e6b1ce4c39993d1aa510719051d1864726ba603179a0ed830180d3b6798cc55", size = 7599714, upload-time = "2026-03-23T14:44:46.855Z" }, - { url = "https://files.pythonhosted.org/packages/16/03/7299e57a263f6ef3a77dc9fd94384f4b9f7e44111a0119037c1b0ea25e69/amplifier_core-1.3.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09adf35c52461b297b1aef119dda6c8b209af1b2eb82bd00702c228db8458937", size = 8637800, upload-time = "2026-03-23T14:44:49.138Z" }, - { url = "https://files.pythonhosted.org/packages/85/62/09d8c57fd97bd9b9e5851da13cd902fc431c80d7a364476b8aae70f2b2c0/amplifier_core-1.3.3-cp311-abi3-win_amd64.whl", hash = "sha256:c09cadb6fe104645386742ebef893f549ffa779f7acc7ec42d5bd949348f2eea", size = 8894201, upload-time = "2026-03-23T14:44:51.892Z" }, - { url = "https://files.pythonhosted.org/packages/63/80/816f0aec391e20218340fe108c95208e236297c80903d58a9290d83c902c/amplifier_core-1.3.3-cp311-abi3-win_arm64.whl", hash = "sha256:263c43010e1e78668ff34ecddfa0444727bd9a8787d8dc2286a8d3fa619b48de", size = 7666984, upload-time = "2026-03-23T14:44:54.508Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9a/61003612d9c94b22dd4470f0fe82c1e68f52e7868c8f76b71cdc76471665/amplifier_core-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:883779eb5acf298048a6afa3178a59cfc29d1f05c413d8446e0f6f2340cda794", size = 7659998, upload-time = "2026-03-23T14:44:56.638Z" }, - { url = "https://files.pythonhosted.org/packages/36/36/ff422fab371c63bde16c8fb2f913265a9e2d2fef61bcce665a5d59124907/amplifier_core-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2407881ca22feb23f92f7b2606b7b5e99f7f4fccf8b9908dad6c0640ee6a3f64", size = 7663014, upload-time = "2026-03-23T14:44:59.025Z" }, + { url = "https://files.pythonhosted.org/packages/b9/53/90edfa9ff6b213f0be37d1cd2ee49df7b644b7d19ac7c8d0331f1ff43775/amplifier_core-2.0.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:445f1e009d2411d55d6dac2ad88484844e688b2cb2901501096faae8a2ac8fdd", size = 8330603, upload-time = "2026-09-23T19:04:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/f865096994e1777274ffc13895efc0c70fb085f96f017083cf2bb6a17562/amplifier_core-2.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9aa0664c2b349e0d90cf20cb2e5e663c37cd54b04fd0cb9776f57ecf31f6ea4", size = 7424176, upload-time = "2026-09-23T19:04:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/fd2653b45a6aba6667ad13113713b7fabfd93200a7a655ecc81dcb7ed162/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2ac4f49bf2858d83bc6ab541ca3fc8042dd17807d25b97f598a1f06b7c305e7", size = 7775409, upload-time = "2026-09-23T19:04:51.956Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/a0360024275c1e214a5d65370ffdb87b35986bd6a1681e1e1c62e34197ac/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a8e7183260b738c32ca16b64f5aacc3e71de2c51918702eae5424ab4f07906", size = 8793506, upload-time = "2026-09-23T19:04:54.038Z" }, + { url = "https://files.pythonhosted.org/packages/42/c0/dbdd12f77d8ded4d76d0170606056d2f493d1d86d08db3b89b579d79904a/amplifier_core-2.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:7d3ea206b6d3f9537115a2afc8b99f5d9e34e9f3236fdc48d8dd6d2d8a631184", size = 9085723, upload-time = "2026-09-23T19:04:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/3042735327bc6fb6216b92632e1d096b6a312c2c0e8f6d111f1b259a56f7/amplifier_core-2.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:7834782f88b764ffb6374b734b74f2e55bc9e57e99197a1ed3d1ab8ae6562e89", size = 7798444, upload-time = "2026-09-23T19:04:58.493Z" }, ] [[package]] name = "amplifier-module-provider-github-copilot" -version = "2.7.0" +version = "2.8.0" source = { editable = "." } dependencies = [ { name = "github-copilot-sdk" }, @@ -46,8 +44,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-core", marker = "extra == 'dev'", specifier = ">=1.3.0" }, - { name = "github-copilot-sdk", specifier = "==1.0.7" }, + { name = "amplifier-core", marker = "extra == 'dev'", specifier = ">=2.0.0" }, + { name = "github-copilot-sdk", specifier = "==1.0.15" }, { name = "packaging", specifier = ">=20" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -204,7 +202,7 @@ toml = [ [[package]] name = "github-copilot-sdk" -version = "1.0.7" +version = "1.0.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -212,7 +210,7 @@ dependencies = [ { name = "python-dateutil" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/67/ec/734b0d28abbf7d26eb1a60810deedbcc0719910d64f018477010baf3e3f3/github_copilot_sdk-1.0.7-py3-none-any.whl", hash = "sha256:6f3e74e1779a4508dd6636e06fd0aa55eb6244805a66d61a4f21b30c958e2985", size = 421447, upload-time = "2026-07-16T15:21:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/e6e926b3219cb95a501ad87d038c0257cf636d238f81ea44da9ff3db68f2/github_copilot_sdk-1.0.15-py3-none-any.whl", hash = "sha256:419162ec98b4a818c1a0d617fea0dd08345c24c5f2d4c29f38091ebd1b33e630", size = 704307, upload-time = "2026-09-28T19:33:55.654Z" }, ] [[package]]