diff --git a/.github/skills/add-agent-binding/SKILL.md b/.github/skills/add-agent-binding/SKILL.md new file mode 100644 index 0000000..17306a0 --- /dev/null +++ b/.github/skills/add-agent-binding/SKILL.md @@ -0,0 +1,367 @@ +--- +name: add-agent-binding +description: 'Add a new SDK-backed Agent binding provider to azure-functions-python-extensions. USE WHEN: integrating another agent SDK, creating an AgentFunctionApp and markdown_agent decorator, injecting an SDK Agent/session type, forwarding SDK creation arguments, implementing provider lifecycle or capability adapters, adding Durable support, samples, tests, packaging, or CI wiring. NOT FOR: adding features to an existing provider without a new binding package, Connector SDK types (use add-connector-type), or application-only Agent samples.' +--- + +# Add an Agent Binding + +Use this workflow to add an SDK-backed provider package built on +`azurefunctions-agents-extensions-base`. + +The binding should let customers author a normal Azure Function while receiving +the SDK's native Agent or session type as an injected parameter. Keep SDK setup, +configuration discovery, lifecycle management, and Durable execution inside the +extension. + +## Reference Implementations + +Choose the nearest existing provider before writing code: + +| Provider | Use as a reference when | +| --- | --- | +| `azurefunctions-agents-extensions-agent-framework` | The SDK creates a fresh client and Agent for each invocation. | +| `azurefunctions-agents-extensions-copilot-sdk` | The SDK has a process-owning client that should be cached while sessions remain invocation-scoped. | +| `azurefunctions-agents-extensions-base` | The provider contract, discovery model, binding injection, and Durable bridge need clarification. | + +Do not copy a lifecycle merely because it exists. Determine what the target SDK +owns and how each object is safely closed. + +## Required Decisions + +Before editing, identify and record these decisions in the working notes or PR: + +| Decision | Questions to answer | +| --- | --- | +| Injected SDK type | What native SDK type should the Function parameter receive? Is it async-context-managed? | +| Client lifecycle | Is the client cheap and invocation-scoped, or a process/runtime owner that must be cached? Is construction thread-safe? | +| Invocation lifecycle | What fresh Agent/session/context is created per invocation, and how is it closed on success, failure, and cancellation? | +| Execution API | How does the native object execute a prompt, and how is the final text validated and returned from `run_agent()`? | +| Configuration | Which settings have secure defaults? Which require explicit environment variables or factories? | +| SDK arguments | Which `create_*` arguments should customers pass directly on `@app.markdown_agent(...)`? Which names are reserved by the extension? | +| Capabilities | Can the SDK support discovered Skills and remote HTTP MCP without changing their meaning or security? | +| Durable | Can a compiled binding execute from the shared Durable activity without relying on a live injected object? | + +If one of these is unknown, inspect the installed SDK signatures and lifecycle +documentation or run a focused probe before implementing it. + +## Package Shape + +Use the provider ID `{provider_id}` and normalized package suffix +`{provider-id}`: + +```text +azurefunctions-agents-extensions-{provider-id}/ +├── LICENSE +├── MANIFEST.in +├── README.md +├── pyproject.toml +├── azurefunctions/agents/extensions/{provider_module}/ +│ ├── __init__.py +│ ├── apps.py +│ ├── provider.py +│ └── py.typed +├── samples/ +└── tests/ +``` + +Add another module such as `options.py` only when it provides meaningful public +typing or separates substantial logic. Do not create abstractions solely to +match another provider's file count. + +## Procedure + +### 1. Define the Public Customer Experience + +Start with the intended Function code. The injected parameter should use the +SDK-native type: + +```python +from target_sdk import NativeAgent +from azurefunctions.agents.extensions.target import AgentFunctionApp + +app = AgentFunctionApp() + + +@app.route(route="orders", methods=["POST"]) +@app.markdown_agent( + arg_name="agent", + agent_name="orders", + streaming=True, + reasoning_effort="high", +) +async def process_order(req: func.HttpRequest, agent: NativeAgent) -> str: + ... +``` + +Use the SDK's names and types where practical. Avoid inventing an extension-only +configuration vocabulary for arguments the SDK already exposes. + +### 2. Register the Provider Package + +In `pyproject.toml`: + +- Name the distribution `azurefunctions-agents-extensions-{provider-id}`. +- Depend on `azurefunctions-agents-extensions-base` and the target SDK. +- Add `mcp`, `durable`, and `dev` extras only when applicable. +- Include `py.typed` as package data. +- Register a zero-argument provider factory: + +```toml +[project.entry-points."azurefunctions.agents.extensions.providers"] +{provider_id} = "azurefunctions.agents.extensions.{provider_module}.provider:create_provider" +``` + +The entry-point name, `provider_id`, and normalized distribution name must agree. + +### 3. Implement the Provider Contract + +In `provider.py`: + +1. Define a stable provider ID constant. +2. Implement an immutable compiled binding that satisfies `CompiledAgent`: + - `open_agent(invocation)` returns an async context manager yielding the + injected SDK-native object. + - `run_agent(prompt, invocation)` opens the same object, executes one prompt, + validates the SDK response, and returns text for Durable activities. +3. Implement `AgentProvider` with: + - `provider_id` + - `distribution_name` + - `supported_capabilities` + - `compile_binding(...)` +4. Validate the injected parameter annotation when one is present. Durable + compilation may provide `inspect.Signature.empty`, which must remain valid. +5. Validate factories synchronously and reject awaitable results with a clear + error rather than leaking an unawaited coroutine. +6. Reject unsupported provider options with a sorted, provider-specific error. +7. Return a zero-argument `create_provider()` factory. + +Keep compilation side-effect-free. It should validate and freeze a recipe, not +connect to networks or create invocation-scoped SDK objects. + +### 4. Design and Test Lifecycle Ownership + +Implement the lifecycle established in the Required Decisions section: + +- Use `AsyncExitStack` when several SDK, MCP, credential, or session contexts + must close together. +- Create fresh invocation state unless the SDK explicitly requires a shared + process owner. +- If a client is cached, make first creation thread-safe, validate its concrete + type, share it only at the intended app/binding scope, and keep sessions fresh. +- Close invocation objects on normal return, exception, and cancellation. +- Do not close a cached process client after every invocation. +- Document shutdown limitations if the host does not provide an awaited + application-lifetime hook. + +At minimum, tests must prove object counts, identity, entered/exited state, +concurrent first use when caching, and failure/cancellation cleanup. + +### 5. Expose SDK Creation Arguments Directly + +The decorator should accept the arguments customers would pass to the target +SDK's Agent/session creation method: + +```python +def markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory | None = None, + **sdk_options: Unpack[SdkCreationOptions], +) -> Callable[[_F], _F]: + ... +``` + +Follow these rules: + +- Keep `arg_name` and `agent_name` extension-owned. +- Reserve only options the extension must interpret, such as + `client_factory`, provider credentials, or custom tools. +- Forward all other supported SDK options unchanged to the SDK creation call. +- Apply customer values after extension defaults so explicit values win. +- Provide a `TypedDict` plus `Unpack` when the SDK does not publish a reusable + typed kwargs object. Match the installed SDK signature exactly. +- At runtime, compare option names with `inspect.signature(...)` or an equally + authoritative SDK surface and fail with a clear list of unsupported names. +- Do not silently drop unknown options. +- Test representative direct arguments, extension-default overrides, and an + unsupported argument. + +If an SDK argument collides with an extension-owned name, document and test the +chosen mapping. Prefer a top-level decorator argument when it is central to the +binding, while still passing the corresponding value to the SDK. + +### 6. Add `AgentFunctionApp` + +In `apps.py`: + +- Subclass `azure.functions.FunctionApp` through a provider-specific mixin. +- Configure the provider once with `configure_app(...)`. +- Keep `app_root` app-scoped; never expose it as a per-binding option. +- Put app-wide defaults on `AgentFunctionApp(...)`. +- Let decorator values override app defaults for only that binding. +- Delegate `orchestration_trigger(...)` to `durable_orchestration_trigger(...)` + when Durable is supported. +- Export only intentional public symbols from `__init__.py`. + +The provider package owns this app class. Do not require changes to +`azure.functions.FunctionApp` for a provider-specific API. + +### 7. Translate Discovered Capabilities + +Declare only capabilities the provider actually implements. Current neutral +capabilities are `skills` and `mcp`. + +For Skills: + +- Consume `SkillDefinition` paths supplied by the base package. +- Let the SDK load content when that preserves its native behavior. +- Do not rediscover or reinterpret app files in the provider. + +For MCP: + +- Consume `MCPServerDefinition` values from the base package. +- Preserve server names, remote HTTP URLs, tool allowlists, headers, and Entra + authentication semantics. +- Expand `$VAR` and `%VAR%` references at invocation time and report all missing + names clearly. +- Require HTTPS when credentials or headers leave loopback. +- Keep credentials and tokens out of logs and persisted config. +- Acquire and close credentials at the narrowest safe scope; refresh tokens per + request when the SDK transport permits it. + +If the SDK cannot preserve a capability contract, omit it from +`supported_capabilities`; the base package will reject the configuration. + +### 8. Add Durable Support + +When supported: + +- Add the base package's `durable` extra. +- Export `DurableAgentContext`. +- Add `orchestration_trigger(...)` to the provider app. +- Ensure `run_agent()` uses the same compiled instructions, options, and + capabilities as direct injection. +- Keep SDK calls in the generated activity, never in deterministic orchestrator + code. +- Test hidden activity registration, serialization, execution, failures, and + missing parameter annotations. + +Add a Durable sample with an HTTP starter, orchestrator, and Agent activity. +Run it with Core Tools and poll a real instance to a terminal state. + +### 9. Add Samples That Demonstrate the Value + +Provide a minimal non-Durable sample containing: + +- `function_app.py` +- `host.json` +- `local.settings.template.json` with placeholders, never secrets +- `requirements.txt` +- at least one raw `.agent.md` file +- Skill and `mcp.json` assets when those capabilities are supported +- a concise README with runtime provisioning and local-run steps + +Use the extension's defaults in the primary sample. Do not make customers +recreate the client factory merely to run the happy path. + +When the SDK setup is non-trivial, add a standalone direct-SDK script that +performs the same task. It should honestly show the authentication, client and +session lifecycle, instruction loading, capability wiring, option defaults, and +response validation that the binding removes. + +Add sample-indexing tests that import `function_app.py` in a subprocess and +assert the exact Function names without making live SDK calls. + +### 10. Document the Binding + +The package README must state: + +- The SDK-native type injected by the binding. +- What client is created by default and which settings it reads. +- Which object is cached and which object is fresh per invocation. +- That `@app.markdown_agent(...)` accepts SDK creation arguments directly, with + a short example such as `streaming=True` and `reasoning_effort="high"`. +- Reserved extension arguments and app-wide defaults. +- Permission/security defaults and when customers must replace them. +- Skill, MCP, Durable, authentication, and runtime-provisioning behavior. +- How to supply an advanced custom client factory. + +Favor customer behavior over internal helper type names. + +### 11. Add Tests + +Mirror the provider modules under `tests/`. Cover: + +- Public imports and package version. +- Provider entry-point discovery and metadata. +- Correct annotation acceptance and rejection. +- Required, defaulted, malformed, and unknown options. +- Direct forwarding of SDK creation arguments. +- App defaults and per-binding overrides. +- Native response extraction and malformed responses. +- Client and invocation lifecycle, including cancellation and concurrency. +- Skills and MCP mapping, allowlists, environment expansion, HTTPS rules, + authentication, and cleanup. +- Durable compilation and activity execution when supported. +- Sample indexing and standard setting names. + +Use SDK fakes for focused unit tests. Add a real local smoke test for the primary +sample and Durable workflow when credentials and emulators are available. + +### 12. Wire Build and Release Pipelines + +Add the package to all relevant matrices and jobs, not just the PR build: + +- `eng/templates/jobs/build.yml` +- `eng/templates/official/jobs/build-artifacts.yml` +- `eng/templates/official/jobs/unit-tests.yml` + +Install the editable base package before provider tests. Build both the source +distribution and wheel, and ensure package imports work from the built artifact. + +## Validation + +Run from the new package directory using its supported Python versions: + +```text +python -m pip install -U -e .[dev] +python -m flake8 azurefunctions tests samples +python -m mypy azurefunctions +python -m pytest -q --instafail tests +python -m build +python -m twine check dist/* +``` + +Adjust paths only to match established package configuration. Also run: + +- Sample indexing tests. +- A direct local invocation through Core Tools. +- A complete Durable orchestration when Durable is supported. +- `git diff --check` over the changed files. + +## PR Description + +Keep the PR summary customer-facing and concise. Include: + +1. The package and SDK being integrated. +2. The native SDK type injected into Functions. +3. The default client and client/session lifecycle. +4. That SDK creation arguments can be passed directly to + `@app.markdown_agent(...)`, with one short code example. +5. Supported Skills, MCP, Durable, samples, and validation. + +Do not lead with internal typing helpers or implementation-only classes. + +## Definition of Done + +- Provider package and entry point are discoverable. +- SDK-native injection works with a fresh invocation context. +- Client ownership and cleanup behavior are explicit and tested. +- SDK creation arguments are directly usable and unknown names fail clearly. +- Supported capabilities preserve the base package contracts. +- Direct and Durable execution share one compiled binding behavior. +- Samples show both the simple binding path and, when useful, direct SDK setup. +- README, tests, build, official artifacts, and official test pipelines are updated. +- Focused checks and relevant local end-to-end workflows pass. \ No newline at end of file diff --git a/README.md b/README.md index 1a25e21..837fbbe 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A supported Python version is required - see * [Base extension](azurefunctions-extensions-base/README.md) * [Agent provider base](azurefunctions-agents-extensions-base/README.md) * [Microsoft Agent Framework](azurefunctions-agents-extensions-agent-framework/README.md) +* [GitHub Copilot SDK](azurefunctions-agents-extensions-copilot-sdk/README.md) * [Azure Blob Storage bindings](azurefunctions-extensions-bindings-blob/README.md) * [Azure Cosmos DB bindings](azurefunctions-extensions-bindings-cosmosdb/README.md) * [Azure Event Hubs bindings](azurefunctions-extensions-bindings-eventhub/README.md) diff --git a/azurefunctions-agents-extensions-agent-framework/README.md b/azurefunctions-agents-extensions-agent-framework/README.md index eb41a60..d0fa900 100644 --- a/azurefunctions-agents-extensions-agent-framework/README.md +++ b/azurefunctions-agents-extensions-agent-framework/README.md @@ -1,6 +1,6 @@ # Azure Functions Microsoft Agent Framework Extension -Inject Microsoft Agent Framework Agents built from raw `.agent.md` instructions +Inject Microsoft Agent Framework Agents built from raw Markdown instructions into Python Azure Functions. ## Install @@ -54,9 +54,11 @@ async def process_order(req: func.HttpRequest, agent: Agent): or modifications. One app uses the Microsoft Agent Framework provider selected by this package. -Place the complete instructions at `orders.agent.md` or -`agents/orders.agent.md`. The file is raw UTF-8 text; no front matter or runtime -configuration is interpreted. +Place the complete instructions at `orders.agent.md`, `orders.md`, or the same +filename under `agents/`. The `.agent.md` suffix is recommended; plain `.md` is +also supported. The file is raw UTF-8 text; no front matter or runtime +configuration is interpreted. If multiple candidates exist, lookup fails as +ambiguous. ## Skills and MCP servers diff --git a/azurefunctions-agents-extensions-base/README.md b/azurefunctions-agents-extensions-base/README.md index 06f78e2..f306bc9 100644 --- a/azurefunctions-agents-extensions-base/README.md +++ b/azurefunctions-agents-extensions-base/README.md @@ -31,18 +31,21 @@ directory; decorators cannot override it. ## Markdown lookup -An `agent_name` resolves exactly one UTF-8 file: +An `agent_name` resolves exactly one UTF-8 file from these candidates: ```text /.agent.md +/.md /agents/.agent.md +/agents/.md ``` -The entire file is passed to the provider unchanged. Front matter, YAML, -substitutions, tools, skills, MCP configuration, and history are not parsed by -this package. If both locations exist, lookup fails as ambiguous. Absolute -paths, separators, traversal components, and symlinks outside `app_root` are -rejected. +The `.agent.md` suffix is recommended because it makes Agent instructions easy +to identify, but plain `.md` is also supported. The entire file is passed to the +provider unchanged. Front matter, YAML, substitutions, tools, skills, MCP +configuration, and history are not parsed by this package. If multiple +candidates exist, lookup fails as ambiguous. Absolute paths, separators, +traversal components, and symlinks outside `app_root` are rejected. ## Skills and MCP discovery diff --git a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py index ec892b8..c877078 100644 --- a/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py +++ b/azurefunctions-agents-extensions-base/azurefunctions/agents/extensions/base/bindings.py @@ -171,24 +171,27 @@ def _find_exact_file(directory: Path, expected_name: str) -> Path | None: def _resolve_instructions(app_root: Path, agent_name: str) -> str: - expected_name = f"{_validate_agent_name(agent_name)}.agent.md" + validated_name = _validate_agent_name(agent_name) + expected_names = ( + f"{validated_name}.agent.md", + f"{validated_name}.md", + ) matches = [ match - for match in ( - _find_exact_file(app_root, expected_name), - _find_exact_file(app_root / "agents", expected_name), - ) - if match is not None + for directory in (app_root, app_root / "agents") + for expected_name in expected_names + if (match := _find_exact_file(directory, expected_name)) is not None ] if not matches: + expected = " or ".join(repr(name) for name in expected_names) raise FileNotFoundError( - f"Agent {agent_name!r} was not found as {expected_name!r} in " + f"Agent {agent_name!r} was not found as {expected} in " f"{str(app_root)!r} or its 'agents' directory" ) if len(matches) > 1: + conflicts = ", ".join(repr(str(match)) for match in matches) raise ValueError( - f"Agent {agent_name!r} is ambiguous: both " - f"{str(matches[0])!r} and {str(matches[1])!r} exist" + f"Agent {agent_name!r} is ambiguous; found: {conflicts}" ) source = matches[0].resolve(strict=True) diff --git a/azurefunctions-agents-extensions-base/tests/test_bindings.py b/azurefunctions-agents-extensions-base/tests/test_bindings.py index 188df21..7954a09 100644 --- a/azurefunctions-agents-extensions-base/tests/test_bindings.py +++ b/azurefunctions-agents-extensions-base/tests/test_bindings.py @@ -86,6 +86,38 @@ async def handler(value: str, agent: object) -> tuple[str, object]: assert provider.compiled.opened == provider.compiled.closed == 2 +@pytest.mark.parametrize( + ("directory", "filename"), + [ + ("", "orders.agent.md"), + ("", "orders.md"), + ("agents", "orders.agent.md"), + ("agents", "orders.md"), + ], +) +def test_markdown_agent_resolves_supported_instruction_paths( + tmp_path, + provider, + directory, + filename, +): + instructions = "Use the order API." + parent = tmp_path / directory + parent.mkdir(exist_ok=True) + (parent / filename).write_text(instructions, encoding="utf-8") + + @bindings.markdown_agent( + func.FunctionApp(), + provider="agent_framework", + arg_name="agent", + agent_name="orders", + ) + async def handler(agent: object) -> None: + pass + + assert provider.compile_args["instructions"] == instructions + + def test_markdown_agent_logs_provider_usage(tmp_path, provider, caplog): (tmp_path / "orders.agent.md").write_text("instructions", encoding="utf-8") app = func.FunctionApp() @@ -179,7 +211,7 @@ async def handler(agent: object) -> None: def test_markdown_agent_rejects_ambiguous_files(tmp_path, provider): (tmp_path / "agents").mkdir() (tmp_path / "orders.agent.md").write_text("root", encoding="utf-8") - (tmp_path / "agents" / "orders.agent.md").write_text("nested", encoding="utf-8") + (tmp_path / "agents" / "orders.md").write_text("nested", encoding="utf-8") with pytest.raises(ValueError, match="ambiguous"): diff --git a/azurefunctions-agents-extensions-copilot-sdk/LICENSE b/azurefunctions-agents-extensions-copilot-sdk/LICENSE new file mode 100644 index 0000000..ce29e72 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in b/azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in new file mode 100644 index 0000000..2235005 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azurefunctions *.py *.pyi +recursive-include tests *.py +include LICENSE README.md \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/README.md new file mode 100644 index 0000000..d1be102 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/README.md @@ -0,0 +1,273 @@ +# Azure Functions GitHub Copilot SDK Extension + +Inject GitHub Copilot SDK sessions built from raw Markdown instructions into +Python Azure Functions. + +## Install + +```text +pip install azurefunctions-agents-extensions-copilot-sdk +``` + +Install Entra authentication for remote MCP servers with the MCP extra: + +```text +pip install "azurefunctions-agents-extensions-copilot-sdk[mcp]" +``` + +Install Durable Functions support with the durable extra: + +```text +pip install "azurefunctions-agents-extensions-copilot-sdk[durable]" +``` + +The GitHub Copilot SDK uses a native runtime. Pre-provision it during image or +deployment construction so a Function invocation never downloads executable +content: + +```text +python -m copilot download-runtime +``` + +## Use a Copilot SDK Agent app + +The extension uses these standard Function App settings: + +| Setting | Purpose | +| --- | --- | +| `COPILOT_GITHUB_TOKEN` | GitHub token used by the default client. | +| `COPILOT_BASE_DIRECTORY` | Writable directory for isolated Copilot runtime state. | +| `COPILOT_MODEL` | Model used by Agent sessions. | + +Use these exact names in `local.settings.json` and Azure Function App settings. +The extension creates the default `CopilotClient` in `mode="empty"`, disables +SDK telemetry, and does not use interactive logged-in-user authentication: + +```python +import azure.functions as func +from copilot.session import CopilotSession +from copilot.session_events import AssistantMessageData +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp + +app = AgentFunctionApp() + + +@app.route(route="orders", methods=["POST"]) +@app.markdown_agent(arg_name="agent", agent_name="orders") +async def process_order( + req: func.HttpRequest, + agent: CopilotSession, +) -> str: + response = await agent.send_and_wait(req.get_body().decode()) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") + return response.data.content +``` + +`AgentFunctionApp` subclasses `azure.functions.FunctionApp` and pins every +Agent binding in the app to this provider. Place the complete instructions at +`orders.agent.md`, `orders.md`, or the same filename under `agents/`. The +`.agent.md` suffix is recommended; plain `.md` is also supported. The file is +raw UTF-8 text; no front matter or runtime configuration is interpreted. If +multiple candidates exist, lookup fails as ambiguous. + +`AgentFunctionApp()` reads its model from `COPILOT_MODEL` and defaults +`on_permission_request` to `PermissionHandler.approve_all`. Pass `model=` or +`on_permission_request=` only to override those defaults. A `markdown_agent()` +decorator can override `client_factory`, `model`, `on_permission_request`, +`provider`, `tools`, and any Copilot session option for one binding. +`app_root` can be set only on `AgentFunctionApp`. + +`PermissionHandler.approve_all` should be used only when every exposed tool is +trusted. Supply a restrictive SDK permission handler when tools can access +privileged data or perform external actions. + +### Customize Copilot sessions + +Pass Copilot session options directly to `markdown_agent()`: + +```python +@app.markdown_agent( + arg_name="agent", + agent_name="orders", + reasoning_effort="high", + streaming=True, +) +async def process_order( + req: func.HttpRequest, + agent: CopilotSession, +) -> str: + ... +``` + +The extension forwards these arguments to `CopilotClient.create_session()` +without validating their names or values. The installed Copilot SDK accepts or +rejects them. Session options are applied after the extension defaults, so they +take precedence. This includes +extension-generated values such as `system_message`, `tools`, +`available_tools`, `mcp_servers`, `skill_directories`, and their enablement +flags. Override those values only when intentionally replacing the Agent's +instructions or discovered capabilities. + +For app-wide defaults, pass a mapping to `AgentFunctionApp`: + +```python +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp + +app = AgentFunctionApp( + session_options={ + "reasoning_effort": "high", + "streaming": True, + }, +) +``` + +Supplying any session option to `markdown_agent()` replaces the app-wide +`session_options` object for that binding. + +## Authentication and BYOK + +Keep GitHub tokens and model-provider credentials in Function App settings or a +secret store. Do not put them in source, `.agent.md`, or `mcp.json` files. The +default client reads `COPILOT_GITHUB_TOKEN` and `COPILOT_BASE_DIRECTORY` when it +is first needed. + +The optional `provider` value is passed to the Copilot SDK session for bring +your own key (BYOK) scenarios. For example: + +```python +import os + +from copilot.session import ProviderConfig + +provider: ProviderConfig = { + "type": "openai", + "wire_api": "responses", + "base_url": os.environ["MODEL_BASE_URL"], + "api_key": os.environ["MODEL_API_KEY"], +} + +app = AgentFunctionApp( + provider=provider, +) +``` + +The Copilot SDK also supports Azure and Anthropic provider configurations. Use +the provider fields supported by the installed SDK version, and keep all +credentials outside checked-in configuration. + +For advanced client authentication or runtime transports, pass a synchronous +zero-argument `client_factory`. The extension calls it once, validates the +result, and caches that `CopilotClient`: + +```python +from copilot import CopilotClient + + +def create_copilot_client() -> CopilotClient: + return CopilotClient(...) + + +app = AgentFunctionApp( + client_factory=create_copilot_client, +) +``` + +App-level bindings share the app's cached client. A decorator-level +`client_factory` override has a separate cache for that binding. Factories must +return `CopilotClient` synchronously; awaitable results are rejected. + +## Skills and MCP servers + +Skills and remote MCP servers are discovered automatically from the app root: + +```text +skills/inventory/SKILL.md +mcp.json +``` + +The base extension discovers Skill directory paths without reading their +contents. The Copilot SDK loads and validates the Skill files for each session. + +V1 supports remote HTTP MCP transports only: + +```json +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock"], + "headers": {"X-Tenant": "%TENANT_ID%"}, + "auth": { + "scope": "$INVENTORY_MCP_SCOPE", + "client_id": "%AZURE_CLIENT_ID%" + } + } + } +} +``` + +`$VAR` and `%VAR%` references are resolved for each invocation. Missing values +fail before session creation. Servers with headers or Entra authentication must +use HTTPS, except for explicit loopback development endpoints. Entra tokens are +acquired through `DefaultAzureCredential` and held only for the invocation. + +Every Agent in the Function App receives all valid Skills and MCP servers under +the app root. V1 has no app-level or per-binding capability selectors. Skills, +MCP servers, and Python `tools=` can perform privileged operations; use separate +Function Apps when capability sets require isolation. + +## Invocation lifecycle + +The extension creates one client lazily and reuses it across invocations for the +lifetime of the Python worker process. Each invocation creates and closes a +fresh session. Default ephemeral sessions are deleted after disconnect so they +do not accumulate in the cached client's registry. Leave +`enable_session_store` disabled: this binding creates a new session for every +invocation and does not expose a session ID or resume workflow. Enabling the +store retains each disconnected session in the cached client for the lifetime +of the worker. The extension replaces the session system message with the raw +Agent instructions and disables ambient Copilot configuration discovery, +session persistence, memory, telemetry, built-in Skills, and tool search. +Explicit Python tools, discovered Skills, and discovered MCP servers remain +available. + +Sessions and Entra credentials are closed on success, error, and cancellation. +The cached client remains open so later invocations can reuse its runtime +connection. + +## Durable Agents + +Use `context.call_agent(agent_name, input_)` inside a synchronous generator +orchestrator. Agent execution runs in the extension's hidden activity, keeping +orchestrator replay deterministic: + +```python +from azurefunctions.agents.extensions.copilot_sdk import DurableAgentContext + + +@app.orchestration_trigger(context_name="context") +def order_orchestrator(context: DurableAgentContext): + assessment = yield context.call_agent( + "orders", + {"order": context.get_input()}, + ) + return {"assessment": assessment} +``` + +Importing the package remains safe without Durable installed. Using Durable +decorators requires the `[durable]` extra. All Durable Agent calls use the +provider and capabilities configured by `AgentFunctionApp`; an orchestrator +cannot select a different provider or capability set in V1. + +## Current limitations + +- Python 3.13 or later is required. +- Only remote HTTP MCP servers are supported; local process transports are not. +- Agent files contain raw instructions only; front matter is not interpreted. +- Capability filtering within one Function App is not supported in V1. +- Native runtime provisioning is an application deployment responsibility. + +See `samples/agent_samples_copilot-sdk` for direct HTTP and queue triggers and +`samples/agent_samples_copilot-sdk_durable` for Durable orchestration. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py new file mode 100644 index 0000000..78e5970 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/__init__.py @@ -0,0 +1,13 @@ +from azurefunctions.agents.extensions.base.durable import DurableAgentContext + +from .apps import AgentFunctionApp +from .provider import COPILOT_SDK_PROVIDER_ID, ClientFactory + +__all__ = [ + "COPILOT_SDK_PROVIDER_ID", + "AgentFunctionApp", + "ClientFactory", + "DurableAgentContext", +] + +__version__ = "1.0.0b1" diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py new file mode 100644 index 0000000..4eb1d01 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/apps.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +from collections.abc import Callable, Mapping, Sequence +from typing import Any, TypeVar + +import azure.functions as func +from copilot.session import PermissionHandler as CopilotPermissionHandler +from copilot.session import ProviderConfig +from copilot.tools import Tool + +from azurefunctions.agents.extensions.base import ( + configure_app, + durable_orchestration_trigger, +) +from azurefunctions.agents.extensions.base import markdown_agent as base_markdown_agent + +from .provider import ( + COPILOT_SDK_PROVIDER_ID, + ClientFactory, + PermissionHandler, + _CopilotProviderOptions, + _cache_client_factory, + _default_client_factory, +) + +_F = TypeVar("_F", bound=Callable[..., Any]) + + +def _provider_options( + *, + client_factory: ClientFactory | None = None, + model: str | None = None, + on_permission_request: PermissionHandler | None = None, + provider: ProviderConfig | None = None, + tools: Tool | Sequence[Tool] | None = None, + session_options: Mapping[str, object] | None = None, +) -> _CopilotProviderOptions: + options: _CopilotProviderOptions = {} + if client_factory is not None: + options["client_factory"] = _cache_client_factory(client_factory) + if model is not None: + options["model"] = model + if on_permission_request is not None: + options["on_permission_request"] = on_permission_request + if provider is not None: + options["session_provider"] = provider + if tools is not None: + options["tools"] = tools + if session_options is not None: + options["session_options"] = session_options + return options + + +class _CopilotSdkAppMixin: + def markdown_agent( + self, + *, + arg_name: str, + agent_name: str, + client_factory: ClientFactory | None = None, + model: str | None = None, + on_permission_request: PermissionHandler | None = None, + provider: ProviderConfig | None = None, + tools: Tool | Sequence[Tool] | None = None, + **session_options: object, + ) -> Callable[[_F], _F]: + return base_markdown_agent( + self, + provider=COPILOT_SDK_PROVIDER_ID, + arg_name=arg_name, + agent_name=agent_name, + **_provider_options( + client_factory=client_factory, + model=model, + on_permission_request=on_permission_request, + provider=provider, + tools=tools, + session_options=(session_options or None), + ), + ) + + +class AgentFunctionApp( + _CopilotSdkAppMixin, + func.FunctionApp, +): + """Azure Functions app configured for GitHub Copilot SDK sessions.""" + + def __init__( + self, + *, + model: str | None = None, + client_factory: ClientFactory | None = None, + app_root: str | os.PathLike[str] | None = None, + on_permission_request: PermissionHandler | None = ( + CopilotPermissionHandler.approve_all + ), + provider: ProviderConfig | None = None, + tools: Tool | Sequence[Tool] | None = None, + session_options: Mapping[str, object] | None = None, + http_auth_level: func.AuthLevel | str = func.AuthLevel.FUNCTION, + ) -> None: + super().__init__(http_auth_level=http_auth_level) + configure_app( + self, + provider=COPILOT_SDK_PROVIDER_ID, + app_root=app_root, + provider_options=_provider_options( + client_factory=( + client_factory + if client_factory is not None + else _default_client_factory + ), + model=(model if model is not None else os.environ["COPILOT_MODEL"]), + on_permission_request=on_permission_request, + provider=provider, + tools=tools, + session_options=session_options, + ), + ) + + def orchestration_trigger( + self, + context_name: str, + orchestration: str | None = None, + input_type: type | None = None, + ) -> Callable[..., Any]: + return durable_orchestration_trigger( + self, + sdk_decorator=super().orchestration_trigger, + context_name=context_name, + orchestration=orchestration, + input_type=input_type, + ) diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py new file mode 100644 index 0000000..7e1e604 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/provider.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import inspect +import os +import re +import threading +from collections.abc import Awaitable, Callable, Mapping, Sequence +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from ipaddress import ip_address +from typing import Any, AsyncIterator, TypedDict, cast, get_origin +from urllib.parse import urlsplit + +from copilot import CopilotClient, ToolSet +from copilot.session import ( + CopilotSession, + MCPHTTPServerConfig, + MCPStdioServerConfig, + ProviderConfig, + SystemMessageReplaceConfig, +) +from copilot.session_events import AssistantMessageData +from copilot.tools import Tool + +from azurefunctions.agents.extensions.base import ( + AgentCapabilities, + AgentProvider, + CompiledAgent, + InvocationMetadata, + MCPServerDefinition, + SkillDefinition, +) + +COPILOT_SDK_PROVIDER_ID = "copilot_sdk" +ClientFactory = Callable[[], CopilotClient] +PermissionHandler = Callable[..., Any] +_ENV_REFERENCE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%") + + +class _CopilotProviderOptions(TypedDict, total=False): + client_factory: ClientFactory + model: str + on_permission_request: PermissionHandler + session_provider: ProviderConfig + session_options: Mapping[str, object] + tools: Tool | Sequence[Tool] + + +_SUPPORTED_OPTIONS = frozenset(_CopilotProviderOptions.__optional_keys__) + + +@dataclass(frozen=True) +class _CopilotSdkOptions: + client_factory: ClientFactory + model: str + on_permission_request: PermissionHandler | None + provider: ProviderConfig | None + tools: tuple[Tool, ...] + session_options: Mapping[str, object] + + +class _CachedClientFactory: + def __init__(self, factory: ClientFactory) -> None: + self._factory = factory + self._client: CopilotClient | None = None + self._lock = threading.Lock() + + def __call__(self) -> CopilotClient: + with self._lock: + if self._client is None: + client = self._factory() + if inspect.isawaitable(client): + if inspect.iscoroutine(client): + client.close() + raise TypeError( + "client_factory must return a CopilotClient synchronously, " + "not an awaitable" + ) + if not isinstance(client, CopilotClient): + raise TypeError("client_factory must return a CopilotClient") + self._client = client + return self._client + + +def _cache_client_factory(factory: ClientFactory) -> ClientFactory: + if isinstance(factory, _CachedClientFactory): + return factory + return _CachedClientFactory(factory) + + +def _default_client_factory() -> CopilotClient: + github_token = os.environ.get("COPILOT_GITHUB_TOKEN") + if not github_token: + raise ValueError("COPILOT_GITHUB_TOKEN must be set") + base_directory = os.environ.get("COPILOT_BASE_DIRECTORY") + if not base_directory: + raise ValueError("COPILOT_BASE_DIRECTORY must be set") + return CopilotClient( + mode="empty", + github_token=github_token, + base_directory=base_directory, + use_logged_in_user=False, + log_level="none", + telemetry=None, + ) + + +@dataclass(frozen=True) +class CopilotSdkBinding(CompiledAgent): + instructions: str + agent_name: str + options: _CopilotSdkOptions + capabilities: AgentCapabilities + + @asynccontextmanager + async def open_agent( + self, + invocation: InvocationMetadata, + ) -> AsyncIterator[CopilotSession]: + client = self.options.client_factory() + + async with AsyncExitStack() as stack: + if "mcp_servers" in self.options.session_options: + mcp_servers = self.options.session_options["mcp_servers"] + else: + disabled_mcp_servers = cast( + list[str] | None, + self.options.session_options.get("disabled_mcp_servers"), + ) + disabled_names = set(disabled_mcp_servers or ()) + mcp_servers = await _build_mcp_servers( + tuple( + definition + for definition in self.capabilities.mcp_servers + if definition.name not in disabled_names + ), + stack, + ) + skill_directories = cast( + list[str] | None, + self.options.session_options.get( + "skill_directories", + _skill_directories(self.capabilities.skills), + ), + ) + enable_skills = cast( + bool | None, + self.options.session_options.get( + "enable_skills", + bool(skill_directories), + ), + ) + available_tools = ToolSet() + if enable_skills: + available_tools.add_builtin("skill") + if mcp_servers: + available_tools.add_mcp("*") + for tool in self.options.tools: + available_tools.add_custom(tool.name) + create_session_options: dict[str, object] = { + "model": self.options.model, + "tools": list(self.options.tools), + "available_tools": available_tools, + "system_message": SystemMessageReplaceConfig( + mode="replace", + content=self.instructions, + ), + "provider": self.options.provider, + "on_permission_request": self.options.on_permission_request, + "streaming": False, + "enable_config_discovery": False, + "enable_session_telemetry": False, + "request_extensions": False, + "enable_session_store": False, + "included_builtin_skills": [], + "enable_skills": enable_skills, + "skill_directories": skill_directories, + "mcp_servers": mcp_servers, + "mcp_oauth_token_storage": "in-memory", + "skip_custom_instructions": True, + "tool_search": {"enabled": False}, + "infinite_sessions": {"enabled": False}, + "memory": {"enabled": False}, + } + create_session_options.update(self.options.session_options) + create_session = cast( + Callable[..., Awaitable[CopilotSession]], + client.create_session, + ) + session = await create_session(**create_session_options) + if create_session_options["enable_session_store"] is not True: + stack.push_async_callback( + client.delete_session, + session.session_id, + ) + entered_session = await stack.enter_async_context(session) + yield entered_session + + async def run_agent( + self, + prompt: str, + invocation: InvocationMetadata, + ) -> str: + async with self.open_agent(invocation) as session: + response = await session.send_and_wait(prompt) + if response is None or not isinstance(response.data, AssistantMessageData): + raise TypeError( + "GitHub Copilot SDK response must contain a final assistant message" + ) + if not isinstance(response.data.content, str): + raise TypeError("GitHub Copilot SDK response content must be a string") + return response.data.content + + +class CopilotSdkProvider(AgentProvider): + provider_id = COPILOT_SDK_PROVIDER_ID + distribution_name = "azurefunctions-agents-extensions-copilot-sdk" + supported_capabilities = frozenset({"skills", "mcp"}) + + def compile_binding( + self, + *, + instructions: str, + agent_name: str, + options: Mapping[str, object], + annotation: object, + capabilities: AgentCapabilities, + ) -> CopilotSdkBinding: + unknown = sorted(set(options) - _SUPPORTED_OPTIONS) + if unknown: + raise TypeError( + "Unsupported GitHub Copilot SDK option(s): " + ", ".join(unknown) + ) + client_factory = options.get("client_factory") + if client_factory is None: + client_factory = _default_client_factory + if not callable(client_factory): + raise TypeError("client_factory must be callable") + if inspect.iscoroutinefunction(client_factory): + raise TypeError("client_factory must be a synchronous function") + + model = options.get("model") + if not isinstance(model, str) or not model.strip(): + raise TypeError("model option must be a non-empty string") + if annotation is not inspect.Signature.empty: + annotation_origin = get_origin(annotation) + if ( + annotation is not CopilotSession + and annotation_origin is not CopilotSession + ): + raise TypeError( + "GitHub Copilot SDK binding parameter must be annotated " + "as copilot.session.CopilotSession" + ) + + permission_handler = options.get("on_permission_request") + if permission_handler is not None and not callable(permission_handler): + raise TypeError("on_permission_request must be callable") + provider = options.get("session_provider") + if provider is not None and not isinstance(provider, Mapping): + raise TypeError("provider must be a Copilot SDK ProviderConfig mapping") + + return CopilotSdkBinding( + instructions=instructions, + agent_name=agent_name, + options=_CopilotSdkOptions( + client_factory=_cache_client_factory( + cast(ClientFactory, client_factory), + ), + model=model.strip(), + on_permission_request=cast( + PermissionHandler | None, + permission_handler, + ), + provider=cast(ProviderConfig | None, provider), + tools=_normalize_tools(options.get("tools")), + session_options=cast( + Mapping[str, object], + options.get("session_options") or {}, + ), + ), + capabilities=capabilities, + ) + + +def _normalize_tools(value: object) -> tuple[Tool, ...]: + if value is None: + return () + tools = ( + tuple(cast(Sequence[object], value)) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)) + else (value,) + ) + if any(not isinstance(tool, Tool) for tool in tools): + raise TypeError("tools must contain only copilot.tools.Tool values") + return cast(tuple[Tool, ...], tools) + + +def _skill_directories(skills: Sequence[SkillDefinition]) -> list[str]: + return sorted({str(skill.path.parent) for skill in skills}) + + +def _resolve_environment(value: str, *, field: str) -> str: + missing: set[str] = set() + + def replace(match: re.Match[str]) -> str: + name = match.group(1) or match.group(2) + resolved = os.environ.get(name) + if resolved is None: + missing.add(name) + return match.group(0) + return resolved + + result = _ENV_REFERENCE.sub(replace, value) + if missing: + raise ValueError( + f"MCP {field} references missing environment variable(s): " + f"{', '.join(sorted(missing))}" + ) + return result + + +def _is_loopback_host(hostname: str | None) -> bool: + if hostname is None: + return False + normalized = hostname.rstrip(".").casefold() + if normalized == "localhost": + return True + try: + return ip_address(normalized).is_loopback + except ValueError: + return False + + +async def _build_mcp_servers( + definitions: Sequence[MCPServerDefinition], + stack: AsyncExitStack, +) -> dict[str, MCPStdioServerConfig | MCPHTTPServerConfig]: + servers: dict[str, MCPStdioServerConfig | MCPHTTPServerConfig] = {} + for definition in definitions: + config = definition.config + url = _resolve_environment( + config.url, + field=f"server {definition.name!r} URL", + ) + parsed_url = urlsplit(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError( + f"MCP server {definition.name!r} URL must use HTTP or HTTPS" + ) + headers = { + name: _resolve_environment( + value, + field=f"server {definition.name!r} header {name!r}", + ) + for name, value in config.headers + } + if ( + parsed_url.scheme == "http" + and ( + headers + or config.auth is not None + or parsed_url.username is not None + or parsed_url.password is not None + or bool(parsed_url.query) + ) + and not _is_loopback_host(parsed_url.hostname) + ): + raise ValueError( + f"MCP server {definition.name!r} must use HTTPS when credentials, " + "query parameters, headers, or auth are configured; HTTP is allowed " + "only for loopback hosts" + ) + if config.auth is not None: + try: + from azure.identity.aio import DefaultAzureCredential + except ImportError as error: + raise ImportError( + "MCP Entra authentication is not installed. Install " + "'azurefunctions-agents-extensions-copilot-sdk[mcp]'." + ) from error + scope = _resolve_environment( + config.auth.scope, + field=f"server {definition.name!r} auth scope", + ) + client_id = ( + _resolve_environment( + config.auth.client_id, + field=f"server {definition.name!r} auth client_id", + ) + if config.auth.client_id is not None + else None + ) + credential = DefaultAzureCredential( + managed_identity_client_id=client_id, + ) + stack.push_async_callback(credential.close) + token = await credential.get_token(scope) + headers["Authorization"] = f"Bearer {token.token}" + + servers[definition.name] = MCPHTTPServerConfig( + type="http", + url=url, + headers=headers, + tools=( + list(config.allowed_tools) + if config.allowed_tools is not None + else ["*"] + ), + ) + return servers + + +def create_provider() -> CopilotSdkProvider: + return CopilotSdkProvider() diff --git a/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/py.typed b/azurefunctions-agents-extensions-copilot-sdk/azurefunctions/agents/extensions/copilot_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml new file mode 100644 index 0000000..1bacd2c --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-agents-extensions-copilot-sdk" +dynamic = ["version"] +requires-python = ">=3.13" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }, +] +description = "GitHub Copilot SDK integration for Azure Functions." +readme = "README.md" +license = { text = "MIT License" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "azurefunctions-agents-extensions-base>=1.0.0b1", + "github-copilot-sdk>=1.0.14,<2", +] + +[project.optional-dependencies] +mcp = [ + "azure-identity>=1.25.3,<2", +] +durable = [ + "azurefunctions-agents-extensions-base[durable]>=1.0.0b1", +] +dev = [ + "azure-functions-durable>=2.0.0b2", + "azure-identity>=1.25.3,<2", + "coverage", + "flake8", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-instafail", +] + +[project.entry-points."azurefunctions.agents.extensions.providers"] +copilot_sdk = "azurefunctions.agents.extensions.copilot_sdk.provider:create_provider" + +[tool.setuptools.dynamic] +version = { attr = "azurefunctions.agents.extensions.copilot_sdk.__version__" } + +[tool.setuptools.packages.find] +include = ["azurefunctions.agents.extensions.copilot_sdk*"] + +[tool.setuptools.package-data] +"azurefunctions.agents.extensions.copilot_sdk" = ["py.typed"] + +[tool.mypy] +strict = true + +[[tool.mypy.overrides]] +module = ["azure", "azure.*"] +ignore_missing_imports = true \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/README.md new file mode 100644 index 0000000..1aaa33b --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/README.md @@ -0,0 +1,6 @@ +# GitHub Copilot SDK Agent samples + +- `agent_samples_copilot-sdk` demonstrates direct HTTP and queue-triggered + session injection. +- `agent_samples_copilot-sdk_durable` demonstrates replay-safe Agent calls from + a Durable orchestration. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md new file mode 100644 index 0000000..87f7944 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/README.md @@ -0,0 +1,41 @@ +# GitHub Copilot SDK Agent binding sample + +This sample injects a fresh `CopilotSession` into HTTP and queue-triggered +Functions. Copy `local.settings.template.json` to `local.settings.json`, replace +the placeholder settings, install `requirements.txt`, and pre-provision the +native runtime with: + +```text +python -m copilot download-runtime +``` + +Run the app with `func start`. The sample uses `mode="empty"`, disables SDK +telemetry, and exposes only the discovered Skill and remote inventory MCP tool. +Configure `COPILOT_GITHUB_TOKEN`, `COPILOT_BASE_DIRECTORY`, and `COPILOT_MODEL` +using the exact names shown in `local.settings.template.json`. Set +`COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's runtime +state; use separate directories when hosting multiple tenants. +The extension creates one client lazily and reuses it for the lifetime of the +Python worker while creating a fresh session for each invocation. +`AgentFunctionApp()` defaults to `PermissionHandler.approve_all`, which is +appropriate only when every configured tool is trusted. Pass a restrictive +handler for applications with broader tools. + +## Compare with the direct Copilot SDK + +[`function_app.py`](function_app.py) uses `AgentFunctionApp` and the +`@app.markdown_agent` decorator to discover the Agent instructions, Skill, and +MCP server and inject a configured `CopilotSession` into each invocation. + +[`direct_sdk.py`](direct_sdk.py) performs the same order-readiness assessment +without the binding. It shows the client authentication, instruction loading, +Skill and MCP wiring, tool allowlist, session security options, lifecycle +management, and response validation that application code must otherwise own. + +The standalone script reads settings from its process environment; unlike Core +Tools, it does not load `local.settings.json`. Export the same `COPILOT_*` and +`INVENTORY_MCP_URL` values described above, then run: + +```text +python direct_sdk.py +``` diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py new file mode 100644 index 0000000..9d25b67 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/direct_sdk.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +from copilot import CopilotClient, ToolSet +from copilot.session import ( + MCPHTTPServerConfig, + MCPStdioServerConfig, + PermissionHandler, + SystemMessageReplaceConfig, +) +from copilot.session_events import AssistantMessageData + +SAMPLE_ROOT = Path(__file__).resolve().parent + + +def required_setting(name: str) -> str: + value = os.environ.get(name) + if not value: + raise ValueError(f"{name} must be set") + return value + + +async def assess_order(order_id: str, order: dict[str, object]) -> str: + instructions = (SAMPLE_ROOT / "order-fulfillment.agent.md").read_text( + encoding="utf-8" + ) + skill_directories = [str(SAMPLE_ROOT / "skills")] + mcp_servers: dict[str, MCPStdioServerConfig | MCPHTTPServerConfig] = { + "inventory": { + "type": "http", + "url": required_setting("INVENTORY_MCP_URL"), + "tools": ["lookup_stock"], + } + } + available_tools = ToolSet() + available_tools.add_builtin("skill") + available_tools.add_mcp("*") + + async with CopilotClient( + mode="empty", + github_token=required_setting("COPILOT_GITHUB_TOKEN"), + base_directory=required_setting("COPILOT_BASE_DIRECTORY"), + use_logged_in_user=False, + log_level="none", + telemetry=None, + ) as client: + session = await client.create_session( + model=required_setting("COPILOT_MODEL"), + available_tools=available_tools, + system_message=SystemMessageReplaceConfig( + mode="replace", + content=instructions, + ), + on_permission_request=PermissionHandler.approve_all, + streaming=False, + enable_config_discovery=False, + enable_session_telemetry=False, + request_extensions=False, + enable_session_store=False, + included_builtin_skills=[], + enable_skills=True, + skill_directories=skill_directories, + mcp_servers=mcp_servers, + mcp_oauth_token_storage="in-memory", + skip_custom_instructions=True, + tool_search={"enabled": False}, + infinite_sessions={"enabled": False}, + memory={"enabled": False}, + ) + async with session: + response = await session.send_and_wait( + json.dumps( + { + "order_id": order_id, + "order": order, + "task": "assess fulfillment readiness", + } + ) + ) + + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") + if not isinstance(response.data.content, str): + raise RuntimeError("Copilot returned non-text assistant content") + return response.data.content + + +async def main() -> None: + assessment = await assess_order( + "sample-order-001", + { + "sku": "SKU-LOCAL-001", + "quantity": 2, + "destination": "Seattle", + "priority": "standard", + }, + ) + print(json.dumps({"assessment": assessment}, indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py new file mode 100644 index 0000000..9657585 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/function_app.py @@ -0,0 +1,57 @@ +import json + +import azure.functions as func +from copilot.session import CopilotSession +from copilot.session_events import AssistantMessageData +from azurefunctions.agents.extensions.copilot_sdk import AgentFunctionApp + +app = AgentFunctionApp() + + +@app.route(route="orders/{orderId}", methods=["POST"]) +@app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order( + req: func.HttpRequest, + order_agent: CopilotSession, +) -> func.HttpResponse: + try: + order = req.get_json() + except ValueError: + return func.HttpResponse( + body=json.dumps({"error": "Order failed validation."}), + status_code=400, + mimetype="application/json", + ) + + response = await order_agent.send_and_wait( + json.dumps( + { + "order_id": req.route_params["orderId"], + "order": order, + "task": "assess fulfillment readiness", + } + ), + ) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") + return func.HttpResponse( + body=json.dumps({"assessment": response.data.content}), + mimetype="application/json", + ) + + +@app.queue_trigger( + arg_name="message", + queue_name="orders", + connection="AzureWebJobsStorage", +) +@app.markdown_agent(arg_name="order_agent", agent_name="order-fulfillment") +async def process_order_event( + message: func.QueueMessage, + order_agent: CopilotSession, +) -> None: + response = await order_agent.send_and_wait( + message.get_body().decode("utf-8") + ) + if response is None or not isinstance(response.data, AssistantMessageData): + raise RuntimeError("Copilot did not return a final assistant message") \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json new file mode 100644 index 0000000..bab9278 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json new file mode 100644 index 0000000..bc1e589 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/local.settings.template.json @@ -0,0 +1,11 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "COPILOT_GITHUB_TOKEN": "", + "COPILOT_BASE_DIRECTORY": "", + "COPILOT_MODEL": "gpt-5", + "INVENTORY_MCP_URL": "https://inventory.example.com/mcp" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json new file mode 100644 index 0000000..9318245 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "inventory": { + "type": "streamable-http", + "url": "$INVENTORY_MCP_URL", + "tools": ["lookup_stock"] + } + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md new file mode 100644 index 0000000..cacbb0f --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/order-fulfillment.agent.md @@ -0,0 +1,4 @@ +You assess whether an order is ready for fulfillment. + +Use available policy Skills and inventory tools when relevant. Return a concise +assessment with any required human-review actions. Never invent order details. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt new file mode 100644 index 0000000..983df1d --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/requirements.txt @@ -0,0 +1 @@ +azurefunctions-agents-extensions-copilot-sdk==1.0.0b1 \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md new file mode 100644 index 0000000..20624bc --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk/skills/order-policy/SKILL.md @@ -0,0 +1,7 @@ +--- +name: order-policy +description: Rules for reviewing orders before fulfillment. +--- + +Require human review when an order contains incomplete shipping information or +requests an unsupported destination. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md new file mode 100644 index 0000000..2400c04 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/README.md @@ -0,0 +1,17 @@ +# Durable GitHub Copilot SDK Agent binding sample + +This sample schedules Copilot SDK Agent calls as replay-safe Durable activities. +Install `requirements.txt`, configure the settings from +`local.settings.template.json`, pre-provision the native runtime with +`python -m copilot download-runtime`, and run `func start`. +Configure `COPILOT_GITHUB_TOKEN`, `COPILOT_BASE_DIRECTORY`, and `COPILOT_MODEL` +using the exact names shown in `local.settings.template.json`. Set +`COPILOT_BASE_DIRECTORY` to an isolated writable directory for the app's runtime +state; use separate directories when hosting multiple tenants. +The extension creates one client lazily and reuses it for the lifetime of the +Python worker while creating a fresh session for each activity invocation. +`AgentFunctionApp()` defaults to `PermissionHandler.approve_all`; pass a +restrictive handler unless every configured tool is trusted. + +The orchestrator only schedules `context.call_agent(...)`; model and tool work +runs in the extension's hidden activity. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py new file mode 100644 index 0000000..e513e1f --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/function_app.py @@ -0,0 +1,50 @@ +import json + +import azure.durable_functions as df +import azure.functions as func +from azurefunctions.agents.extensions.copilot_sdk import ( + AgentFunctionApp, + DurableAgentContext, +) + +app = AgentFunctionApp() + + +@app.route(route="orders/orchestrations", methods=["POST"]) +@app.durable_client_input(client_name="client") +async def start_order_orchestration( + req: func.HttpRequest, + client: df.DurableFunctionsClient, +) -> func.HttpResponse: + try: + order = req.get_json() + except ValueError: + return func.HttpResponse( + body=json.dumps({"error": "Order failed validation."}), + status_code=400, + mimetype="application/json", + ) + + instance_id = await client.start_new( + "order_orchestrator", + client_input=order, + ) + management = client.create_http_management_payload(req, instance_id) + return func.HttpResponse( + body=json.dumps(management), + status_code=202, + mimetype="application/json", + headers={"Location": management["statusQueryGetUri"]}, + ) + + +@app.orchestration_trigger(context_name="context") +def order_orchestrator(context: DurableAgentContext): + assessment = yield context.call_agent( + "order-fulfillment", + { + "order": context.get_input(), + "task": "assess fulfillment readiness", + }, + ) + return {"assessment": assessment} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json new file mode 100644 index 0000000..55d1642 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json new file mode 100644 index 0000000..8064466 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/local.settings.template.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "COPILOT_GITHUB_TOKEN": "", + "COPILOT_BASE_DIRECTORY": "", + "COPILOT_MODEL": "gpt-6" + } +} \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md new file mode 100644 index 0000000..db51b5e --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/order-fulfillment.agent.md @@ -0,0 +1,2 @@ +You assess whether an order is ready for fulfillment. Return a concise risk +assessment and any required human-review actions. \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt new file mode 100644 index 0000000..5d34100 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/samples/agent_samples_copilot-sdk_durable/requirements.txt @@ -0,0 +1 @@ +azurefunctions-agents-extensions-copilot-sdk[durable]==1.0.0b1 \ No newline at end of file diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py new file mode 100644 index 0000000..f299955 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_apps.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import inspect +from unittest.mock import Mock + +import azure.functions as func +from copilot.session import PermissionHandler +from azurefunctions.agents.extensions.copilot_sdk import ( + AgentFunctionApp, + apps, +) + + +def test_api_exposes_extension_options_and_arbitrary_session_options(): + assert list(inspect.signature(AgentFunctionApp.__init__).parameters) == [ + "self", + "model", + "client_factory", + "app_root", + "on_permission_request", + "provider", + "tools", + "session_options", + "http_auth_level", + ] + assert list(inspect.signature(AgentFunctionApp.markdown_agent).parameters) == [ + "self", + "arg_name", + "agent_name", + "client_factory", + "model", + "on_permission_request", + "provider", + "tools", + "session_options", + ] + assert ( + inspect.signature(AgentFunctionApp.markdown_agent) + .parameters["session_options"] + .kind + is inspect.Parameter.VAR_KEYWORD + ) + + +def test_typed_agent_function_app_pins_copilot_provider(monkeypatch): + parent_init = Mock() + configure_app = Mock() + monkeypatch.setattr(func.FunctionApp, "__init__", parent_init) + monkeypatch.setattr(apps, "configure_app", configure_app) + factory = lambda: object() + permission_handler = lambda *args: None + + AgentFunctionApp( + client_factory=factory, + model="gpt-5", + app_root="app", + on_permission_request=permission_handler, + ) + + parent_init.assert_called_once_with(http_auth_level=func.AuthLevel.FUNCTION) + options = configure_app.call_args.kwargs["provider_options"] + assert callable(options["client_factory"]) + assert options["client_factory"] is not factory + assert options["model"] == "gpt-5" + assert options["on_permission_request"] is permission_handler + assert configure_app.call_args.kwargs["provider"] == "copilot_sdk" + assert configure_app.call_args.kwargs["app_root"] == "app" + + +def test_typed_agent_function_app_configures_default_client(monkeypatch): + parent_init = Mock() + configure_app = Mock() + monkeypatch.setattr(func.FunctionApp, "__init__", parent_init) + monkeypatch.setattr(apps, "configure_app", configure_app) + monkeypatch.setenv("COPILOT_MODEL", "gpt-5") + + AgentFunctionApp() + + options = configure_app.call_args.kwargs["provider_options"] + assert callable(options["client_factory"]) + assert options["model"] == "gpt-5" + assert options["on_permission_request"] is PermissionHandler.approve_all + + +def test_agent_function_app_uses_function_app_directly(): + assert func.FunctionApp in AgentFunctionApp.__bases__ + + +def test_typed_markdown_agent_forwards_arbitrary_session_options(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + factory = lambda: object() + + result = app.markdown_agent( + arg_name="agent", + agent_name="orders", + client_factory=factory, + model="gpt-5", + streaming=True, + custom_session_option="value", + ) + + assert result is parent_decorator.return_value + call = parent_decorator.call_args + assert call.args == (app,) + assert call.kwargs["provider"] == "copilot_sdk" + assert call.kwargs["arg_name"] == "agent" + assert call.kwargs["agent_name"] == "orders" + assert callable(call.kwargs["client_factory"]) + assert call.kwargs["client_factory"] is not factory + assert call.kwargs["model"] == "gpt-5" + assert call.kwargs["session_options"] == { + "streaming": True, + "custom_session_option": "value", + } + + +def test_typed_markdown_agent_forwards_sdk_provider_without_keyword_collision( + monkeypatch, +): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + sdk_provider = { + "type": "openai", + "wire_api": "responses", + "base_url": "https://models.example.test", + "api_key": "test-key", + } + + result = app.markdown_agent( + arg_name="agent", + agent_name="orders", + provider=sdk_provider, + ) + + assert result is parent_decorator.return_value + call = parent_decorator.call_args + assert call.kwargs["provider"] == "copilot_sdk" + assert call.kwargs["session_provider"] is sdk_provider + + +def test_typed_markdown_agent_omits_empty_session_options(monkeypatch): + parent_decorator = Mock(return_value=object()) + monkeypatch.setattr(apps, "base_markdown_agent", parent_decorator) + app = object.__new__(AgentFunctionApp) + + app.markdown_agent( + arg_name="agent", + agent_name="orders", + ) + + assert "session_options" not in parent_decorator.call_args.kwargs diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py new file mode 100644 index 0000000..67e93cf --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_imports.py @@ -0,0 +1,39 @@ +import subprocess +import sys + + +def test_copilot_sdk_exports_supported_api(): + import azurefunctions.agents.extensions.copilot_sdk as copilot_sdk + from azurefunctions.agents.extensions.base.durable import DurableAgentContext + + assert copilot_sdk.AgentFunctionApp is not None + assert not hasattr(copilot_sdk, "CopilotSessionOptions") + assert copilot_sdk.DurableAgentContext is DurableAgentContext + assert copilot_sdk.COPILOT_SDK_PROVIDER_ID == "copilot_sdk" + assert not hasattr(copilot_sdk, "markdown_agent") + + +def test_copilot_sdk_import_does_not_import_durable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import importlib.abc\n" + "import sys\n" + "class BlockDurable(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path, target=None):\n" + " if fullname == 'azure.durable_functions' or " + "fullname.startswith('azure.durable_functions.'):\n" + " raise ModuleNotFoundError(name=fullname)\n" + "sys.meta_path.insert(0, BlockDurable())\n" + "import azurefunctions.agents.extensions.copilot_sdk\n" + "assert 'azure.durable_functions' not in sys.modules" + ), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py new file mode 100644 index 0000000..55fd18f --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_provider.py @@ -0,0 +1,711 @@ +from __future__ import annotations + +import asyncio +import inspect +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import SimpleNamespace + +import pytest +from azurefunctions.agents.extensions.base import ( + AgentCapabilities, + InvocationMetadata, + MCPAuthConfig, + MCPHTTPConfig, + MCPServerDefinition, + SkillDefinition, +) +from azurefunctions.agents.extensions.copilot_sdk import provider +from copilot.session import CopilotSession +from copilot.session_events import AssistantMessageData +from copilot.tools import Tool + + +class _Session: + def __init__(self, session_id: str) -> None: + self.session_id = session_id + self.entered = False + self.closed = False + self.prompts: list[str] = [] + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + async def send_and_wait(self, prompt: str): + self.prompts.append(prompt) + return SimpleNamespace( + data=AssistantMessageData( + content=f"response:{prompt}", + message_id="message-1", + ) + ) + + +class _Client: + created: list[_Client] = [] + next_session_id = 0 + + def __init__(self, **options) -> None: + self.entered = False + self.closed = False + self.client_options = options + self.sessions: list[_Session] = [] + self._sessions: dict[str, _Session] = {} + self.deleted_session_ids: list[str] = [] + self.deleted_closed_states: list[bool] = [] + self.session_options = None + self.created.append(self) + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.closed = True + + async def create_session(self, **options): + type(self).next_session_id += 1 + session_id = options.get("session_id") or f"session-{self.next_session_id}" + session = _Session(session_id) + self.sessions.append(session) + self._sessions[session_id] = session + self.session_options = options + return session + + async def delete_session(self, session_id): + session = self._sessions.pop(session_id) + self.deleted_session_ids.append(session_id) + self.deleted_closed_states.append(session.closed) + + +@pytest.fixture(autouse=True) +def fake_client(monkeypatch): + _Client.created.clear() + _Client.next_session_id = 0 + monkeypatch.setattr(provider, "CopilotClient", _Client) + + +def _compile(*, capabilities=AgentCapabilities(), **overrides): + options = {"client_factory": _Client, "model": "gpt-5"} + options.update(overrides) + return provider.CopilotSdkProvider().compile_binding( + instructions="raw instructions", + agent_name="orders", + options=options, + annotation=CopilotSession, + capabilities=capabilities, + ) + + +def test_binding_reuses_client_and_closes_fresh_sessions(): + binding = _compile() + + async def invoke_twice(): + async with binding.open_agent(InvocationMetadata()) as first: + assert first.entered + async with binding.open_agent(InvocationMetadata()) as second: + assert second.entered + + asyncio.run(invoke_twice()) + + assert len(_Client.created) == 1 + assert not _Client.created[0].closed + assert len(_Client.created[0].sessions) == 2 + assert all(session.closed for session in _Client.created[0].sessions) + assert _Client.created[0]._sessions == {} + assert _Client.created[0].deleted_session_ids == ["session-1", "session-2"] + assert _Client.created[0].deleted_closed_states == [True, True] + options = _Client.created[0].session_options + assert options["model"] == "gpt-5" + assert options["system_message"] == { + "mode": "replace", + "content": "raw instructions", + } + assert options["enable_config_discovery"] is False + assert options["enable_session_store"] is False + assert options["included_builtin_skills"] == [] + assert list(options["available_tools"]) == [] + + +def test_binding_applies_create_session_overrides(): + binding = _compile( + session_options={ + "streaming": True, + "enable_session_store": True, + "reasoning_effort": "high", + "system_message": {"mode": "append", "content": "extra"}, + } + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["streaming"] is True + assert options["enable_session_store"] is True + assert options["reasoning_effort"] == "high" + assert options["system_message"] == {"mode": "append", "content": "extra"} + + +def test_binding_preserves_sessions_when_store_is_enabled(): + binding = _compile(session_options={"enable_session_store": True}) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + client = _Client.created[0] + assert list(client._sessions) == ["session-1"] + assert client.deleted_session_ids == [] + + +def test_binding_applies_sdk_provider(): + sdk_provider = { + "type": "openai", + "wire_api": "responses", + "base_url": "https://models.example.test", + "api_key": "test-key", + } + binding = _compile(session_provider=sdk_provider) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].session_options["provider"] is sdk_provider + + +def test_provider_forwards_arbitrary_create_session_options(): + custom_option = object() + binding = _compile(session_options={"custom_session_option": custom_option}) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].session_options["custom_session_option"] is custom_option + + +def test_provider_defers_session_option_validation_to_client(): + class RejectingClient(_Client): + async def create_session(self, **options): + raise TypeError("Copilot SDK rejected the session options") + + binding = _compile( + client_factory=RejectingClient, + session_options={"custom_session_option": "value"}, + ) + + with pytest.raises(TypeError, match="Copilot SDK rejected"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + +def test_client_cache_is_safe_during_concurrent_first_use(): + factory_calls = 0 + + def create_client(): + nonlocal factory_calls + factory_calls += 1 + time.sleep(0.01) + return _Client() + + binding = _compile(client_factory=create_client) + + with ThreadPoolExecutor(max_workers=8) as executor: + clients = list( + executor.map( + lambda _: binding.options.client_factory(), + range(8), + ) + ) + + assert factory_calls == 1 + assert all(client is clients[0] for client in clients) + + +def test_app_level_client_cache_is_shared_across_bindings(): + client_factory = provider._cache_client_factory(_Client) + + first = _compile(client_factory=client_factory) + second = _compile(client_factory=client_factory) + + assert first.options.client_factory() is second.options.client_factory() + assert len(_Client.created) == 1 + + +def test_binding_run_agent_returns_assistant_content(): + assert ( + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + == "response:hello" + ) + assert _Client.created[0].sessions[0].prompts == ["hello"] + assert not _Client.created[0].closed + + +def test_provider_uses_secure_default_client_and_validates_options(monkeypatch): + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "github-token") + monkeypatch.setenv("COPILOT_BASE_DIRECTORY", "copilot-data") + binding = provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"model": "gpt-5"}, + annotation=CopilotSession, + capabilities=AgentCapabilities(), + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert _Client.created[0].client_options == { + "mode": "empty", + "github_token": "github-token", + "base_directory": "copilot-data", + "use_logged_in_user": False, + "log_level": "none", + "telemetry": None, + } + with pytest.raises(TypeError, match="model option"): + _compile(model="") + with pytest.raises(TypeError, match="CopilotSession"): + provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"client_factory": _Client, "model": "gpt-5"}, + annotation=str, + capabilities=AgentCapabilities(), + ) + + +def test_provider_accepts_missing_annotation_for_durable_activity(): + binding = provider.CopilotSdkProvider().compile_binding( + instructions="instructions", + agent_name="orders", + options={"client_factory": _Client, "model": "gpt-5"}, + annotation=inspect.Signature.empty, + capabilities=AgentCapabilities(), + ) + + assert binding.agent_name == "orders" + + +def test_session_creation_error_keeps_cached_client_open(monkeypatch): + binding = _compile() + + async def fail_create(self, **options): + raise RuntimeError("session failed") + + monkeypatch.setattr(_Client, "create_session", fail_create) + + with pytest.raises(RuntimeError, match="session failed"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + + +def test_binding_maps_discovered_skills_and_mcp(monkeypatch): + monkeypatch.setenv("MCP_URL", "https://mcp.example.test") + binding = _compile( + capabilities=AgentCapabilities( + skills=( + SkillDefinition(Path("skills/inventory")), + SkillDefinition(Path("skills/orders")), + ), + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig( + "$MCP_URL", + allowed_tools=("lookup",), + headers=(("X-Tenant", "%TENANT_ID%"),), + ), + ), + ), + ) + ) + monkeypatch.setenv("TENANT_ID", "tenant-1") + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + pass + + asyncio.run(invoke()) + + options = _Client.created[0].session_options + assert options["enable_skills"] is True + assert options["skill_directories"] == ["skills"] + assert options["mcp_servers"] == { + "orders": { + "type": "http", + "url": "https://mcp.example.test", + "headers": {"X-Tenant": "tenant-1"}, + "tools": ["lookup"], + } + } + assert options["mcp_oauth_token_storage"] == "in-memory" + assert list(options["available_tools"]) == ["builtin:skill", "mcp:*"] + + +@pytest.mark.parametrize( + ("mcp_servers", "expected_available_tools"), + [ + ({}, []), + ( + { + "custom": { + "type": "http", + "url": "https://custom.example.test/mcp", + "tools": ["lookup"], + } + }, + ["mcp:*"], + ), + ], +) +def test_explicit_mcp_servers_replace_discovered_servers( + mcp_servers, + expected_available_tools, +): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "discovered", + MCPHTTPConfig("$MISSING_MCP_URL"), + ), + ), + ), + session_options={"mcp_servers": mcp_servers}, + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["mcp_servers"] == mcp_servers + assert list(options["available_tools"]) == expected_available_tools + + +def test_disabled_discovered_mcp_server_is_not_prepared(): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "inventory", + MCPHTTPConfig("$MISSING_INVENTORY_MCP_URL"), + ), + ), + ), + session_options={"disabled_mcp_servers": ["inventory"]}, + ) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["mcp_servers"] == {} + assert list(options["available_tools"]) == [] + + +@pytest.mark.parametrize( + ("available_tools", "expected_available_tools"), + [ + (None, ["builtin:skill"]), + (["builtin:custom"], ["builtin:custom"]), + ], +) +def test_explicit_skill_configuration_uses_effective_available_tools( + available_tools, + expected_available_tools, +): + session_options = { + "enable_skills": True, + "skill_directories": ["custom-skills"], + } + if available_tools is not None: + session_options["available_tools"] = available_tools + binding = _compile(session_options=session_options) + + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + options = _Client.created[0].session_options + assert options["enable_skills"] is True + assert options["skill_directories"] == ["custom-skills"] + assert list(options["available_tools"]) == expected_available_tools + + +def test_binding_allowlists_explicit_python_tools(): + binding = _compile( + tools=Tool( + name="lookup_order", + description="Look up an order", + ) + ) + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + pass + + asyncio.run(invoke()) + + options = _Client.created[0].session_options + assert list(options["available_tools"]) == ["custom:lookup_order"] + + +def test_mcp_credentials_require_https(monkeypatch): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig( + "http://mcp.example.test", + headers=(("X-Key", "secret"),), + ), + ), + ), + ) + ) + + with pytest.raises(ValueError, match="must use HTTPS"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + + +def test_mcp_url_credentials_require_https(): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig("http://user:password@mcp.example.test/mcp"), + ), + ), + ) + ) + + with pytest.raises(ValueError, match="must use HTTPS"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + + +def test_mcp_url_query_requires_https(): + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "orders", + MCPHTTPConfig( + "http://mcp.example.test/mcp?access_token=secret" + ), + ), + ), + ) + ) + + with pytest.raises(ValueError, match="must use HTTPS"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + + +def test_cancellation_closes_session_but_keeps_cached_client_open(monkeypatch): + async def cancel(self, prompt): + raise asyncio.CancelledError + + monkeypatch.setattr(_Session, "send_and_wait", cancel) + + with pytest.raises(asyncio.CancelledError): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + assert _Client.created[0].sessions[0].closed + assert _Client.created[0]._sessions == {} + + +@pytest.mark.parametrize( + "response", + [None, SimpleNamespace(data=object())], +) +def test_binding_rejects_missing_final_assistant_message(monkeypatch, response): + async def send(self, prompt): + return response + + monkeypatch.setattr(_Session, "send_and_wait", send) + + with pytest.raises(TypeError, match="final assistant message"): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + assert _Client.created[0].sessions[0].closed + assert _Client.created[0]._sessions == {} + + +def test_binding_rejects_non_string_assistant_content(monkeypatch): + class FakeAssistantMessageData: + content = None + + async def send(self, prompt): + return SimpleNamespace(data=FakeAssistantMessageData()) + + monkeypatch.setattr(provider, "AssistantMessageData", FakeAssistantMessageData) + monkeypatch.setattr(_Session, "send_and_wait", send) + + with pytest.raises(TypeError, match="content must be a string"): + asyncio.run(_compile().run_agent("hello", InvocationMetadata())) + + assert not _Client.created[0].closed + assert _Client.created[0].sessions[0].closed + + +@pytest.mark.parametrize("factory_kind", ["async_callable", "returns_awaitable"]) +def test_binding_rejects_awaitable_factory_results(factory_kind): + async def create_client(): + return _Client() + + if factory_kind == "async_callable": + + class AsyncFactory: + async def __call__(self): + return _Client() + + client_factory = AsyncFactory() + else: + client_factory = lambda: create_client() + + binding = _compile(client_factory=client_factory) + + with pytest.raises(TypeError, match="must return.*not an awaitable"): + asyncio.run(binding.run_agent("hello", InvocationMetadata())) + + +def test_provider_rejects_async_factory_function(): + async def create_client(): + return _Client() + + with pytest.raises(TypeError, match="must be a synchronous function"): + _compile(client_factory=create_client) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"unknown": True}, "Unsupported"), + ({"tools": ["lookup"]}, "copilot.tools.Tool"), + ({"session_provider": "custom"}, "ProviderConfig"), + ], +) +def test_provider_rejects_unsupported_options(overrides, message): + with pytest.raises(TypeError, match=message): + _compile(**overrides) + + +def test_mcp_entra_token_is_injected_and_credential_is_closed(monkeypatch): + import azure.identity.aio + + credentials = [] + + class FakeCredential: + def __init__(self, *, managed_identity_client_id): + self.managed_identity_client_id = managed_identity_client_id + self.scopes = [] + self.closed = False + credentials.append(self) + + async def get_token(self, scope): + self.scopes.append(scope) + return SimpleNamespace(token="entra-token") + + async def close(self): + self.closed = True + + monkeypatch.setattr( + azure.identity.aio, + "DefaultAzureCredential", + FakeCredential, + ) + monkeypatch.setenv("MCP_SCOPE", "api://inventory/.default") + monkeypatch.setenv("MCP_CLIENT_ID", "client-id") + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "inventory", + MCPHTTPConfig( + "https://mcp.example.test", + auth=MCPAuthConfig( + scope="$MCP_SCOPE", + client_id="%MCP_CLIENT_ID%", + ), + ), + ), + ), + ) + ) + + async def invoke(): + async with binding.open_agent(InvocationMetadata()): + assert not credentials[0].closed + + asyncio.run(invoke()) + + assert credentials[0].managed_identity_client_id == "client-id" + assert credentials[0].scopes == ["api://inventory/.default"] + assert credentials[0].closed + assert _Client.created[0].session_options["mcp_servers"]["inventory"] == { + "type": "http", + "url": "https://mcp.example.test", + "headers": {"Authorization": "Bearer entra-token"}, + "tools": ["*"], + } + + +def test_mcp_entra_cancellation_waits_for_token_acquisition_before_close(monkeypatch): + import azure.identity.aio + + started = asyncio.Event() + token_finished = False + close_states = [] + + class BlockingAsyncCredential: + def __init__(self, *, managed_identity_client_id): + pass + + async def get_token(self, scope): + nonlocal token_finished + started.set() + try: + await asyncio.Event().wait() + finally: + token_finished = True + + async def close(self): + close_states.append(token_finished) + + monkeypatch.setattr( + azure.identity.aio, + "DefaultAzureCredential", + BlockingAsyncCredential, + ) + binding = _compile( + capabilities=AgentCapabilities( + mcp_servers=( + MCPServerDefinition( + "inventory", + MCPHTTPConfig( + "https://mcp.example.test", + auth=MCPAuthConfig(scope="api://inventory/.default"), + ), + ), + ), + ) + ) + + async def cancel_during_token_acquisition(): + task = asyncio.create_task( + binding.run_agent("hello", InvocationMetadata()) + ) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(cancel_during_token_acquisition()) + + assert close_states == [True] diff --git a/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py new file mode 100644 index 0000000..4101197 --- /dev/null +++ b/azurefunctions-agents-extensions-copilot-sdk/tests/test_samples.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_PACKAGE_ROOT = Path(__file__).parents[1] +_SAMPLES_ROOT = _PACKAGE_ROOT / "samples" + + +@pytest.mark.parametrize( + ("sample_name", "expected_names"), + [ + ( + "agent_samples_copilot-sdk", + {"process_order", "process_order_event"}, + ), + ( + "agent_samples_copilot-sdk_durable", + { + "azurefunctions_agents_run_markdown_agent", + "order_orchestrator", + "start_order_orchestration", + }, + ), + ], +) +def test_sample_indexes_all_functions(sample_name, expected_names): + environment = os.environ.copy() + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(_PACKAGE_ROOT), environment.get("PYTHONPATH")]) + ) + environment.update( + { + "COPILOT_GITHUB_TOKEN": "index-only-placeholder", + "COPILOT_MODEL": "gpt-5", + "INVENTORY_MCP_URL": "https://inventory.example.test/mcp", + } + ) + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json; import function_app; " + "print(json.dumps([function.get_function_name() " + "for function in function_app.app.get_functions()]))" + ), + ], + cwd=_SAMPLES_ROOT / sample_name, + env=environment, + check=True, + capture_output=True, + text=True, + ) + + assert set(json.loads(completed.stdout)) == expected_names + + +def test_direct_sample_assets_follow_discovery_conventions(): + sample_root = _SAMPLES_ROOT / "agent_samples_copilot-sdk" + + assert (sample_root / "order-fulfillment.agent.md").is_file() + assert (sample_root / "skills" / "order-policy" / "SKILL.md").is_file() + assert (sample_root / "mcp.json").is_file() + + +@pytest.mark.parametrize( + "sample_name", + ["agent_samples_copilot-sdk", "agent_samples_copilot-sdk_durable"], +) +def test_samples_use_extension_default_client(sample_name): + source = (_SAMPLES_ROOT / sample_name / "function_app.py").read_text() + + assert "CopilotClient" not in source + assert "client_factory=" not in source + assert "app = AgentFunctionApp()" in source + + +@pytest.mark.parametrize( + "sample_name", + ["agent_samples_copilot-sdk", "agent_samples_copilot-sdk_durable"], +) +def test_sample_templates_use_standard_copilot_settings(sample_name): + settings = json.loads( + (_SAMPLES_ROOT / sample_name / "local.settings.template.json").read_text() + ) + + assert { + "COPILOT_GITHUB_TOKEN", + "COPILOT_BASE_DIRECTORY", + "COPILOT_MODEL", + } <= settings["Values"].keys() diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index c78ebf9..0f919cb 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -13,6 +13,9 @@ jobs: agents_framework_extension: EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-framework' EXTENSION_NAME: 'Agents Framework' + agents_copilot_sdk_extension: + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-copilot-sdk' + EXTENSION_NAME: 'Agents Copilot SDK' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' diff --git a/eng/templates/official/jobs/agent-provider-tests.yml b/eng/templates/official/jobs/agent-provider-tests.yml new file mode 100644 index 0000000..7a4efa1 --- /dev/null +++ b/eng/templates/official/jobs/agent-provider-tests.yml @@ -0,0 +1,44 @@ +parameters: + - name: PoolName + type: string + default: '' + - name: job_name + type: string + - name: extension_name + type: string + - name: extension_directory + type: string + +jobs: + - job: ${{ parameters.job_name }} + displayName: "${{ parameters.extension_name }} Extension Tests" + dependsOn: [] + pool: + name: ${{ parameters.PoolName }} + image: 1es-ubuntu-22.04 + os: linux + strategy: + matrix: + python313: + PYTHON_VERSION: '3.13' + python314: + PYTHON_VERSION: '3.14' + condition: always() + steps: + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate' + inputs: + artifactFeeds: public/upstream-public + onlyAddExtraIndex: false + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + python -m pip install -e ./azurefunctions-agents-extensions-base + cd ${{ parameters.extension_directory }} + python -m pip install -U -e .[dev,mcp] + displayName: "Install ${{ parameters.extension_name }} Dependencies" + - bash: | + python -m pytest -q --instafail ${{ parameters.extension_directory }}/tests/ + displayName: "Run ${{ parameters.extension_name }} Tests for Python $(PYTHON_VERSION)" diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index 0053ace..e763a3a 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -13,6 +13,9 @@ jobs: agents_framework_extension: EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-agent-framework' EXTENSION_NAME: 'Agents Framework' + agents_copilot_sdk_extension: + EXTENSION_DIRECTORY: 'azurefunctions-agents-extensions-copilot-sdk' + EXTENSION_NAME: 'Agents Copilot SDK' blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' diff --git a/eng/templates/official/jobs/unit-tests.yml b/eng/templates/official/jobs/unit-tests.yml index f5f5e31..848f816 100644 --- a/eng/templates/official/jobs/unit-tests.yml +++ b/eng/templates/official/jobs/unit-tests.yml @@ -49,38 +49,19 @@ jobs: python -m pytest -q --instafail azurefunctions-agents-extensions-base/tests/ displayName: "Run Agents Base Tests for Python $(PYTHON_VERSION)" - - job: "AgentsFrameworkTests" - displayName: "Agents Framework Extension Tests" - dependsOn: [] - pool: - name: ${{ parameters.PoolName }} - image: 1es-ubuntu-22.04 - os: linux - strategy: - matrix: - python313: - PYTHON_VERSION: '3.13' - python314: - PYTHON_VERSION: '3.14' - condition: always() - steps: - - task: PipAuthenticate@1 - displayName: 'Pip Authenticate' - inputs: - artifactFeeds: public/upstream-public - onlyAddExtraIndex: false - - task: UsePythonVersion@0 - inputs: - versionSpec: $(PYTHON_VERSION) - - bash: | - python -m pip install --upgrade pip - python -m pip install -e ./azurefunctions-agents-extensions-base - cd azurefunctions-agents-extensions-agent-framework - python -m pip install -U -e .[dev,mcp] - displayName: 'Install Agents Framework Dependencies' - - bash: | - python -m pytest -q --instafail azurefunctions-agents-extensions-agent-framework/tests/ - displayName: "Run Agents Framework Tests for Python $(PYTHON_VERSION)" + - template: /eng/templates/official/jobs/agent-provider-tests.yml@self + parameters: + PoolName: ${{ parameters.PoolName }} + job_name: AgentsFrameworkTests + extension_name: Agents Framework + extension_directory: azurefunctions-agents-extensions-agent-framework + + - template: /eng/templates/official/jobs/agent-provider-tests.yml@self + parameters: + PoolName: ${{ parameters.PoolName }} + job_name: AgentsCopilotSdkTests + extension_name: Agents Copilot SDK + extension_directory: azurefunctions-agents-extensions-copilot-sdk - job: "BaseTests" displayName: "Base Extension Tests"