diff --git a/MIGRATION.md b/MIGRATION.md index 0746397..058d42a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,3 +1,87 @@ +# 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.14` (stable). +The move is **wire-compatible** — no public API, config key, env var, or CLI flag +changes, and no action is required on upgrade. + +The 5 quarantined SDK imports (`CopilotClient`, `ModelCapabilitiesOverride`, +`ModelLimitsOverride`, `PermissionDecisionReject`, `PermissionRequestResult`) are +unchanged in name, location and shape. Nothing was removed or renamed across the +`1.0.8 → 1.0.14` span: `CopilotClient.__init__`, `CopilotClient.create_session` +and `CopilotSession.send` are each purely additive (verified by signature +set-difference — removed == none for all three). + +Four SDK surface changes are absorbed by the provider: + +- **New `Tool.is_terminal` field.** SDK 1.0.14's `copilot.tools.Tool` dataclass + gains a trailing `is_terminal: bool = False` field, which `copilot/client.py` + reads by attribute in **both** `create_session` (`:2570`) and `resume` + (`:3355`) as `if tool.is_terminal:`. The provider hands the SDK a duck-typed + `SDKToolWrapper`, so the wrapper now mirrors that field with a `False` default. + Note this read is a **truthiness** gate, not the `is not None` test used for + `defer` and `metadata`: `False` **omits** the `isTerminal` key from the wire + payload, keeping the tool-forwarding request byte-identical to 1.0.7. This is + the same shape as the v1.0.2 `defer` and v1.0.7 `metadata` field additions. + Without it, every tool-forwarding turn would raise `AttributeError`. +- **Two new mode-gated capabilities**, both pinned (MinimalMode MUST:17-18): + `custom_agents_local_only=True` and `enable_experimental_mode=False`. Each + mirrors the SDK's own empty-mode default. Under `mode="copilot-cli"` the + empty-mode helpers return `None`, so leaving either unset would hand the + setting to the bundled CLI — for `enable_experimental_mode` that means a CLI + upgrade could silently switch experimental behavior on underneath the provider. +- **`ReasoningEffort` Literal grows to 5 members.** `"max"` is now SDK-native; it + was a documented provider fallback-allowlist extra through 1.0.7. **The + provider's forwarded set is unchanged** — `"max"` simply moved from + provider-extra to SDK-native, and `"none"` remains the sole remaining extra. +- **27 new `SessionEventType` members**, all classified **DROP** (no kernel domain + mapping): the fusion pipeline (`session.fusion_*`, `assistant.fusion_phase_*`), + auto-tier advisories, `model.call_start`/`model.call_finished`, + `assistant.turn_retry`, `agent.interrupted`, four `session.*` state notices, two + MCP server-lifecycle events, `factory.run_*`, plus `prompt_cache_break`, + `tool_search.activated`, `sandbox.decision` and `ui.ephemeral_query`. See + `config/data/events.yaml` for the per-family reasoning. + +`CopilotSession.send` also gains a keyword-only `source` parameter. The provider +does not pass it, so the SDK default preserves pre-1.0.14 wire behavior; the +signature pin was widened to record the true surface. + +### Also in this release: dev-dependency floor `amplifier-core>=2.0.0` + +**Contributors only — no runtime impact.** `amplifier-core` is injected by the +Amplifier runtime when the module is loaded; it is declared under +`[project.optional-dependencies] dev` for type hints and tests, not installed at +runtime by this package. + +The floor moves `1.3.0 → 2.0.0` to match what the CLI actually ships (**2.0.1**). +The previous constraint was an unbounded `>=1.3.0`, so `uv.lock` resolved +**1.3.3** — meaning the test suite validated against 1.3.3 while production ran +2.0.1, a full major version apart, with nothing reporting the divergence. The +lock is regenerated to 2.0.1 and the suite is green against it (1586 passed). + +**Action required:** none for users. Contributors with an existing dev +environment should re-sync (`uv sync --extra dev`) to pick up `amplifier-core` +2.x. + +--- + +## What Changed: SDK requirement is now `github-copilot-sdk==1.0.14` + +- **What:** The provider now pins `github-copilot-sdk==1.0.14` (was `==1.0.7`). + The import-time support floor is unchanged (`>=1.0.0`; pre-GA beta SDKs remain + unsupported). The `1.0.7 → 1.0.14` move is **non-breaking** for the provider's + wire behavior — `Tool.is_terminal` defaults to `False`, which reproduces the + 1.0.7 tool payload exactly, and the two new MinimalMode pins mirror the SDK's + own empty-mode defaults. +- **Action required:** none. A clean `pip install` of `2.8.0` pulls `1.0.14` + 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/amplifier_module_provider_github_copilot/__init__.py b/amplifier_module_provider_github_copilot/__init__.py index c471d09..354bc05 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.14``) 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.14 (pyproject.toml). " + "Reinstall with: pip install 'github-copilot-sdk==1.0.14' " 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.14'" ) 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/_sdk_protection.py b/amplifier_module_provider_github_copilot/config/_sdk_protection.py index 03bb737..54982d8 100644 --- a/amplifier_module_provider_github_copilot/config/_sdk_protection.py +++ b/amplifier_module_provider_github_copilot/config/_sdk_protection.py @@ -113,6 +113,27 @@ class MinimalModeConfig: # sdk-boundary:MinimalMode SDK-introspection closure). memory: dict[str, Any] = field(default_factory=lambda: {"enabled": False}) + # MUST:17 — Confine custom-agent resolution to the local session; never let + # the SDK resolve agents from shared/remote sources. Added at v1.0.14 as a + # new mode-gated create_session kwarg with empty-mode default helper + # `_custom_agents_local_only_default` returning True ONLY when + # `mode == "empty"`. Our adapter ships `mode="copilot-cli"`, where the + # helper returns None and the bundled-CLI default would otherwise apply — + # so this explicit pin IS the wire shape. Complements MUST:5 (which only + # empties the custom_agents list). Value mirrors the SDK empty-mode default + # exactly (contract: sdk-boundary:MinimalMode SDK-introspection closure). + custom_agents_local_only: bool = True + + # MUST:18 — Keep SDK experimental features off. Added at v1.0.14 as a new + # mode-gated create_session kwarg with empty-mode default helper + # `_enable_experimental_mode_default` returning False ONLY when + # `mode == "empty"`. Under `mode="copilot-cli"` the helper returns None and + # the bundled-CLI default applies, so without this pin a CLI upgrade could + # silently switch experimental behavior on underneath the provider — the + # wire shape must be owned here, not inherited. Value mirrors the SDK + # empty-mode default exactly. + enable_experimental_mode: bool = False + @dataclass(frozen=True) class ToolCaptureConfig: diff --git a/amplifier_module_provider_github_copilot/config/data/events.yaml b/amplifier_module_provider_github_copilot/config/data/events.yaml index 1733d73..a748118 100644 --- a/amplifier_module_provider_github_copilot/config/data/events.yaml +++ b/amplifier_module_provider_github_copilot/config/data/events.yaml @@ -245,6 +245,79 @@ event_classifications: - mcp.tools.list_changed - mcp.resources.list_changed - mcp.prompts.list_changed + # SDK v1.0.14 (bundled CLI 1.0.77) — 27 new SessionEventType enum members. + # All DROP, for the same standing reason as the v1.0.7 block above: the + # provider's domain surface is single-completion + tool-call + usage + error + # + content-deltas. None of the members below carry a kernel domain mapping, + # and each is a sibling of an already-dropped family. + # + # Fusion pipeline (SDK-internal multi-model routing; the provider selects + # models explicitly and never drives fusion — siblings of the already + # dropped session.auto_mode_resolved / auto_mode_switch.* analytics): + - session.fusion_route_started + - session.fusion_route_failed + - session.fusion_resolved + - session.fusion_completed + - assistant.fusion_phase_started + - assistant.fusion_phase_activity + - assistant.fusion_phase_completed + - assistant.fusion_phase_failed + # + # Auto-tier advisories (model/tier selection is Amplifier's; these are + # recommendation + failure notices, siblings of session.auto_mode_resolved): + - session.auto_tier_recommendation + - session.auto_tier_switch_failed + # + # Model call lifecycle — start/finish granularity for an upstream model + # call. Dropped for the SAME reason as model.call_failure above: the + # provider surfaces completion via its own turn accounting and errors via + # the session.error → ERROR bridge; bridging these would double-emit. + - model.call_start + - model.call_finished + # + # Turn/agent interruption + retry markers. Amplifier owns turn control and + # carries its own retry policy (retry_utils.py); these are SDK-internal + # progress telemetry, siblings of assistant.idle / assistant.turn_*. + - assistant.turn_retry + - agent.interrupted + # + # Session lifecycle/state notices with no kernel domain mapping — siblings + # of the session.context_changed / session.info / session.mode_changed / + # session.managed_settings_resolved entries already dropped above. + # (session.context_cleared in particular: Amplifier owns context + # construction, so an SDK-side clear notice carries no domain value.) + - session.context_cleared + - session.completion_receipt + - session.mode_notice_delivered + - session.managed_settings_enforced + # + # MCP server lifecycle — siblings of the already-dropped + # session.mcp_server_status_changed / session.mcp_servers_loaded. + # The provider pins mcp_servers to {} (MinimalMode MUST:3). + - session.mcp_server_removed + - session.mcp_server_needs_reconnect + # + # Factory run lifecycle (SDK-internal background run bookkeeping; + # siblings of the dropped session.background_tasks_changed). + - factory.run_started + - factory.run_updated + - factory.run_settled + # + # Remaining singletons: + # prompt_cache_break — upstream prompt-cache boundary marker; a + # performance/telemetry signal with no domain type. + # tool_search.activated — SDK lazy tool-search activation. The provider + # pre-loads all tools and pins defer=None, so this never fires in + # practice; classified explicitly rather than left to fall through. + # sandbox.decision — SDK-side sandbox policy decision. Amplifier + # registers a single deny-all hook and owns permissioning, so this is + # a sibling of the dropped permission.* family. + # ui.ephemeral_query — host UI prompt surface; the provider hosts no UI + # (sibling of the dropped elicitation.* / user_input.* families). + - prompt_cache_break + - tool_search.activated + - sandbox.decision + - ui.ephemeral_query 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..7e0ed14 100644 --- a/amplifier_module_provider_github_copilot/request_adapter.py +++ b/amplifier_module_provider_github_copilot/request_adapter.py @@ -33,10 +33,12 @@ # 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.14 SDK +# ReasoningEffort Literal {low,medium,high,xhigh,max}: the live list_models +# endpoint additionally advertises "none", which the v1.0.14 SDK Literal does +# not enumerate. ("max" was also such an extra through v1.0.7 and became +# SDK-native at v1.0.14 — the provider's forwarded set is unchanged.) +# 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") diff --git a/amplifier_module_provider_github_copilot/sdk_adapter/client.py b/amplifier_module_provider_github_copilot/sdk_adapter/client.py index 0eace0c..2bbf389 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.14 mode-gated kwargs. See _sdk_protection.py for + # mode="copilot-cli" rationale (empty-mode default helpers do not fire). + "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..292498d 100644 --- a/amplifier_module_provider_github_copilot/sdk_adapter/types.py +++ b/amplifier_module_provider_github_copilot/sdk_adapter/types.py @@ -231,6 +231,20 @@ class SDKToolWrapper: # on every tool-forwarding turn. # Contract: sdk-boundary:ToolForwarding:MUST:2 metadata: dict[str, Any] | None = None + # SDK v1.0.14 reads tool.is_terminal when building tool definitions in BOTH + # create_session and resume (copilot/client.py:2570 and :3355, + # `if tool.is_terminal: definition["isTerminal"] = True`, immediately after + # the `metadata` block). Mirrors the SDK's own copilot.tools.Tool.is_terminal + # field type. Note this is a TRUTHINESS gate, not `is not None` like `defer` + # and `metadata` above: False = the key is omitted from the wire payload, + # which is the exact pre-v1.0.14 behavior. + # A terminal tool ends the agent turn on a successful call. Amplifier owns + # turn control at the kernel layer and never delegates it to the SDK, so + # this stays False. Without this attribute the SDK raises AttributeError on + # every tool-forwarding turn (same shape as the v1.0.2 `defer` and v1.0.7 + # `metadata` regressions). + # Contract: sdk-boundary:ToolForwarding:MUST:2 + is_terminal: bool = False def convert_tools_for_sdk(tools: list[Any]) -> list[SDKToolWrapper]: diff --git a/contracts/provider-protocol.md b/contracts/provider-protocol.md index b28978d..9c9becb 100644 --- a/contracts/provider-protocol.md +++ b/contracts/provider-protocol.md @@ -5,7 +5,7 @@ - **Module Reference:** amplifier_module_provider_github_copilot/provider.py - **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-24 — `complete:MUST:11`: SDK bumped v1.0.7 → v1.0.14, whose `ReasoningEffort` Literal grows from 4 members to 5 — `{low,medium,high,xhigh}` → `{low,medium,high,xhigh,max}`. **No behavioural change and no change to the provider's forwarded set:** the provider fallback allowlist remains `{none,low,medium,high,xhigh,max}` (== `REASONING_EFFORT_LEVELS`), and `"max"` — previously forwarded as a documented allowlist extra absent from the SDK Literal — is now an SDK-native member. `"none"` is therefore the SOLE remaining provider extra beyond the SDK Literal (still advertised by live `list_models` for some models, still gated per-model by `supported_reasoning_efforts`). The now-unsatisfiable pin `test_max_support_in_live_sdk_but_not_in_literal` was deleted per its own migration instruction; `"max"` is now covered by the SDK-Literal membership pin (`test_reasoning_effort_type_is_literal_5_values`) and by the superset test, whose `_KNOWN_EXTRAS` narrows to `{"none"}`. The resolved model's advertised `supported_reasoning_efforts` remains authoritative and still WIDENS acceptance beyond the static allowlist. Prior: 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). --- diff --git a/contracts/sdk-boundary.md b/contracts/sdk-boundary.md index 7f07398..e23be3c 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.14 — ToolForwarding hardened: `SDKToolWrapper.is_terminal` added to mirror the SDK's new `copilot.tools.Tool.is_terminal` field, read during tool-definition serialization on both the create and resume paths; MinimalMode extended to MUST:1-18) - **Module Reference:** amplifier_module_provider_github_copilot/sdk_adapter/ - **Status:** Non-Negotiable Constraint +- **Update (v1.15):** 2026-09-24 — SDK bumped v1.0.7 → v1.0.14 (stable; bundled CLI 1.0.77). The 5 quarantined imports are unchanged in name, location and shape, and the `1.0.8 → 1.0.14` span removes/renames NOTHING: `CopilotClient.__init__`, `create_session` and `CopilotSession.send` are each purely additive (verified by signature set-difference — removed == none for all three). Two hardening changes. (1) ToolForwarding: the SDK's `copilot.tools.Tool` gained `is_terminal: bool = False` (installed v1.0.14 `tools.py:93`), read when building tool wire-definitions on BOTH paths — create (`client.py:2570`) and resume (`client.py:3355`): `if tool.is_terminal: definition["isTerminal"] = True`. The duck-typed `SDKToolWrapper` lacked it, so every tool-forwarding turn would raise `AttributeError` — the identical failure mode to `defer` (v1.12) and `metadata` (v1.14). Note the guard is a bare TRUTHINESS read, unlike the `is not None` tests used by its two predecessors; `False` default omits the `"isTerminal"` wire key (byte-identical pre-v1.0.14 payload). Amplifier owns turn control at the kernel layer and never delegates turn termination to the SDK. (2) MinimalMode extended from MUST:1-16 to MUST:1-18: v1.0.14 adds two mode-gated `create_session` capabilities — `custom_agents_local_only` (empty-mode default `True`) and `enable_experimental_mode` (empty-mode default `False`). Under `mode="copilot-cli"` both helpers return `None`, so unpinned they inherit bundled-CLI defaults; `enable_experimental_mode` in particular means a CLI upgrade could silently switch experimental behavior on underneath the provider. Both pinned to mirror the SDK's own empty-mode defaults. Companion changes: `ReasoningEffort` grows to 5 members (`"max"` becomes SDK-native — the provider's forwarded set is UNCHANGED, `"max"` merely moves from documented fallback-allowlist extra to SDK member; `"none"` remains the sole extra), and 27 new session-event types classified DROP (config-driven, event-vocabulary). - **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.14: ToolForwarding:MUST:2 extended — `SDKToolWrapper` gains `is_terminal: bool = False`, mirroring the SDK's own `copilot.tools.Tool.is_terminal` (installed v1.0.14 `tools.py:93`). The SDK reads `tool.is_terminal` building tool wire-definitions on BOTH the create (`client.py:2570`) and resume (`client.py:3355`) paths; the duck-typed wrapper lacking it would raise `AttributeError` on every tool-forwarding turn — the identical failure mode to `defer` (v1.12) and `metadata` (v1.14), unreachable by the mocked unit suite or tool-less live smoke, only by an E2E exercising the real tool-definition builder. The read is a bare truthiness gate (NOT `is not None`, unlike its two predecessors), so the `False` default omits the `"isTerminal"` wire key (byte-identical pre-v1.0.14 payload); Amplifier owns turn control at the kernel layer and never delegates turn termination. MinimalMode:MUST:17-18 added — `custom_agents_local_only=True` and `enable_experimental_mode=False`, the 11th and 12th SDK mode-gated capability defaults, each mirroring the SDK empty-mode default; under `mode="copilot-cli"` both helpers return `None` so the bundled-CLI default would otherwise apply. 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_v1014_tool_definition_build` replays the v1.0.14 definition loop and pins `is_terminal False ⇒ key omitted`; `test_no_unpinned_sdk_mode_gated_capability` runtime-enumerates `_mode` so a future 13th default fails loudly. Companion changes: `ReasoningEffort` Literal grows to 5 members (`"max"` SDK-native; provider forwarded set unchanged) and 27 new v1.0.14 session-event types classified DROP (config-driven, event-vocabulary), pinned in `tests/test_event_classifications.py`. - **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. @@ -480,6 +482,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` | Confines SDK custom-agent resolution to the local session — never resolves agents from shared/remote sources. Complements MUST:5 (`custom_agents=[]`), which only empties the list. Pinned because `mode="copilot-cli"` does not invoke the empty-mode default helper (`_custom_agents_local_only_default` returns `True` only when `mode == "empty"`); mirrors that empty-mode default exactly. | +| sdk-boundary:MinimalMode:MUST:18 | `enable_experimental_mode` MUST be set to `False` | Keeps SDK experimental features off — Amplifier owns the provider's behavioral surface. Pinned because `mode="copilot-cli"` does not invoke the empty-mode default helper (`_enable_experimental_mode_default` returns `False` only when `mode == "empty"`); unpinned, a bundled-CLI upgrade could silently switch experimental behavior on underneath the provider. Mirrors the SDK empty-mode default exactly. | ### Test Anchors diff --git a/pyproject.toml b/pyproject.toml index 1234e98..a24cc40 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,35 @@ 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.14 (stable; bundled CLI 1.0.77): the 5 quarantined SDK imports + # (CopilotClient, ModelCapabilitiesOverride, ModelLimitsOverride, + # PermissionDecisionReject, PermissionRequestResult) are UNCHANGED in + # name, location and shape. Nothing was removed or renamed across + # 1.0.8 -> 1.0.14: `CopilotClient.__init__`, `create_session` and + # `CopilotSession.send` are all purely additive (verified by signature + # set-difference; REMOVED == none for each). Four absorbed changes: + # 1. `copilot.tools.Tool` gains a trailing `is_terminal: bool = False`, + # which `copilot/client.py` reads by attribute in BOTH create_session + # (:2570) and resume (:3355) as `if tool.is_terminal:`. 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` and v1.0.7 `metadata` regressions. NOTE this one is a + # truthiness gate, not `is not None`; False omits the wire key. + # 2. Two NEW mode-gated create_session capabilities -- + # `custom_agents_local_only` (empty-mode default True) and + # `enable_experimental_mode` (empty-mode default False). Both pinned + # (MinimalMode:MUST:17-18); under mode="copilot-cli" the empty-mode + # helpers return None, so unpinned they would inherit bundled-CLI + # defaults. + # 3. `ReasoningEffort` Literal grows to 5 members: "max" is now + # SDK-native (was a documented provider fallback-allowlist extra + # through 1.0.7). The provider's forwarded set is UNCHANGED; "none" + # remains the sole remaining extra. + # 4. 27 new SessionEventType members (all DROP; see + # config/data/events.yaml). `CopilotSession.send` gains keyword-only + # `source` (additive; the provider does not pass it, so the SDK + # default preserves pre-1.0.14 wire behavior). + "github-copilot-sdk==1.0.14", # PyYAML for config loading "pyyaml>=6.0", # PEP 440 version parser used by the import-time SDK floor check @@ -78,12 +106,24 @@ dev = [ "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", + # Floor bumped 1.3.0 → 2.0.0 to align with the runtime version actually + # injected by the Amplifier CLI, which ships **2.0.1** (verified on a live + # host and inside a clean DTU). The previous floor's rationale was stale in + # a way that mattered: it claimed alignment with a CLI shipping "1.4.x" and + # that message_models were "byte-identical across 1.2.6→1.4.1". Because the + # constraint was an unbounded `>=1.3.0`, uv.lock resolved **1.3.3** — so the + # entire test suite validated against 1.3.3 while production ran 2.0.1, a + # full major version apart, silently. Re-locked to 2.x so the tested surface + # is the shipped surface; suite verified green against 2.0.1 (1586 passed). + # + # NOTE (deliberately left for maintainer judgement): this floor is still + # unbounded, which is the exact shape that allowed the 1.x→2.x drift above + # to go unnoticed. A `,<3.0.0` cap would convert the next major-version + # divergence into a loud resolution failure instead of a silent one, + # consistent with how the SDK pin (`==`) and the mode-gated-capability test + # force conscious decisions. Not applied here: capping is a policy call for + # a dev-only dependency, and this change-set is an SDK bump. + "amplifier-core>=2.0.0", ] [project.entry-points."amplifier.modules"] diff --git a/tests/test_behaviors.py b/tests/test_behaviors.py index ea7feb7..6c1c4c8 100644 --- a/tests/test_behaviors.py +++ b/tests/test_behaviors.py @@ -459,7 +459,7 @@ 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.14" 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. diff --git a/tests/test_entry_point.py b/tests/test_entry_point.py index 12587fe..55f9f98 100644 --- a/tests/test_entry_point.py +++ b/tests/test_entry_point.py @@ -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.14" 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_sdk_assumptions.py b/tests/test_sdk_assumptions.py index 3ab2042..12f65fa 100644 --- a/tests/test_sdk_assumptions.py +++ b/tests/test_sdk_assumptions.py @@ -603,9 +603,10 @@ 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 - SDK Literal). Only values documented in + The provider allowlist extends the v1.0.14 SDK Literal with ``"none"`` + (advertised by the live list_models endpoint; absent from the v1.0.14 + SDK Literal). ``"max"`` was also such an extra through v1.0.7 and became + SDK-native at v1.0.14. Only values documented in ``_KNOWN_EXTRAS`` are allowed to differ from the SDK Literal — any unreviewed addition is a bug. @@ -625,8 +626,11 @@ 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.14 SDK Literal. + # ``"max"`` was a known extra through v1.0.7; v1.0.14 adds it to the SDK + # Literal, so it is now covered by the "every SDK member must be present" + # assertion below and is no longer an extra. ``"none"`` remains one. + _KNOWN_EXTRAS: frozenset[str] = frozenset({"none"}) sdk_members = frozenset(get_args(ReasoningEffort)) @@ -666,21 +670,28 @@ 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 - red if the installed SDK's ``ReasoningEffort`` Literal differs from that - set, signalling that the provider allowlist and Layer-1 gate need + Pins the v1.0.14 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. + History: v1.0.7 enumerated 4 members {low, medium, high, xhigh}; the + live list_models endpoint already advertised ``"max"``, which the + provider carried as a documented fallback-allowlist extra. v1.0.14 adds + ``"max"`` to the SDK Literal, closing that gap — the provider's + forwarded surface is unchanged, ``"max"`` merely moved from + provider-extra to SDK-native. ``"none"`` remains a provider extra. + Contract: sdk-boundary:SDKSurface:MUST:3 (pin SDK Literal surface) """ from typing import Literal, get_args, get_origin 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,34 +705,16 @@ 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." - ) + # NOTE: ``test_max_support_in_live_sdk_but_not_in_literal`` was deleted at the + # v1.0.7 → v1.0.14 SDK bump, per its own migration instruction. It pinned that + # ``"max"`` was advertised by the live endpoint but ABSENT from the SDK + # ``ReasoningEffort`` Literal. v1.0.14 adds ``"max"`` to the Literal, so the + # gap the test guarded no longer exists and the assertion is unsatisfiable by + # construction. ``"max"`` is now covered by + # ``test_reasoning_effort_type_is_literal_5_values`` (SDK-native membership) + # and by the superset test below (which no longer lists it as a known extra). + # The provider's forwarded surface did not change. ``"none"`` remains a + # provider extra and keeps its own pin, directly below. 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 @@ -904,8 +897,19 @@ def test_send_signature_matches_v030_surface(self) -> None: ) keyword_names = frozenset(p.name for p in keyword_only) + # v1.0.14 adds the keyword-only ``source`` parameter (purely additive; + # nothing was removed or renamed). The provider does not pass it — the + # SDK default preserves the pre-v1.0.14 wire behavior — but the pin must + # record the true surface so a future REMOVAL still goes red. expected = frozenset( - {"agent_mode", "attachments", "display_prompt", "mode", "request_headers"} + { + "agent_mode", + "attachments", + "display_prompt", + "mode", + "request_headers", + "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..e6ad07a 100644 --- a/tests/test_sdk_boundary_contract.py +++ b/tests/test_sdk_boundary_contract.py @@ -417,6 +417,70 @@ 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_v1014_tool_definition_build(self) -> None: + """Reproduce the EXACT attribute-read loop SDK v1.0.14 uses to build tool + definitions, proving SDKToolWrapper exposes ``is_terminal`` and preserves + the pre-v1.0.14 wire shape when it is False. + + Contract: sdk-boundary:ToolForwarding:MUST:2 + + SDK v1.0.14 ``copilot/client.py`` extends the v1.0.7 loop with a trailing + ``tool.is_terminal`` read, in BOTH create_session (client.py:2570) and + resume (client.py:3355), immediately after the ``metadata`` read:: + + if tool.metadata is not None: definition["metadata"] = tool.metadata + if tool.is_terminal: definition["isTerminal"] = True + + v1.0.14 ADDED the ``tool.is_terminal`` access. A wrapper missing + ``is_terminal`` raises ``AttributeError`` on every tool-forwarding turn — + the exact failure the ``defer`` field caused at v1.0.2 and ``metadata`` at + v1.0.7. + + NOTE the guard shape differs from its two predecessors: this is a bare + TRUTHINESS test, not ``is not None``. A wrapper defaulting ``is_terminal`` + to ``None`` would therefore also omit the key — but ``False`` is used to + mirror the SDK's own ``Tool.is_terminal: bool = False`` field type. The + provider's steady state is False: Amplifier owns turn control at the + kernel layer and never delegates turn termination to the SDK. + """ + 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.14 definition-build loop. If the + # wrapper is missing any read attribute this raises AttributeError, + # exactly as the live runtime would before `is_terminal` was added. + 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.14. + 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 +686,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.14 mode-gated pins (sdk-boundary). + "custom_agents_local_only", + "enable_experimental_mode", } mock_client = ConfigCapturingMock() @@ -990,11 +1057,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.14 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/uv.lock b/uv.lock index dfbb358..7344e3f 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.14" }, { 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.14" 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/ed/e2/4d8c622545f55f06773548cf86ffe60f59e6a4cedcc412ff84fcac244b2b/github_copilot_sdk-1.0.14-py3-none-any.whl", hash = "sha256:9f4538cec7295f24507c650d794307396458a51f280a0fbbcd840da177109050", size = 614607, upload-time = "2026-09-16T05:11:14.572Z" }, ] [[package]]