diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5cecb76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.azure/ +.git/ +**/.env +**/.env.* +**/build/ +**/bin/ +**/obj/ +01-MAF-Agent-CS/ +02-MAF-Agent-CS-Hosted/ +03-MAF-Agent-GO/ +04-MAF-Agent-GO-Hosted/ +docs/ +*.md +*.slnx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6652f57 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,93 @@ +# Copilot instructions for this repository + +## Terminology + +Always say **"Microsoft Foundry"**. Never use "Azure AI Foundry" (the old +product name) in code, comments, docs, or commit messages — including in +new content you write and when fixing existing content that uses the old +name. This also applies to related product nouns: prefer "Foundry Project" +over "Azure AI Project" in prose (e.g. "calls a model deployment in a +Microsoft Foundry Project"). + +This rule applies to prose only — do NOT rename literal SDK/package/class +identifiers that happen to contain "AI" (e.g. the `Azure.AI.Projects` NuGet +package, the `AIProjectClient` class). Those are real, versioned API names +and must stay exactly as published upstream. + +## What this repo is + +A set of six small, independent sample apps showing Microsoft Agent Framework +(MAF) agents backed by Microsoft Foundry, across three languages (C#, Go, +C++) and two hosting styles (console app vs. hosted agent). There is no +shared runtime code between samples — each folder is self-contained with its +own dependency manifest, build system, and README. + +## Foundry agent work + +This project was built with the `microsoft-foundry` skill. Before working on +or answering questions about Foundry agents (deploy, invoke, evaluate, +troubleshoot, scaffold new agents, etc.), read that skill first — see +[AGENTS.md](/d:/azure-samples/microsoft-foundry-hosted-agents/AGENTS.md). + +## Folder naming convention + +Folders are numbered by build/complexity order and named +`NN--Agent-[-Hosted]`: + +| Folder | Language | Type | +|---|---|---| +| [01-MAF-Agent-CS](/d:/azure-samples/microsoft-foundry-hosted-agents/01-MAF-Agent-CS) | C# | Console app | +| [02-MAF-Agent-CS-Hosted](/d:/azure-samples/microsoft-foundry-hosted-agents/02-MAF-Agent-CS-Hosted) | C# | Hosted agent | +| [03-MAF-Agent-GO](/d:/azure-samples/microsoft-foundry-hosted-agents/03-MAF-Agent-GO) | Go | Console app | +| [04-MAF-Agent-GO-Hosted](/d:/azure-samples/microsoft-foundry-hosted-agents/04-MAF-Agent-GO-Hosted) | Go | Hosted agent | +| [05-Foundry-Agent-CPP](/d:/azure-samples/microsoft-foundry-hosted-agents/05-Foundry-Agent-CPP) | C++ | Console app | +| [06-Foundry-Agent-CPP-Hosted](/d:/azure-samples/microsoft-foundry-hosted-agents/06-Foundry-Agent-CPP-Hosted) | C++ | Hosted agent | + +The C++ folders intentionally use `Foundry-Agent-CPP` (not `MAF-Agent-CPP`) +because Microsoft Foundry / Microsoft Agent Framework do not provide a +first-party C++ agent SDK — the C++ samples are repository-owned adapters, +not MAF SDK usage. See +[docs/research/cpp-agents-with-microsoft-foundry.md](/d:/azure-samples/microsoft-foundry-hosted-agents/docs/research/cpp-agents-with-microsoft-foundry.md). + +**Critical policy when renaming, moving, or refactoring folders:** folder +names, file paths, and prose/doc references may be changed freely, but the +following internal/deployed identifiers must NOT be changed just because a +folder was renamed, since changing them can break existing Foundry +deployments or violate language constraints: + +- `name:` / service keys in each sample's `azure.yaml` (azd deployment + identifiers, e.g. `maf-agent-cs-02`, `maf-agent-go-04`, `maf-agent-cpp-06`) +- CMake `project(...)` names, target names, and `option(...)` names in + `CMakeLists.txt` +- `vcpkg.json` `name` fields +- Go module import paths (`go.mod`) +- C# `RootNamespace` in `.csproj` files (C# identifiers can't start with a + digit, so these stay like `MAF_Agent_CS_01`) + +The only path-like references inside those same files that DO need to track +a rename are hard functional dependencies, e.g. `entryPoint` (dll filename) +in `azure.yaml`, the source-path argument of `add_subdirectory(...)` in +CMakeLists.txt, Dockerfile `COPY`/`WORKDIR` paths, and CI workflow matrix +values / cache-key globs in +[.github/workflows/build.yml](/d:/azure-samples/microsoft-foundry-hosted-agents/.github/workflows/build.yml). + +Historical/dated docs (e.g. research reports with footnote links pinned to a +specific commit SHA) should be left as frozen snapshots, not rewritten to +match current folder names. + +## Build, run, test + +- **.NET**: `dotnet build .\MAF-Agents-Samples.slnx` builds both C# samples. +- **Go**: each Go sample is built/tested independently from its own folder + (`go build ./...`, `go test ./...`); there is no top-level Go workspace. +- **C++**: each C++ sample uses its own `CMakePresets.json` (`debug` preset) + with vcpkg for dependencies; `06-Foundry-Agent-CPP-Hosted` depends on + `05-Foundry-Agent-CPP` via `add_subdirectory`, so `05` must be buildable on + its own first. +- CI (`.github/workflows/build.yml`) runs all three toolchains on every push + and PR to `main` without needing Foundry credentials — it only validates + that code compiles and unit tests pass, not live Foundry calls. + +See the root [README.md](/d:/azure-samples/microsoft-foundry-hosted-agents/README.md) for prerequisites (Foundry project endpoint, model +deployment name, tooling versions) and per-sample READMEs for +language-specific details. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 686bd2e..0531f41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - module: [MAF-Agent-GO-03, MAF-Agent-GO-04] + module: [03-MAF-Agent-GO, 04-MAF-Agent-GO-Hosted] steps: - uses: actions/checkout@v4 @@ -42,3 +42,39 @@ jobs: - name: Test working-directory: ${{ matrix.module }} run: go test ./... + + cpp-build: + name: Build and test C++ samples + runs-on: ubuntu-latest + strategy: + matrix: + module: [05-Foundry-Agent-CPP, 06-Foundry-Agent-CPP-Hosted] + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + run: sudo apt-get update && sudo apt-get install -y ninja-build + + - name: Bootstrap vcpkg + run: | + git clone https://github.com/microsoft/vcpkg "$HOME/vcpkg" + "$HOME/vcpkg/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$HOME/vcpkg" >> "$GITHUB_ENV" + + - name: Cache vcpkg binaries + uses: actions/cache@v4 + with: + path: ~/.cache/vcpkg/archives + key: ${{ runner.os }}-vcpkg-${{ hashFiles('0[56]-*CPP*/vcpkg.json', '0[56]-*CPP*/vcpkg-configuration.json') }} + + - name: Configure + working-directory: ${{ matrix.module }} + run: cmake --preset debug + + - name: Build + working-directory: ${{ matrix.module }} + run: cmake --build --preset debug + + - name: Test + working-directory: ${{ matrix.module }} + run: ctest --preset debug diff --git a/.gitignore b/.gitignore index 3e212bd..6355e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -439,3 +439,11 @@ vendor/ .env.* !.env.example .azure/ + +# CMake and vcpkg artifacts +build/ +vcpkg_installed/ +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CTestTestfile.cmake diff --git a/MAF-Agent-CS-01/MAF-Agent-CS-01.csproj b/01-MAF-Agent-CS/01-MAF-Agent-CS.csproj similarity index 100% rename from MAF-Agent-CS-01/MAF-Agent-CS-01.csproj rename to 01-MAF-Agent-CS/01-MAF-Agent-CS.csproj diff --git a/MAF-Agent-CS-01/Program.cs b/01-MAF-Agent-CS/Program.cs similarity index 100% rename from MAF-Agent-CS-01/Program.cs rename to 01-MAF-Agent-CS/Program.cs diff --git a/MAF-Agent-CS-02/.agent_configs/baseline/instructions.md b/02-MAF-Agent-CS-Hosted/.agent_configs/baseline/instructions.md similarity index 100% rename from MAF-Agent-CS-02/.agent_configs/baseline/instructions.md rename to 02-MAF-Agent-CS-Hosted/.agent_configs/baseline/instructions.md diff --git a/MAF-Agent-CS-02/.agent_configs/baseline/metadata.yaml b/02-MAF-Agent-CS-Hosted/.agent_configs/baseline/metadata.yaml similarity index 100% rename from MAF-Agent-CS-02/.agent_configs/baseline/metadata.yaml rename to 02-MAF-Agent-CS-Hosted/.agent_configs/baseline/metadata.yaml diff --git a/MAF-Agent-CS-02/.agentignore b/02-MAF-Agent-CS-Hosted/.agentignore similarity index 100% rename from MAF-Agent-CS-02/.agentignore rename to 02-MAF-Agent-CS-Hosted/.agentignore diff --git a/MAF-Agent-CS-02/.gitignore b/02-MAF-Agent-CS-Hosted/.gitignore similarity index 100% rename from MAF-Agent-CS-02/.gitignore rename to 02-MAF-Agent-CS-Hosted/.gitignore diff --git a/MAF-Agent-CS-02/MAF-Agent-CS-02.csproj b/02-MAF-Agent-CS-Hosted/02-MAF-Agent-CS-Hosted.csproj similarity index 100% rename from MAF-Agent-CS-02/MAF-Agent-CS-02.csproj rename to 02-MAF-Agent-CS-Hosted/02-MAF-Agent-CS-Hosted.csproj diff --git a/MAF-Agent-CS-02/Program.cs b/02-MAF-Agent-CS-Hosted/Program.cs similarity index 100% rename from MAF-Agent-CS-02/Program.cs rename to 02-MAF-Agent-CS-Hosted/Program.cs diff --git a/MAF-Agent-CS-02/azure.yaml b/02-MAF-Agent-CS-Hosted/azure.yaml similarity index 94% rename from MAF-Agent-CS-02/azure.yaml rename to 02-MAF-Agent-CS-Hosted/azure.yaml index 133ebb3..436aff5 100644 --- a/MAF-Agent-CS-02/azure.yaml +++ b/02-MAF-Agent-CS-Hosted/azure.yaml @@ -15,7 +15,7 @@ services: AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} codeConfiguration: dependencyResolution: bundled - entryPoint: MAF-Agent-CS-02.dll + entryPoint: 02-MAF-Agent-CS-Hosted.dll runtime: dotnet_10 container: resources: diff --git a/MAF-Agent-CS-02/datasets/smoke-core/smoke-core_dg.jsonl b/02-MAF-Agent-CS-Hosted/datasets/smoke-core/smoke-core_dg.jsonl similarity index 100% rename from MAF-Agent-CS-02/datasets/smoke-core/smoke-core_dg.jsonl rename to 02-MAF-Agent-CS-Hosted/datasets/smoke-core/smoke-core_dg.jsonl diff --git a/MAF-Agent-GO-03/.env.example b/03-MAF-Agent-GO/.env.example similarity index 100% rename from MAF-Agent-GO-03/.env.example rename to 03-MAF-Agent-GO/.env.example diff --git a/MAF-Agent-GO-03/README.md b/03-MAF-Agent-GO/README.md similarity index 97% rename from MAF-Agent-GO-03/README.md rename to 03-MAF-Agent-GO/README.md index ba366fb..08a188b 100644 --- a/MAF-Agent-GO-03/README.md +++ b/03-MAF-Agent-GO/README.md @@ -1,4 +1,4 @@ -# MAF-Agent-GO-03 +# 03-MAF-Agent-GO This console sample uses the Microsoft Agent Framework for Go with a Microsoft Foundry project-backed agent. diff --git a/MAF-Agent-GO-03/go.mod b/03-MAF-Agent-GO/go.mod similarity index 100% rename from MAF-Agent-GO-03/go.mod rename to 03-MAF-Agent-GO/go.mod diff --git a/MAF-Agent-GO-03/go.sum b/03-MAF-Agent-GO/go.sum similarity index 100% rename from MAF-Agent-GO-03/go.sum rename to 03-MAF-Agent-GO/go.sum diff --git a/MAF-Agent-GO-03/maf-agent-go-03.exe b/03-MAF-Agent-GO/maf-agent-go-03.exe similarity index 96% rename from MAF-Agent-GO-03/maf-agent-go-03.exe rename to 03-MAF-Agent-GO/maf-agent-go-03.exe index dfdc67c..0672a69 100644 Binary files a/MAF-Agent-GO-03/maf-agent-go-03.exe and b/03-MAF-Agent-GO/maf-agent-go-03.exe differ diff --git a/MAF-Agent-GO-03/main.go b/03-MAF-Agent-GO/main.go similarity index 100% rename from MAF-Agent-GO-03/main.go rename to 03-MAF-Agent-GO/main.go diff --git a/MAF-Agent-GO-04/.agentignore b/04-MAF-Agent-GO-Hosted/.agentignore similarity index 100% rename from MAF-Agent-GO-04/.agentignore rename to 04-MAF-Agent-GO-Hosted/.agentignore diff --git a/MAF-Agent-GO-04/.dockerignore b/04-MAF-Agent-GO-Hosted/.dockerignore similarity index 100% rename from MAF-Agent-GO-04/.dockerignore rename to 04-MAF-Agent-GO-Hosted/.dockerignore diff --git a/MAF-Agent-GO-04/.env.example b/04-MAF-Agent-GO-Hosted/.env.example similarity index 100% rename from MAF-Agent-GO-04/.env.example rename to 04-MAF-Agent-GO-Hosted/.env.example diff --git a/MAF-Agent-GO-04/.gitignore b/04-MAF-Agent-GO-Hosted/.gitignore similarity index 100% rename from MAF-Agent-GO-04/.gitignore rename to 04-MAF-Agent-GO-Hosted/.gitignore diff --git a/MAF-Agent-GO-04/Dockerfile b/04-MAF-Agent-GO-Hosted/Dockerfile similarity index 100% rename from MAF-Agent-GO-04/Dockerfile rename to 04-MAF-Agent-GO-Hosted/Dockerfile diff --git a/MAF-Agent-GO-04/README.md b/04-MAF-Agent-GO-Hosted/README.md similarity index 99% rename from MAF-Agent-GO-04/README.md rename to 04-MAF-Agent-GO-Hosted/README.md index 7077b12..7c7a52f 100644 --- a/MAF-Agent-GO-04/README.md +++ b/04-MAF-Agent-GO-Hosted/README.md @@ -1,4 +1,4 @@ -# MAF-Agent-GO-04 +# 04-MAF-Agent-GO-Hosted This sample hosts a Microsoft Agent Framework Go agent as a containerized Microsoft Foundry Hosted Agent. It exposes `/invocations` for plain-text chat prompts and provides `/readiness` for platform health checks. The same endpoint also supports optional Agent Framework Go AG-UI JSON requests with Server-Sent Events (SSE) responses. diff --git a/MAF-Agent-GO-04/azure.yaml b/04-MAF-Agent-GO-Hosted/azure.yaml similarity index 100% rename from MAF-Agent-GO-04/azure.yaml rename to 04-MAF-Agent-GO-Hosted/azure.yaml diff --git a/MAF-Agent-GO-04/go.mod b/04-MAF-Agent-GO-Hosted/go.mod similarity index 100% rename from MAF-Agent-GO-04/go.mod rename to 04-MAF-Agent-GO-Hosted/go.mod diff --git a/MAF-Agent-GO-04/go.sum b/04-MAF-Agent-GO-Hosted/go.sum similarity index 100% rename from MAF-Agent-GO-04/go.sum rename to 04-MAF-Agent-GO-Hosted/go.sum diff --git a/MAF-Agent-GO-04/maf-agent-go-04.exe b/04-MAF-Agent-GO-Hosted/maf-agent-go-04.exe similarity index 92% rename from MAF-Agent-GO-04/maf-agent-go-04.exe rename to 04-MAF-Agent-GO-Hosted/maf-agent-go-04.exe index 0b2fdd9..baab02a 100644 Binary files a/MAF-Agent-GO-04/maf-agent-go-04.exe and b/04-MAF-Agent-GO-Hosted/maf-agent-go-04.exe differ diff --git a/MAF-Agent-GO-04/main.go b/04-MAF-Agent-GO-Hosted/main.go similarity index 100% rename from MAF-Agent-GO-04/main.go rename to 04-MAF-Agent-GO-Hosted/main.go diff --git a/MAF-Agent-GO-04/main_test.go b/04-MAF-Agent-GO-Hosted/main_test.go similarity index 100% rename from MAF-Agent-GO-04/main_test.go rename to 04-MAF-Agent-GO-Hosted/main_test.go diff --git a/05-Foundry-Agent-CPP/.env.example b/05-Foundry-Agent-CPP/.env.example new file mode 100644 index 0000000..4a725ab --- /dev/null +++ b/05-Foundry-Agent-CPP/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT= +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5-mini diff --git a/05-Foundry-Agent-CPP/CMakeLists.txt b/05-Foundry-Agent-CPP/CMakeLists.txt new file mode 100644 index 0000000..9baf175 --- /dev/null +++ b/05-Foundry-Agent-CPP/CMakeLists.txt @@ -0,0 +1,61 @@ +cmake_minimum_required(VERSION 3.25) + +project(maf_agent_cpp_05 VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(azure-core-cpp CONFIG REQUIRED) +find_package(azure-identity-cpp CONFIG REQUIRED) +find_package(CURL REQUIRED) +find_package(nlohmann_json CONFIG REQUIRED) + +add_library(foundry_agent STATIC + src/agent.cpp + src/config.cpp + src/http_transport.cpp +) +add_library(FoundryAgent::foundry_agent ALIAS foundry_agent) + +target_include_directories(foundry_agent + PUBLIC + $ +) +target_link_libraries(foundry_agent + PUBLIC + Azure::azure-core + Azure::azure-identity + CURL::libcurl + nlohmann_json::nlohmann_json +) +target_compile_features(foundry_agent PUBLIC cxx_std_20) + +if(MSVC) + target_compile_options(foundry_agent PRIVATE /W4 /permissive-) +else() + target_compile_options(foundry_agent PRIVATE -Wall -Wextra -Wpedantic) +endif() + +add_executable(maf_agent_cpp_05 src/main.cpp) +target_link_libraries(maf_agent_cpp_05 PRIVATE FoundryAgent::foundry_agent) + +option(MAF_CPP05_BUILD_TESTS "Build MAF-Agent-CPP-05 tests" ON) +if(MAF_CPP05_BUILD_TESTS) + include(CTest) + find_package(Catch2 3 CONFIG REQUIRED) + + add_executable(maf_agent_cpp_05_tests + tests/agent_tests.cpp + tests/config_tests.cpp + ) + target_include_directories(maf_agent_cpp_05_tests PRIVATE tests) + target_link_libraries(maf_agent_cpp_05_tests + PRIVATE + FoundryAgent::foundry_agent + Catch2::Catch2WithMain + ) + + include(Catch) + catch_discover_tests(maf_agent_cpp_05_tests) +endif() diff --git a/05-Foundry-Agent-CPP/CMakePresets.json b/05-Foundry-Agent-CPP/CMakePresets.json new file mode 100644 index 0000000..d86f09b --- /dev/null +++ b/05-Foundry-Agent-CPP/CMakePresets.json @@ -0,0 +1,55 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 25, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "debug", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "MAF_CPP05_BUILD_TESTS": "ON" + } + }, + { + "name": "release", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "MAF_CPP05_BUILD_TESTS": "OFF" + } + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + } + ], + "testPresets": [ + { + "name": "debug", + "configurePreset": "debug", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/05-Foundry-Agent-CPP/README.md b/05-Foundry-Agent-CPP/README.md new file mode 100644 index 0000000..2728172 --- /dev/null +++ b/05-Foundry-Agent-CPP/README.md @@ -0,0 +1,64 @@ +# 05-Foundry-Agent-CPP + +This C++20 console sample calls a model deployment in a Microsoft Foundry Project, prints one answer, and exits. It mirrors the local C# and Go samples. + +Microsoft does not currently provide a Foundry agent SDK for C++. This sample uses first-party [`azure-identity-cpp`](https://github.com/Azure/azure-sdk-for-cpp/tree/main/sdk/identity/azure-identity) for `DefaultAzureCredential`, then uses a small repository-owned libcurl client for the project-scoped OpenAI Responses endpoint. See the [C++ research report](../docs/research/cpp-agents-with-microsoft-foundry.md) for alternatives and support boundaries. + +## Prerequisites + +- CMake 3.25 or later +- Ninja +- A C++20-capable compiler +- [vcpkg](https://vcpkg.io), with `VCPKG_ROOT` set +- Azure CLI signed in with `az login`, or another `DefaultAzureCredential` source +- A Foundry Project with a deployed model + +## Configure + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-5-mini" +``` + +The model deployment variable is optional and defaults to `gpt-5-mini`. The project endpoint must use HTTPS and retain `/api/projects/`. + +## Build and test + +From this directory: + +```powershell +cmake --preset debug +cmake --build --preset debug +ctest --preset debug +``` + +The tests inject both the credential and HTTP transport, so they do not require Azure access. + +## Run + +Windows: + +```powershell +.\build\debug\maf_agent_cpp_05.exe +``` + +Linux or macOS: + +```bash +./build/debug/maf_agent_cpp_05 +``` + +The request uses token scope `https://ai.azure.com/.default` and posts to: + +```text +/openai/v1/responses +``` + +## Initial limitations + +- Non-streaming +- No tools +- No conversation state +- Repository-owned Foundry REST adapter, with no first-party C++ SDK support commitment + +This sample has unit coverage designed for offline execution. A live Foundry call must be validated against your own project and permissions. diff --git a/05-Foundry-Agent-CPP/include/foundry_agent/agent.hpp b/05-Foundry-Agent-CPP/include/foundry_agent/agent.hpp new file mode 100644 index 0000000..3335739 --- /dev/null +++ b/05-Foundry-Agent-CPP/include/foundry_agent/agent.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "foundry_agent/config.hpp" +#include "foundry_agent/http_transport.hpp" + +#include +#include + +#include +#include + +namespace foundry_agent { + +struct AgentResponse final { + std::string text; + std::string responseId; +}; + +class Agent final { +public: + Agent( + Config config, + std::shared_ptr credential, + std::shared_ptr transport = {}); + + static Agent CreateDefault(Config config); + + AgentResponse Run( + const std::string& input, + const Azure::Core::Context& context = {}) const; + +private: + Config config_; + std::shared_ptr credential_; + std::shared_ptr transport_; +}; + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/include/foundry_agent/config.hpp b/05-Foundry-Agent-CPP/include/foundry_agent/config.hpp new file mode 100644 index 0000000..d2262a9 --- /dev/null +++ b/05-Foundry-Agent-CPP/include/foundry_agent/config.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace foundry_agent { + +class Config final { +public: + static constexpr std::string_view DefaultModel = "gpt-5-mini"; + static constexpr std::string_view TokenScope = "https://ai.azure.com/.default"; + static constexpr std::string_view DefaultInstructions = + "You are a friendly assistant. Keep your answers brief."; + + Config(std::string projectEndpoint, std::string modelDeployment); + + static Config FromEnvironment(); + + [[nodiscard]] const std::string& ProjectEndpoint() const noexcept; + [[nodiscard]] const std::string& ModelDeployment() const noexcept; + [[nodiscard]] std::string ResponsesUrl() const; + +private: + std::string projectEndpoint_; + std::string modelDeployment_; +}; + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/include/foundry_agent/errors.hpp b/05-Foundry-Agent-CPP/include/foundry_agent/errors.hpp new file mode 100644 index 0000000..fbf3210 --- /dev/null +++ b/05-Foundry-Agent-CPP/include/foundry_agent/errors.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace foundry_agent { + +class AgentError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +class ConfigError final : public AgentError { +public: + using AgentError::AgentError; +}; + +class AuthenticationError final : public AgentError { +public: + using AgentError::AgentError; +}; + +class TransportError final : public AgentError { +public: + using AgentError::AgentError; +}; + +class ResponseError final : public AgentError { +public: + using AgentError::AgentError; +}; + +class ServiceError final : public AgentError { +public: + ServiceError(int statusCode, std::string serviceCode, std::string message) + : AgentError(std::move(message)), + statusCode_(statusCode), + serviceCode_(std::move(serviceCode)) {} + + [[nodiscard]] int StatusCode() const noexcept { return statusCode_; } + [[nodiscard]] const std::string& ServiceCode() const noexcept { return serviceCode_; } + +private: + int statusCode_; + std::string serviceCode_; +}; + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/include/foundry_agent/http_transport.hpp b/05-Foundry-Agent-CPP/include/foundry_agent/http_transport.hpp new file mode 100644 index 0000000..dad744f --- /dev/null +++ b/05-Foundry-Agent-CPP/include/foundry_agent/http_transport.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace foundry_agent { + +struct HttpRequest final { + std::string url; + std::map headers; + std::string body; +}; + +struct HttpResponse final { + int statusCode{}; + std::map headers; + std::string body; +}; + +class HttpTransport { +public: + virtual ~HttpTransport() = default; + virtual HttpResponse Post(const HttpRequest& request) = 0; +}; + +std::shared_ptr MakeCurlTransport(); + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/src/agent.cpp b/05-Foundry-Agent-CPP/src/agent.cpp new file mode 100644 index 0000000..d55b967 --- /dev/null +++ b/05-Foundry-Agent-CPP/src/agent.cpp @@ -0,0 +1,138 @@ +#include "foundry_agent/agent.hpp" + +#include "foundry_agent/errors.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace foundry_agent { +namespace { + +std::string ExtractErrorCode(const nlohmann::json& document) +{ + if (document.contains("error") && document["error"].is_object()) { + return document["error"].value("code", ""); + } + return {}; +} + +std::string ExtractErrorMessage(const nlohmann::json& document) +{ + if (document.contains("error") && document["error"].is_object()) { + return document["error"].value("message", "Foundry returned an error."); + } + return "Foundry returned an error."; +} + +} // namespace + +Agent::Agent( + Config config, + std::shared_ptr credential, + std::shared_ptr transport) + : config_(std::move(config)), + credential_(std::move(credential)), + transport_(transport ? std::move(transport) : MakeCurlTransport()) +{ + if (!credential_) { + throw ConfigError{"A token credential is required."}; + } +} + +Agent Agent::CreateDefault(Config config) +{ + return Agent{ + std::move(config), + std::make_shared()}; +} + +AgentResponse Agent::Run( + const std::string& input, + const Azure::Core::Context& context) const +{ + if (input.empty()) { + throw ResponseError{"Agent input must not be empty."}; + } + + Azure::Core::Credentials::TokenRequestContext tokenRequest; + tokenRequest.Scopes = {std::string{Config::TokenScope}}; + + std::string token; + try { + token = credential_->GetToken(tokenRequest, context).Token; + } catch (const Azure::Core::Credentials::AuthenticationException& error) { + throw AuthenticationError{"Failed to acquire a Foundry access token: " + + std::string{error.what()}}; + } + + const nlohmann::json requestDocument{ + {"model", config_.ModelDeployment()}, + {"instructions", Config::DefaultInstructions}, + {"input", nlohmann::json::array({ + { + {"role", "user"}, + {"content", input} + } + })} + }; + + const HttpResponse response = transport_->Post(HttpRequest{ + config_.ResponsesUrl(), + { + {"Accept", "application/json"}, + {"Authorization", "Bearer " + token}, + {"Content-Type", "application/json"} + }, + requestDocument.dump() + }); + + nlohmann::json responseDocument; + try { + responseDocument = nlohmann::json::parse(response.body); + } catch (const nlohmann::json::exception&) { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ServiceError{ + response.statusCode, "", "Foundry returned a non-JSON error response."}; + } + throw ResponseError{"Foundry returned malformed JSON."}; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ServiceError{ + response.statusCode, + ExtractErrorCode(responseDocument), + ExtractErrorMessage(responseDocument)}; + } + + AgentResponse result; + result.responseId = responseDocument.value("id", ""); + + if (responseDocument.contains("output") && responseDocument["output"].is_array()) { + for (const auto& output : responseDocument["output"]) { + if (!output.is_object() || !output.contains("content") || + !output["content"].is_array()) { + continue; + } + for (const auto& content : output["content"]) { + if (content.is_object() && content.value("type", "") == "output_text") { + if (!result.text.empty()) { + result.text.push_back('\n'); + } + result.text += content.value("text", ""); + } + } + } + } + + if (result.text.empty()) { + throw ResponseError{"Foundry response did not contain assistant output text."}; + } + return result; +} + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/src/config.cpp b/05-Foundry-Agent-CPP/src/config.cpp new file mode 100644 index 0000000..6bddd98 --- /dev/null +++ b/05-Foundry-Agent-CPP/src/config.cpp @@ -0,0 +1,71 @@ +#include "foundry_agent/config.hpp" + +#include "foundry_agent/errors.hpp" + +#include +#include +#include + +namespace foundry_agent { +namespace { + +std::string ReadEnvironment(const char* name) +{ + const char* value = std::getenv(name); + return value == nullptr ? std::string{} : std::string{value}; +} + +} // namespace + +Config::Config(std::string projectEndpoint, std::string modelDeployment) + : projectEndpoint_(std::move(projectEndpoint)), + modelDeployment_(std::move(modelDeployment)) +{ + while (!projectEndpoint_.empty() && projectEndpoint_.back() == '/') { + projectEndpoint_.pop_back(); + } + + if (!projectEndpoint_.starts_with("https://")) { + throw ConfigError{"FOUNDRY_PROJECT_ENDPOINT must be an HTTPS URL."}; + } + if (projectEndpoint_.find("/api/projects/") == std::string::npos) { + throw ConfigError{ + "FOUNDRY_PROJECT_ENDPOINT must include /api/projects/."}; + } + if (projectEndpoint_.find_first_of("?#") != std::string::npos) { + throw ConfigError{"FOUNDRY_PROJECT_ENDPOINT must not contain a query or fragment."}; + } + if (modelDeployment_.empty()) { + throw ConfigError{"AZURE_AI_MODEL_DEPLOYMENT_NAME must not be empty."}; + } +} + +Config Config::FromEnvironment() +{ + auto endpoint = ReadEnvironment("FOUNDRY_PROJECT_ENDPOINT"); + auto model = ReadEnvironment("AZURE_AI_MODEL_DEPLOYMENT_NAME"); + if (endpoint.empty()) { + throw ConfigError{"Set FOUNDRY_PROJECT_ENDPOINT environment variable."}; + } + if (model.empty()) { + model = DefaultModel; + } + return Config{std::move(endpoint), std::move(model)}; +} + +const std::string& Config::ProjectEndpoint() const noexcept +{ + return projectEndpoint_; +} + +const std::string& Config::ModelDeployment() const noexcept +{ + return modelDeployment_; +} + +std::string Config::ResponsesUrl() const +{ + return projectEndpoint_ + "/openai/v1/responses"; +} + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/src/http_transport.cpp b/05-Foundry-Agent-CPP/src/http_transport.cpp new file mode 100644 index 0000000..4f62604 --- /dev/null +++ b/05-Foundry-Agent-CPP/src/http_transport.cpp @@ -0,0 +1,120 @@ +#include "foundry_agent/http_transport.hpp" + +#include "foundry_agent/errors.hpp" + +#include + +#include +#include +#include +#include + +namespace foundry_agent { +namespace { + +size_t WriteBody(char* data, size_t size, size_t count, void* userData) +{ + const size_t length = size * count; + static_cast(userData)->append(data, length); + return length; +} + +size_t WriteHeader(char* data, size_t size, size_t count, void* userData) +{ + const size_t length = size * count; + std::string line{data, length}; + const auto separator = line.find(':'); + if (separator != std::string::npos) { + auto name = line.substr(0, separator); + auto value = line.substr(separator + 1); + value.erase(value.begin(), std::find_if(value.begin(), value.end(), [](unsigned char ch) { + return !std::isspace(ch); + })); + while (!value.empty() && std::isspace(static_cast(value.back()))) { + value.pop_back(); + } + static_cast*>(userData) + ->insert_or_assign(std::move(name), std::move(value)); + } + return length; +} + +class CurlGlobal final { +public: + CurlGlobal() + { + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + throw TransportError{"Failed to initialize libcurl."}; + } + } + + ~CurlGlobal() + { + curl_global_cleanup(); + } +}; + +class CurlTransport final : public HttpTransport { +public: + HttpResponse Post(const HttpRequest& request) override + { + std::unique_ptr curl{ + curl_easy_init(), &curl_easy_cleanup}; + if (!curl) { + throw TransportError{"Failed to create a libcurl request."}; + } + + curl_slist* headerList = nullptr; + for (const auto& [name, value] : request.headers) { + headerList = curl_slist_append(headerList, (name + ": " + value).c_str()); + } + std::unique_ptr headers{ + headerList, &curl_slist_free_all}; + + HttpResponse response; + char errorBuffer[CURL_ERROR_SIZE]{}; + + curl_easy_setopt(curl.get(), CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get()); + curl_easy_setopt(curl.get(), CURLOPT_POST, 1L); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, request.body.data()); + curl_easy_setopt( + curl.get(), CURLOPT_POSTFIELDSIZE_LARGE, static_cast(request.body.size())); + curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, WriteBody); + curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response.body); + curl_easy_setopt(curl.get(), CURLOPT_HEADERFUNCTION, WriteHeader); + curl_easy_setopt(curl.get(), CURLOPT_HEADERDATA, &response.headers); + curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT_MS, 10'000L); + curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT_MS, 120'000L); + curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl.get(), CURLOPT_ERRORBUFFER, errorBuffer); + + const CURLcode result = curl_easy_perform(curl.get()); + if (result != CURLE_OK) { + const std::string detail = + errorBuffer[0] == '\0' ? curl_easy_strerror(result) : errorBuffer; + throw TransportError{"Foundry request failed: " + detail}; + } + + long statusCode = 0; + curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &statusCode); + response.statusCode = static_cast(statusCode); + return response; + } +}; + +CurlGlobal& GlobalCurl() +{ + static CurlGlobal instance; + return instance; +} + +} // namespace + +std::shared_ptr MakeCurlTransport() +{ + (void)GlobalCurl(); + return std::make_shared(); +} + +} // namespace foundry_agent diff --git a/05-Foundry-Agent-CPP/src/main.cpp b/05-Foundry-Agent-CPP/src/main.cpp new file mode 100644 index 0000000..28da7d9 --- /dev/null +++ b/05-Foundry-Agent-CPP/src/main.cpp @@ -0,0 +1,25 @@ +#include "foundry_agent/agent.hpp" +#include "foundry_agent/config.hpp" +#include "foundry_agent/errors.hpp" + +#include +#include + +int main() +{ + try { + auto agent = foundry_agent::Agent::CreateDefault( + foundry_agent::Config::FromEnvironment()); + const auto response = agent.Run("Hello! Tell me a fun fact about C++."); + std::cout << response.text << '\n'; + return EXIT_SUCCESS; + } catch (const foundry_agent::ServiceError& error) { + std::cerr << "Foundry service error (" << error.StatusCode() << "): " + << error.what() << '\n'; + } catch (const foundry_agent::AgentError& error) { + std::cerr << "Agent error: " << error.what() << '\n'; + } catch (const std::exception& error) { + std::cerr << "Unexpected error: " << error.what() << '\n'; + } + return EXIT_FAILURE; +} diff --git a/05-Foundry-Agent-CPP/tests/agent_tests.cpp b/05-Foundry-Agent-CPP/tests/agent_tests.cpp new file mode 100644 index 0000000..0e65e6a --- /dev/null +++ b/05-Foundry-Agent-CPP/tests/agent_tests.cpp @@ -0,0 +1,73 @@ +#include "foundry_agent/agent.hpp" +#include "foundry_agent/errors.hpp" +#include "fake_transport.hpp" + +#include +#include + +#include + +namespace { + +foundry_agent::Config TestConfig() +{ + return { + "https://example.services.ai.azure.com/api/projects/sample", + "gpt-5-mini"}; +} + +} // namespace + +TEST_CASE("Agent sends the expected Foundry request") +{ + auto credential = std::make_shared(); + auto transport = std::make_shared(); + transport->response = { + 200, + {}, + R"({"id":"resp-1","output":[{"type":"message","content":[{"type":"output_text","text":"Hello!"}]}]})"}; + + const foundry_agent::Agent agent{TestConfig(), credential, transport}; + const auto response = agent.Run("Hi"); + + REQUIRE(response.text == "Hello!"); + REQUIRE(response.responseId == "resp-1"); + REQUIRE(credential->requestedScope == "https://ai.azure.com/.default"); + REQUIRE( + transport->lastRequest.url == + "https://example.services.ai.azure.com/api/projects/sample/openai/v1/responses"); + REQUIRE(transport->lastRequest.headers.at("Authorization") == "Bearer test-token"); + + const auto body = nlohmann::json::parse(transport->lastRequest.body); + REQUIRE(body.at("model") == "gpt-5-mini"); + REQUIRE(body.at("input").at(0).at("content") == "Hi"); +} + +TEST_CASE("Agent surfaces service errors") +{ + auto credential = std::make_shared(); + auto transport = std::make_shared(); + transport->response = { + 429, + {}, + R"({"error":{"code":"TooManyRequests","message":"Slow down."}})"}; + + const foundry_agent::Agent agent{TestConfig(), credential, transport}; + try { + (void)agent.Run("Hi"); + FAIL("Expected a service error."); + } catch (const foundry_agent::ServiceError& error) { + REQUIRE(error.StatusCode() == 429); + REQUIRE(error.ServiceCode() == "TooManyRequests"); + } +} + +TEST_CASE("Agent rejects responses without output text") +{ + auto credential = std::make_shared(); + auto transport = std::make_shared(); + transport->response = {200, {}, R"({"id":"resp-1","output":[]})"}; + + const foundry_agent::Agent agent{TestConfig(), credential, transport}; + REQUIRE_THROWS_AS(agent.Run("Hi"), foundry_agent::ResponseError); +} diff --git a/05-Foundry-Agent-CPP/tests/config_tests.cpp b/05-Foundry-Agent-CPP/tests/config_tests.cpp new file mode 100644 index 0000000..0ea96bc --- /dev/null +++ b/05-Foundry-Agent-CPP/tests/config_tests.cpp @@ -0,0 +1,32 @@ +#include "foundry_agent/config.hpp" +#include "foundry_agent/errors.hpp" + +#include + +TEST_CASE("Config normalizes a trailing slash") +{ + const foundry_agent::Config config{ + "https://example.services.ai.azure.com/api/projects/sample/", + "gpt-5-mini"}; + REQUIRE( + config.ResponsesUrl() == + "https://example.services.ai.azure.com/api/projects/sample/openai/v1/responses"); +} + +TEST_CASE("Config rejects an insecure endpoint") +{ + REQUIRE_THROWS_AS( + foundry_agent::Config{ + "http://example.services.ai.azure.com/api/projects/sample", + "gpt-5-mini"}, + foundry_agent::ConfigError); +} + +TEST_CASE("Config requires a project path") +{ + REQUIRE_THROWS_AS( + foundry_agent::Config{ + "https://example.services.ai.azure.com", + "gpt-5-mini"}, + foundry_agent::ConfigError); +} diff --git a/05-Foundry-Agent-CPP/tests/fake_transport.hpp b/05-Foundry-Agent-CPP/tests/fake_transport.hpp new file mode 100644 index 0000000..6ebb967 --- /dev/null +++ b/05-Foundry-Agent-CPP/tests/fake_transport.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "foundry_agent/http_transport.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace foundry_agent::tests { + +class FakeCredential final : public Azure::Core::Credentials::TokenCredential { +public: + FakeCredential() : TokenCredential("FakeCredential") {} + + Azure::Core::Credentials::AccessToken GetToken( + const Azure::Core::Credentials::TokenRequestContext& request, + const Azure::Core::Context&) const override + { + requestedScope = request.Scopes.empty() ? "" : request.Scopes.front(); + return { + "test-token", + Azure::Core::DateTime{ + std::chrono::system_clock::now() + std::chrono::hours{1}} + }; + } + + mutable std::string requestedScope; +}; + +class FakeTransport final : public HttpTransport { +public: + HttpResponse Post(const HttpRequest& request) override + { + lastRequest = request; + return response; + } + + HttpRequest lastRequest; + HttpResponse response; +}; + +} // namespace foundry_agent::tests diff --git a/05-Foundry-Agent-CPP/vcpkg-configuration.json b/05-Foundry-Agent-CPP/vcpkg-configuration.json new file mode 100644 index 0000000..ee8ad02 --- /dev/null +++ b/05-Foundry-Agent-CPP/vcpkg-configuration.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg-configuration.schema.json", + "default-registry": { + "kind": "git", + "baseline": "ddd0023b0eee70986e42ed49d9d4afb8098f212e", + "repository": "https://github.com/microsoft/vcpkg" + } +} diff --git a/05-Foundry-Agent-CPP/vcpkg.json b/05-Foundry-Agent-CPP/vcpkg.json new file mode 100644 index 0000000..0cc344d --- /dev/null +++ b/05-Foundry-Agent-CPP/vcpkg.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "name": "maf-agent-cpp-05", + "version": "0.1.0", + "dependencies": [ + "azure-core-cpp", + "azure-identity-cpp", + "curl", + "nlohmann-json", + "catch2" + ] +} diff --git a/06-Foundry-Agent-CPP-Hosted/.agentignore b/06-Foundry-Agent-CPP-Hosted/.agentignore new file mode 100644 index 0000000..b8a755b --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/.agentignore @@ -0,0 +1,5 @@ +.azure/ +.git/ +.env +.env.* +build/ diff --git a/06-Foundry-Agent-CPP-Hosted/.env.example b/06-Foundry-Agent-CPP-Hosted/.env.example new file mode 100644 index 0000000..3d2a078 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/.env.example @@ -0,0 +1,3 @@ +FOUNDRY_PROJECT_ENDPOINT= +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5-mini +PORT=8088 diff --git a/06-Foundry-Agent-CPP-Hosted/CMakeLists.txt b/06-Foundry-Agent-CPP-Hosted/CMakeLists.txt new file mode 100644 index 0000000..7a79ebf --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/CMakeLists.txt @@ -0,0 +1,51 @@ +cmake_minimum_required(VERSION 3.25) + +project(maf_agent_cpp_06 VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(MAF_CPP05_BUILD_TESTS OFF CACHE BOOL "" FORCE) +add_subdirectory(../05-Foundry-Agent-CPP MAF-Agent-CPP-05) + +find_package(httplib CONFIG REQUIRED) + +add_library(foundry_host STATIC + src/host.cpp +) +target_include_directories(foundry_host + PUBLIC + $ +) +target_link_libraries(foundry_host + PUBLIC + FoundryAgent::foundry_agent + httplib::httplib +) +target_compile_features(foundry_host PUBLIC cxx_std_20) + +if(MSVC) + target_compile_options(foundry_host PRIVATE /W4 /permissive-) +else() + target_compile_options(foundry_host PRIVATE -Wall -Wextra -Wpedantic) +endif() + +add_executable(maf_agent_cpp_06 src/main.cpp) +target_link_libraries(maf_agent_cpp_06 PRIVATE foundry_host) + +option(MAF_CPP06_BUILD_TESTS "Build MAF-Agent-CPP-06 tests" ON) +if(MAF_CPP06_BUILD_TESTS) + include(CTest) + find_package(Catch2 3 CONFIG REQUIRED) + + add_executable(maf_agent_cpp_06_tests tests/host_tests.cpp) + target_link_libraries(maf_agent_cpp_06_tests + PRIVATE + foundry_host + Catch2::Catch2WithMain + ) + + include(Catch) + catch_discover_tests(maf_agent_cpp_06_tests) +endif() diff --git a/06-Foundry-Agent-CPP-Hosted/CMakePresets.json b/06-Foundry-Agent-CPP-Hosted/CMakePresets.json new file mode 100644 index 0000000..51cd10a --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/CMakePresets.json @@ -0,0 +1,55 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 25, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "debug", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "MAF_CPP06_BUILD_TESTS": "ON" + } + }, + { + "name": "release", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "MAF_CPP06_BUILD_TESTS": "OFF" + } + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + } + ], + "testPresets": [ + { + "name": "debug", + "configurePreset": "debug", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/06-Foundry-Agent-CPP-Hosted/Dockerfile b/06-Foundry-Agent-CPP-Hosted/Dockerfile new file mode 100644 index 0000000..7274b90 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 + +FROM ubuntu:24.04 AS build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + libssl-dev \ + ninja-build \ + pkg-config \ + tar \ + unzip \ + zip \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/microsoft/vcpkg /opt/vcpkg \ + && /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics +ENV VCPKG_ROOT=/opt/vcpkg + +WORKDIR /src +COPY 05-Foundry-Agent-CPP/ 05-Foundry-Agent-CPP/ +COPY 06-Foundry-Agent-CPP-Hosted/ 06-Foundry-Agent-CPP-Hosted/ + +WORKDIR /src/06-Foundry-Agent-CPP-Hosted +RUN cmake -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake \ + -DMAF_CPP06_BUILD_TESTS=OFF \ + -B build/release \ + && cmake --build build/release --target maf_agent_cpp_06 + +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libcurl4t64 \ + libgcc-s1 \ + libssl3t64 \ + libstdc++6 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 agent + +COPY --from=build /src/06-Foundry-Agent-CPP-Hosted/build/release/maf_agent_cpp_06 /app/agent + +USER agent +WORKDIR /app +EXPOSE 8088 + +ENTRYPOINT ["/app/agent"] diff --git a/06-Foundry-Agent-CPP-Hosted/README.md b/06-Foundry-Agent-CPP-Hosted/README.md new file mode 100644 index 0000000..c96c701 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/README.md @@ -0,0 +1,86 @@ +# 06-Foundry-Agent-CPP-Hosted + +This sample reuses the client from [`05-Foundry-Agent-CPP`](../05-Foundry-Agent-CPP/README.md) and hosts it in a C++20 Linux container for Microsoft Foundry Hosted Agents. + +The server binds to `0.0.0.0`, defaults to port `8088`, and implements: + +- `GET /readiness` +- `POST /invocations` using Foundry Invocations protocol `2.0.0` +- raw UTF-8 and JSON-string prompts +- a 1 MiB request limit +- stateless requests and lazy agent initialization +- `501 Not Implemented` for AG-UI objects until streaming is implemented + +See the [C++ research report](../docs/research/cpp-agents-with-microsoft-foundry.md) for the rationale behind Invocations and the current C++ capability gaps. + +## Prerequisites + +- The local prerequisites from [`05-Foundry-Agent-CPP`](../05-Foundry-Agent-CPP/README.md) +- Docker for local container builds +- Azure Developer CLI (`azd`) and the Foundry agent extensions for deployment + +## Build and test locally + +From this directory: + +```powershell +cmake --preset debug +cmake --build --preset debug +ctest --preset debug +``` + +Run the server: + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-5-mini" +.\build\debug\maf_agent_cpp_06.exe +``` + +On Linux: + +```bash +./build/debug/maf_agent_cpp_06 +``` + +Check readiness and invoke it: + +```powershell +Invoke-RestMethod http://localhost:8088/readiness +Invoke-RestMethod http://localhost:8088/invocations ` + -Method Post ` + -ContentType "text/plain" ` + -Body "Hello!" +``` + +## Build the Linux AMD64 container + +The Docker build context must be the repository root because the hosted sample consumes the sibling local sample: + +```powershell +docker build --platform linux/amd64 ` + -f .\06-Foundry-Agent-CPP-Hosted\Dockerfile ` + -t maf-agent-cpp-06 . +``` + +Run it with your current Azure credential environment or workload identity configuration: + +```powershell +docker run --rm -p 8088:8088 ` + -e FOUNDRY_PROJECT_ENDPOINT ` + -e AZURE_AI_MODEL_DEPLOYMENT_NAME ` + maf-agent-cpp-06 +``` + +## Deploy + +Replace the Foundry Project endpoint placeholder in [`azure.yaml`](azure.yaml), then follow the repository's established `azd` Hosted Agent workflow. The manifest uses a repository-root Docker context, remote build, a non-root runtime user, and Invocations `2.0.0`. + +Deployment and managed-identity behavior must be validated in your own Foundry environment; CI does not perform live Azure operations. + +## Initial limitations + +- Plain text responses only; AG-UI streaming is not implemented +- No tools or persistent conversation state +- Repository-owned Foundry client and hosting adapter +- Linux AMD64 container target diff --git a/06-Foundry-Agent-CPP-Hosted/azure.yaml b/06-Foundry-Agent-CPP-Hosted/azure.yaml new file mode 100644 index 0000000..63bc9c7 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/azure.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: maf-agent-cpp-06 +services: + foundry-project: + host: azure.ai.project + endpoint: + maf-agent-cpp-06: + project: . + host: azure.ai.agent + language: docker + docker: + path: ./Dockerfile + context: .. + remoteBuild: true + uses: + - foundry-project + env: + FOUNDRY_PROJECT_ENDPOINT: ${FOUNDRY_PROJECT_ENDPOINT} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + container: + resources: + cpu: "0.5" + memory: 1Gi + kind: hosted + name: maf-agent-cpp-06 + protocols: + - protocol: invocations + version: 2.0.0 +infra: + provider: microsoft.foundry diff --git a/06-Foundry-Agent-CPP-Hosted/include/foundry_host/host.hpp b/06-Foundry-Agent-CPP-Hosted/include/foundry_host/host.hpp new file mode 100644 index 0000000..d5998f4 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/include/foundry_host/host.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace foundry_host { + +constexpr int DefaultPort = 8088; +constexpr std::size_t MaximumRequestSize = 1024 * 1024; + +struct InvocationInput final { + std::string prompt; + bool isAgUi{}; +}; + +InvocationInput ParseInvocation(std::string_view body); +int ResolvePort(const char* value); + +class AgentRunner { +public: + virtual ~AgentRunner() = default; + virtual std::string Run(const std::string& prompt) = 0; +}; + +using AgentFactory = std::function()>; + +class Host final { +public: + explicit Host(AgentFactory agentFactory); + + void Configure(httplib::Server& server); + void HandleReadiness(const httplib::Request& request, httplib::Response& response) const; + void HandleInvocation(const httplib::Request& request, httplib::Response& response); + +private: + std::shared_ptr GetAgent(); + + AgentFactory agentFactory_; + std::mutex agentMutex_; + std::shared_ptr agent_; +}; + +std::shared_ptr CreateDefaultAgentRunner(); + +} // namespace foundry_host diff --git a/06-Foundry-Agent-CPP-Hosted/src/host.cpp b/06-Foundry-Agent-CPP-Hosted/src/host.cpp new file mode 100644 index 0000000..15aa7c9 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/src/host.cpp @@ -0,0 +1,203 @@ +#include "foundry_host/host.hpp" + +#include "foundry_agent/agent.hpp" +#include "foundry_agent/config.hpp" +#include "foundry_agent/errors.hpp" + +#include + +#include +#include +#include +#include + +namespace foundry_host { +namespace { + +std::string_view Trim(std::string_view value) +{ + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) { + return {}; + } + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +class DefaultAgentRunner final : public AgentRunner { +public: + DefaultAgentRunner() + : agent_(foundry_agent::Agent::CreateDefault( + foundry_agent::Config::FromEnvironment())) {} + + std::string Run(const std::string& prompt) override + { + return agent_.Run(prompt).text; + } + +private: + foundry_agent::Agent agent_; +}; + +void SetText(httplib::Response& response, int status, std::string content) +{ + response.status = status; + response.set_content(std::move(content), "text/plain; charset=utf-8"); +} + +} // namespace + +InvocationInput ParseInvocation(std::string_view body) +{ + const auto trimmed = Trim(body); + if (trimmed.empty()) { + throw std::invalid_argument{"Request body must not be empty."}; + } + + if (trimmed.front() == '"' || trimmed.front() == '{' || trimmed.front() == '[') { + nlohmann::json document; + try { + document = nlohmann::json::parse(trimmed); + } catch (const nlohmann::json::exception&) { + throw std::invalid_argument{"Request body contains malformed JSON."}; + } + + if (document.is_string()) { + const auto prompt = Trim(document.get()); + if (prompt.empty()) { + throw std::invalid_argument{"Prompt must not be empty."}; + } + return {std::string{prompt}, false}; + } + + if (document.is_object() && + document.contains("messages") && + document["messages"].is_array() && + !document["messages"].empty()) { + return {{}, true}; + } + + throw std::invalid_argument{ + "JSON invocations must be a string or an AG-UI object containing messages."}; + } + + return {std::string{trimmed}, false}; +} + +int ResolvePort(const char* value) +{ + if (value == nullptr || *value == '\0') { + return DefaultPort; + } + + int port = 0; + const std::string_view text{value}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); + if (error != std::errc{} || end != text.data() + text.size() || + port < 1 || port > 65535) { + throw std::invalid_argument{"PORT must be an integer between 1 and 65535."}; + } + return port; +} + +Host::Host(AgentFactory agentFactory) + : agentFactory_(std::move(agentFactory)) +{ + if (!agentFactory_) { + throw std::invalid_argument{"An agent factory is required."}; + } +} + +void Host::Configure(httplib::Server& server) +{ + server.set_payload_max_length(MaximumRequestSize); + server.set_read_timeout(5, 0); + server.set_write_timeout(30, 0); + server.Get("/readiness", [this](const auto& request, auto& response) { + HandleReadiness(request, response); + }); + server.Post("/invocations", [this](const auto& request, auto& response) { + HandleInvocation(request, response); + }); +} + +void Host::HandleReadiness( + const httplib::Request& request, + httplib::Response& response) const +{ + if (request.method != "GET") { + response.set_header("Allow", "GET"); + SetText(response, 405, "Method Not Allowed"); + return; + } + response.set_header("Cache-Control", "no-store"); + response.status = 200; + response.set_content(R"({"status":"ready"})", "application/json"); +} + +void Host::HandleInvocation( + const httplib::Request& request, + httplib::Response& response) +{ + if (request.method != "POST") { + response.set_header("Allow", "POST"); + SetText(response, 405, "Method Not Allowed"); + return; + } + if (request.body.size() > MaximumRequestSize) { + SetText(response, 413, "Request body exceeds the 1 MiB limit."); + return; + } + + InvocationInput invocation; + try { + invocation = ParseInvocation(request.body); + } catch (const std::invalid_argument& error) { + SetText(response, 400, error.what()); + return; + } + + if (invocation.isAgUi) { + SetText( + response, + 501, + "AG-UI streaming is not implemented. Send plain text or a JSON string."); + return; + } + + try { + auto agent = GetAgent(); + response.set_header("Cache-Control", "no-store"); + SetText(response, 200, agent->Run(invocation.prompt)); + } catch (const foundry_agent::AuthenticationError& error) { + SetText(response, 503, std::string{"Authentication failed: "} + error.what()); + } catch (const foundry_agent::ServiceError& error) { + SetText( + response, + error.StatusCode() == 429 ? 503 : 502, + std::string{"Foundry request failed: "} + error.what()); + } catch (const foundry_agent::AgentError& error) { + SetText(response, 502, std::string{"Foundry request failed: "} + error.what()); + } catch (const std::exception& error) { + SetText(response, 503, std::string{"Agent initialization failed: "} + error.what()); + } +} + +std::shared_ptr Host::GetAgent() +{ + std::lock_guard lock{agentMutex_}; + if (!agent_) { + agent_ = agentFactory_(); + if (!agent_) { + throw std::runtime_error{"Agent factory returned no agent."}; + } + } + return agent_; +} + +std::shared_ptr CreateDefaultAgentRunner() +{ + return std::make_shared(); +} + +} // namespace foundry_host diff --git a/06-Foundry-Agent-CPP-Hosted/src/main.cpp b/06-Foundry-Agent-CPP-Hosted/src/main.cpp new file mode 100644 index 0000000..753ff33 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/src/main.cpp @@ -0,0 +1,59 @@ +#include "foundry_host/host.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +std::atomic_bool stopRequested{false}; + +extern "C" void HandleSignal(int) +{ + stopRequested.store(true, std::memory_order_relaxed); +} + +} // namespace + +int main() +{ + try { + const int port = foundry_host::ResolvePort(std::getenv("PORT")); + foundry_host::Host host{foundry_host::CreateDefaultAgentRunner}; + httplib::Server server; + host.Configure(server); + + std::signal(SIGINT, HandleSignal); + std::signal(SIGTERM, HandleSignal); + + if (!server.bind_to_port("0.0.0.0", port)) { + std::cerr << "Failed to bind to port " << port << ".\n"; + return EXIT_FAILURE; + } + + std::jthread shutdownMonitor{[&server](std::stop_token token) { + while (!token.stop_requested() && + !stopRequested.load(std::memory_order_relaxed)) { + std::this_thread::sleep_for(std::chrono::milliseconds{100}); + } + if (stopRequested.load(std::memory_order_relaxed)) { + server.stop(); + } + }}; + + std::cout << "Listening on 0.0.0.0:" << port << '\n'; + if (!server.listen_after_bind()) { + std::cerr << "Server stopped unexpectedly.\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; + } catch (const std::exception& error) { + std::cerr << "Hosted agent failed: " << error.what() << '\n'; + return EXIT_FAILURE; + } +} diff --git a/06-Foundry-Agent-CPP-Hosted/tests/host_tests.cpp b/06-Foundry-Agent-CPP-Hosted/tests/host_tests.cpp new file mode 100644 index 0000000..5e5a0de --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/tests/host_tests.cpp @@ -0,0 +1,111 @@ +#include "foundry_host/host.hpp" + +#include + +#include +#include +#include + +namespace { + +class FakeAgent final : public foundry_host::AgentRunner { +public: + std::string Run(const std::string& prompt) override + { + lastPrompt = prompt; + return response; + } + + std::string lastPrompt; + std::string response{"assistant response"}; +}; + +} // namespace + +TEST_CASE("Invocation parser accepts plain text and JSON strings") +{ + REQUIRE(foundry_host::ParseInvocation(" hello ").prompt == "hello"); + REQUIRE(foundry_host::ParseInvocation(R"("hello")").prompt == "hello"); +} + +TEST_CASE("Invocation parser recognizes AG-UI and rejects unsupported JSON") +{ + REQUIRE(foundry_host::ParseInvocation( + R"({"messages":[{"role":"user","content":"hello"}]})").isAgUi); + REQUIRE_THROWS_AS(foundry_host::ParseInvocation(R"({"messages":[]})"), + std::invalid_argument); + REQUIRE_THROWS_AS(foundry_host::ParseInvocation(R"({"prompt":"hello"})"), + std::invalid_argument); + REQUIRE_THROWS_AS(foundry_host::ParseInvocation("[]"), std::invalid_argument); + REQUIRE_THROWS_AS(foundry_host::ParseInvocation(" "), std::invalid_argument); +} + +TEST_CASE("Port validation honors defaults and valid values") +{ + REQUIRE(foundry_host::ResolvePort(nullptr) == 8088); + REQUIRE(foundry_host::ResolvePort("") == 8088); + REQUIRE(foundry_host::ResolvePort("9090") == 9090); + REQUIRE_THROWS_AS(foundry_host::ResolvePort("0"), std::invalid_argument); + REQUIRE_THROWS_AS(foundry_host::ResolvePort("invalid"), std::invalid_argument); +} + +TEST_CASE("Readiness returns the hosted health contract") +{ + foundry_host::Host host{[] { return std::make_shared(); }}; + httplib::Request request; + request.method = "GET"; + httplib::Response response; + + host.HandleReadiness(request, response); + + REQUIRE(response.status == 200); + REQUIRE(response.body == R"({"status":"ready"})"); + REQUIRE(response.get_header_value("Content-Type") == "application/json"); +} + +TEST_CASE("Invocation runs through a lazily initialized stateless agent") +{ + auto agent = std::make_shared(); + int factoryCalls = 0; + foundry_host::Host host{[&] { + ++factoryCalls; + return agent; + }}; + httplib::Request request; + request.method = "POST"; + request.body = R"("hello")"; + httplib::Response response; + + host.HandleInvocation(request, response); + REQUIRE(response.status == 200); + REQUIRE(response.body == "assistant response"); + REQUIRE(agent->lastPrompt == "hello"); + + httplib::Response secondResponse; + host.HandleInvocation(request, secondResponse); + REQUIRE(factoryCalls == 1); +} + +TEST_CASE("AG-UI objects return not implemented") +{ + foundry_host::Host host{[] { return std::make_shared(); }}; + httplib::Request request; + request.method = "POST"; + request.body = R"({"messages":[{"role":"user","content":"hello"}]})"; + httplib::Response response; + + host.HandleInvocation(request, response); + REQUIRE(response.status == 501); +} + +TEST_CASE("Oversized requests return payload too large") +{ + foundry_host::Host host{[] { return std::make_shared(); }}; + httplib::Request request; + request.method = "POST"; + request.body.assign(foundry_host::MaximumRequestSize + 1, 'x'); + httplib::Response response; + + host.HandleInvocation(request, response); + REQUIRE(response.status == 413); +} diff --git a/06-Foundry-Agent-CPP-Hosted/vcpkg-configuration.json b/06-Foundry-Agent-CPP-Hosted/vcpkg-configuration.json new file mode 100644 index 0000000..ee8ad02 --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/vcpkg-configuration.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg-configuration.schema.json", + "default-registry": { + "kind": "git", + "baseline": "ddd0023b0eee70986e42ed49d9d4afb8098f212e", + "repository": "https://github.com/microsoft/vcpkg" + } +} diff --git a/06-Foundry-Agent-CPP-Hosted/vcpkg.json b/06-Foundry-Agent-CPP-Hosted/vcpkg.json new file mode 100644 index 0000000..cb5775b --- /dev/null +++ b/06-Foundry-Agent-CPP-Hosted/vcpkg.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "name": "maf-agent-cpp-06", + "version": "0.1.0", + "dependencies": [ + "azure-core-cpp", + "azure-identity-cpp", + "catch2", + "cpp-httplib", + "curl", + "nlohmann-json" + ] +} diff --git a/MAF-Agents-Samples.slnx b/MAF-Agents-Samples.slnx index db4f2e5..d7231de 100644 --- a/MAF-Agents-Samples.slnx +++ b/MAF-Agents-Samples.slnx @@ -1,4 +1,4 @@ - - + + diff --git a/README.md b/README.md index 36a0d43..4473d4c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # MAF Agent Samples -This repository contains a small set of sample Microsoft Agent Framework (MAF) applications that use Microsoft Foundry and Azure AI Projects. Each sample creates a simple "friendly assistant" agent and either runs it once from the console or hosts it as a long-running service. +This repository contains a small set of sample Microsoft Agent Framework (MAF) applications that use Microsoft Foundry and Foundry Projects. Each sample creates a simple "friendly assistant" agent and either runs it once from the console or hosts it as a long-running service. ## Projects | Project | Language | Type | Description | |---|---|---|---| -| `MAF-Agent-CS-01` | C# | Console app | Creates an AI agent from a Microsoft Foundry project and runs a single sample prompt, then exits. | -| `MAF-Agent-CS-02` | C# | Hosted agent | Runs as a long-lived web service that registers a Foundry **Responses** endpoint with `AgentHost`, so Foundry can call it like any other hosted agent. | -| `MAF-Agent-GO-03` | Go | Console app | Creates and runs a Microsoft Agent Framework agent backed by a Foundry project, prints one response, then exits. | -| `MAF-Agent-GO-04` | Go | Hosted agent | A containerized service that exposes Foundry's **Invocations** protocol (including the AG-UI contract) so it can be deployed and called as a Foundry hosted agent. | +| `01-MAF-Agent-CS` | C# | Console app | Creates an AI agent from a Microsoft Foundry project and runs a single sample prompt, then exits. | +| `02-MAF-Agent-CS-Hosted` | C# | Hosted agent | Runs as a long-lived web service that registers a Foundry **Responses** endpoint with `AgentHost`, so Foundry can call it like any other hosted agent. | +| `03-MAF-Agent-GO` | Go | Console app | Creates and runs a Microsoft Agent Framework agent backed by a Foundry project, prints one response, then exits. | +| `04-MAF-Agent-GO-Hosted` | Go | Hosted agent | A containerized service that exposes Foundry's **Invocations** protocol (including the AG-UI contract) so it can be deployed and called as a Foundry hosted agent. | | `MAF-Agents-Samples.slnx` | — | — | Solution file for the two C# projects. | +| `05-Foundry-Agent-CPP` | C++ | Console app | Calls a Foundry Project from C++20 through Microsoft Entra authentication and the project-scoped Responses REST API. | +| `06-Foundry-Agent-CPP-Hosted` | C++ | Hosted agent | Reuses the C++ client in a Linux container that exposes Foundry's **Invocations** protocol through cpp-httplib. | **Console app vs. hosted agent, in plain terms:** - A **console app** is a simple, one-shot program you run locally with `dotnet run` or `go run`. It calls Foundry once, prints the answer, and exits. Use these first to confirm your Foundry project and model deployment work. @@ -28,14 +30,18 @@ Once you have those two values — the **project endpoint** and the **model depl Tooling prerequisites: -- .NET 10 SDK (for `MAF-Agent-CS-01` and `MAF-Agent-CS-02`) -- Go 1.26 SDK (for `MAF-Agent-GO-03` and `MAF-Agent-GO-04`) +- .NET 10 SDK (for `01-MAF-Agent-CS` and `02-MAF-Agent-CS-Hosted`) +- Go 1.26 SDK (for `03-MAF-Agent-GO` and `04-MAF-Agent-GO-Hosted`) +- CMake 3.25 or later, Ninja, a C++20 compiler, and vcpkg (for `05-Foundry-Agent-CPP` and `06-Foundry-Agent-CPP-Hosted`) - Azure CLI, signed in (`az login`) -- Docker (or another OCI-compatible builder) and Azure Developer CLI (`azd`), only if you plan to deploy `MAF-Agent-GO-04` as a container — see its own README +- Docker (or another OCI-compatible builder) and Azure Developer CLI (`azd`), only if you plan to deploy `04-MAF-Agent-GO-Hosted` as a container — see its own README +- Docker and `azd` are also required to deploy `06-Foundry-Agent-CPP-Hosted` - An Azure account with permission to create or use a Microsoft Foundry project > **Note on preview packages:** The C# samples reference preview/beta NuGet packages (`Azure.AI.Projects`, `Microsoft.Agents.AI.Foundry`, `Microsoft.Agents.AI.Foundry.Hosting`). These SDKs are under active development and their APIs may change between versions. If a sample fails to build after `dotnet restore`, check whether a newer preview package version changed an API used in `Program.cs`. +> **C++ support boundary:** Microsoft Foundry and Microsoft Agent Framework do not currently provide a first-party C++ agent SDK or hosting adapter. The C++ samples use first-party `azure-identity-cpp` for authentication and repository-owned REST and hosting adapters. See [C++ Agents with Microsoft Foundry](docs/research/cpp-agents-with-microsoft-foundry.md) for the full research and trade-off analysis. + ## Environment variables All samples use the same two environment variable names: @@ -56,47 +62,76 @@ $env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-5-mini" dotnet build .\MAF-Agents-Samples.slnx ``` +Build the C++ samples: + +```powershell +Set-Location .\05-Foundry-Agent-CPP +cmake --preset debug +cmake --build --preset debug +Set-Location ..\06-Foundry-Agent-CPP-Hosted +cmake --preset debug +cmake --build --preset debug +``` + ## Run Run the C# console sample: ```powershell -dotnet run --project .\MAF-Agent-CS-01\MAF-Agent-CS-01.csproj +dotnet run --project .\01-MAF-Agent-CS\01-MAF-Agent-CS.csproj ``` Run the C# hosted agent sample: ```powershell -dotnet run --project .\MAF-Agent-CS-02\MAF-Agent-CS-02.csproj +dotnet run --project .\02-MAF-Agent-CS-Hosted\02-MAF-Agent-CS-Hosted.csproj ``` -Run the Go console sample — see [`MAF-Agent-GO-03/README.md`](MAF-Agent-GO-03/README.md) for full details: +Run the Go console sample — see [`03-MAF-Agent-GO/README.md`](03-MAF-Agent-GO/README.md) for full details: ```powershell -go run .\MAF-Agent-GO-03 +go run .\03-MAF-Agent-GO ``` -Run the Go hosted agent sample — see [`MAF-Agent-GO-04/README.md`](MAF-Agent-GO-04/README.md) for local invocation and Foundry deployment instructions: +Run the Go hosted agent sample — see [`04-MAF-Agent-GO-Hosted/README.md`](04-MAF-Agent-GO-Hosted/README.md) for local invocation and Foundry deployment instructions: ```powershell -Set-Location .\MAF-Agent-GO-04 +Set-Location .\04-MAF-Agent-GO-Hosted go run . ``` +Run the C++ samples after building: + +```powershell +.\05-Foundry-Agent-CPP\build\debug\maf_agent_cpp_05.exe +.\06-Foundry-Agent-CPP-Hosted\build\debug\maf_agent_cpp_06.exe +``` + +See [`05-Foundry-Agent-CPP/README.md`](05-Foundry-Agent-CPP/README.md) and [`06-Foundry-Agent-CPP-Hosted/README.md`](06-Foundry-Agent-CPP-Hosted/README.md) for configuration, Linux commands, local invocation, and deployment. + ## Test -`MAF-Agent-GO-04` includes unit tests for its HTTP handlers. Run them with: +`04-MAF-Agent-GO-Hosted` includes unit tests for its HTTP handlers. Run them with: ```powershell -Set-Location .\MAF-Agent-GO-04 +Set-Location .\04-MAF-Agent-GO-Hosted go test ./... ``` -The other three samples do not currently have automated tests; they are intended as minimal, readable starting points. +The C++ samples use Catch2 tests that do not require Azure credentials: + +```powershell +Set-Location .\05-Foundry-Agent-CPP +ctest --preset debug +Set-Location ..\06-Foundry-Agent-CPP-Hosted +ctest --preset debug +``` + +The two C# samples do not currently have automated tests; they are intended as minimal, readable starting points. ## Continuous integration -A minimal GitHub Actions workflow (`.github/workflows/build.yml`) builds the .NET solution and builds/tests both Go modules on every push and pull request to `main`. It does not require Foundry credentials since it only validates that the code compiles and unit tests pass. +A minimal GitHub Actions workflow (`.github/workflows/build.yml`) builds the .NET solution and builds/tests the Go and C++ samples on every push and pull request to `main`. It does not require Foundry credentials since it only validates that the code compiles and unit tests pass. ## Resources @@ -106,6 +141,9 @@ A minimal GitHub Actions workflow (`.github/workflows/build.yml`) builds the .NE - [Microsoft Agent Framework GitHub repository](https://github.com/microsoft/agent-framework) - [Deploy and host agents in Microsoft Foundry](https://learn.microsoft.com/azure/ai-foundry/agents/how-to/hosted-agents-overview) - [Azure Developer CLI (`azd`) documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview) +- [C++ Agents with Microsoft Foundry: Current Options, Gaps, and Recommended Architecture](docs/research/cpp-agents-with-microsoft-foundry.md) +- [Azure SDK for C++](https://github.com/Azure/azure-sdk-for-cpp) +- [vcpkg C++ package manager](https://vcpkg.io) ## Notes diff --git a/docs/research/cpp-agents-with-microsoft-foundry.md b/docs/research/cpp-agents-with-microsoft-foundry.md new file mode 100644 index 0000000..0a398a6 --- /dev/null +++ b/docs/research/cpp-agents-with-microsoft-foundry.md @@ -0,0 +1,668 @@ +> **Implementation status** +> +> Baseline C++20 samples now exist at **MAF-Agent-CPP-05** (local console agent) and **MAF-Agent-CPP-06** (Foundry-hosted custom container). Both use `azure-identity-cpp` for Microsoft Entra authentication and a repository-owned libcurl Responses client. The hosted sample uses cpp-httplib and declares Foundry Invocations protocol `2.0.0`. The source and offline tests are implemented; local model calls, container execution, Foundry deployment, and managed-identity behavior still require validation in a configured environment. + +# C++ Agents with Microsoft Foundry: Current Options, Gaps, and Recommended Architecture + +**Research date:** August 27, 2026 +**Repository baseline:** `Azure-Samples/microsoft-foundry-hosted-agents` at commit `47d3f1d13905a3f087d15fee275f8153fa754420` +**Query type:** Technical deep dive + +## Executive Summary + +A C++ developer can build both scenarios represented by this repository today: a local executable that calls a model deployment in a Microsoft Foundry Project, and a Linux container that exposes the Foundry Hosted Agent runtime contract. The important qualification is that C++ is not a first-class language in the current Microsoft Foundry agent SDK or Microsoft Agent Framework: there is no C++ equivalent of `Azure.AI.Projects`, `Microsoft.Agents.AI.Foundry`, or `agent-framework-go`.[^1][^2] + +The most direct pure-C++ path is therefore standards based: use `azure-identity-cpp` for Microsoft Entra authentication, call the project-scoped OpenAI Responses endpoint over HTTPS, and expose `/invocations` plus `/readiness` from a Linux AMD64 container.[^3][^4][^5] This is viable for an educational sample and for controlled workloads, but the application must own request models, token attachment, streaming, tool-call orchestration, session state, protocol behavior, retries, and telemetry. + +For the repository's next pair of samples, the recommended baseline is a shared C++ agent core with two thin hosts: + +1. a console entry point equivalent to `MAF-Agent-CS-01` and `MAF-Agent-GO-03`; and +2. a custom-container HTTP entry point equivalent to `MAF-Agent-GO-04`, using Foundry Invocations protocol `2.0.0`. + +For production systems that need complete Responses semantics, platform-managed conversations, rich tool calling, or the lowest support risk, a supported .NET, Python, or Go adapter should remain the orchestration boundary while C++ is integrated through a local bridge, MCP tool, A2A service, or native library. + +## Scope and Assumptions + +- "Run locally" means a C++ process running on the developer machine while calling cloud resources in a Microsoft Foundry Project. It does **not** mean running an on-device model with Foundry Local. +- "Hosted Agent" means a custom container deployed to Foundry Agent Service with `host: azure.ai.agent`, `kind: hosted`, and a declared protocol. +- The target behavior is the simple friendly assistant in the repository, with streaming and tools treated as progressive enhancements rather than minimum requirements. +- At the research baseline, the local `cpp-hosted-agent` branch was identical to `main` and had no C++ implementation. + +## 1. What the Existing C# and Go Samples Establish + +The repository contains four reference scenarios: + +| Sample | Execution model | Agent client | Authentication | Hosting protocol | +|---|---|---|---|---| +| `MAF-Agent-CS-01` | Local console | `AIProjectClient.AsAIAgent()` | `AzureCliCredential` | None | +| `MAF-Agent-CS-02` | Foundry-hosted source deployment | Same Foundry agent abstraction | `DefaultAzureCredential` | Responses `2.0.0` | +| `MAF-Agent-GO-03` | Local console | `foundryprovider.NewAgent()` | `DefaultAzureCredential` | None | +| `MAF-Agent-GO-04` | Foundry-hosted custom container | Same Go agent abstraction | `DefaultAzureCredential` | Invocations `2.0.0`, with optional AG-UI SSE | + +The local C# sample reads `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME`, creates an agent with fixed instructions, runs one prompt, prints the answer, and exits.[^6] The Go local sample follows the same lifecycle through `foundryprovider.NewAgent()` and `RunText(...).Collect()`.[^7] + +The hosted C# sample reuses the same agent construction but adds the official .NET Foundry hosting adapter. That adapter registers the Responses protocol and owns the web-host behavior.[^8] The hosted Go sample cannot use an equivalent official Responses adapter, so it implements a custom HTTP server, exposes `/invocations` and `/readiness`, and declares `invocations` version `2.0.0` in `azure.yaml`.[^9][^10] + +This gives the intended C++ design a useful rule: + +> The model-facing agent logic should be reusable. Local and hosted modes should differ primarily in their entry point and transport. + +## 2. Current C++ Support Landscape + +### 2.1 Microsoft Foundry and Agent Framework + +Current Foundry quickstarts and SDK guidance expose supported paths for Python, C#, JavaScript/TypeScript, Java, REST, `azd`, VS Code, and declarative experiences, depending on the specific workflow. C++ is not offered as a Foundry SDK language or hosted-agent quickstart language.[^1][^11] + +Microsoft Agent Framework currently provides .NET and Python implementations and links to a separate Go implementation. Its repository has no C++ implementation, package, or hosting adapter.[^2] + +Consequences for C++: + +- no `AIProjectClient` equivalent; +- no typed Foundry agent client; +- no `AIAgent` abstraction; +- no built-in agent session or tool loop; +- no official Responses or Invocations server adapter; +- no first-party C++ package that emits the platform's expected OpenTelemetry spans automatically. + +### 2.2 Azure SDK for C++ + +The official Azure SDK for C++ is still useful, but only at the infrastructure layer. Its current `sdk/` tree includes core, identity, storage, Key Vault, Event Hubs, Tables, App Configuration, and Attestation packages; it does not include AI Projects, Foundry, Azure OpenAI, or Agent Framework clients.[^3] + +`azure-identity-cpp` does provide the credential types needed by this design, including `DefaultAzureCredential`, `AzureCliCredential`, `ManagedIdentityCredential`, `WorkloadIdentityCredential`, and service-principal credentials.[^12] It can request a token for the Foundry data-plane scope, after which the C++ application attaches the bearer token to its HTTPS requests. + +### 2.3 Community and Protocol-Level Building Blocks + +| Need | Credible C++ option | Status and caveat | +|---|---|---| +| HTTP client | libcurl, CPR, or `azure-core-cpp` transport primitives | Mature, but Foundry request types remain application-owned | +| HTTP server | cpp-httplib, Drogon, or Boost.Beast | Mature; the application owns Hosted Agent routes and lifecycle | +| JSON | nlohmann/json | Mature and widely used | +| AG-UI | `ag-ui-protocol/ag-ui/sdks/community/c++` | Complete community-tier C++17 source, but no first-party SLA or published package[^13] | +| MCP | Community C++ implementations or direct JSON-RPC/HTTP | No official Microsoft C++ SDK; treat as community integration | +| A2A | Direct HTTP/JSON-RPC/SSE or community code | A2A is preview in Foundry and has no official C++ SDK[^14] | +| Telemetry | OpenTelemetry C++ | Traces, metrics, and logs are stable, but Foundry-specific conventions must be wired manually[^15] | +| High-level agent orchestration | Community projects | No community option currently offers parity and supportability comparable to Microsoft Agent Framework | + +Community OpenAI C++ clients can reduce basic HTTP and JSON boilerplate, but most assume OpenAI API keys and standard OpenAI base URLs. They do not remove the need to integrate `DefaultAzureCredential`, preserve the Foundry Project path, or implement the Hosted Agent server contract. + +### 2.4 Foundry Local Is a Different Product Path + +The `microsoft/foundry-local` repository contains C++ source: + +- a build-from-source, Windows-only C++17 SDK under `sdk/cpp/`; and +- a larger cross-platform rewrite under `sdk_v2/cpp/` that is still under development and not released as a supported package.[^16] + +However, the public Microsoft Learn SDK reference lists C#, JavaScript, Python, and Rust, not C++.[^17] More importantly, Foundry Local runs models on the developer device. It does not reproduce the repository's requirement to use a cloud Foundry Project, and the currently usable C++ path cannot serve as the Linux AMD64 Hosted Agent container. + +Foundry Local is therefore relevant to a broader discussion of C++ AI development, but it is not the implementation foundation for these two parity scenarios. + +## 3. The Exact Cloud Call a C++ Agent Must Make + +The current Go and .NET implementations reveal the exact model-deployment path used by their high-level Foundry adapters. Given: + +```text +FOUNDRY_PROJECT_ENDPOINT= +https://.services.ai.azure.com/api/projects/ +``` + +the model-deployment agent calls: + +```http +POST https://.services.ai.azure.com/api/projects//openai/v1/responses +Authorization: Bearer +Content-Type: application/json +``` + +The project path is preserved; the clients append `/openai/v1/`, and the Responses client appends `/responses`.[^18] The Go provider and .NET `ProjectOpenAIClient` both use the Foundry scope `https://ai.azure.com/.default` for this `*.services.ai.azure.com/api/projects/...` surface.[^18] + +A minimal request equivalent to the repository's simple agent is: + +```json +{ + "model": "gpt-5-mini", + "instructions": "You are a friendly assistant. Keep your answers brief.", + "input": [ + { + "role": "user", + "content": "Hello! Tell me a fun fact about C++." + } + ] +} +``` + +If the C++ application calls an already-deployed named server agent instead of a model deployment, the path changes to: + +```text +/agents//endpoint/protocols/openai/responses?api-version=v1 +``` + +That is a different operating mode and should not be used for the initial C++ parity sample.[^18] + +### What the Missing SDK Would Normally Do + +A production-quality C++ wrapper needs to own at least: + +1. project endpoint validation and URL construction; +2. acquisition and caching of the `https://ai.azure.com/.default` token; +3. bearer-header attachment and token refresh; +4. JSON serialization and response parsing; +5. timeout, cancellation, retry, and `Retry-After` handling; +6. correlation/request IDs and diagnostic logging; +7. SSE parsing for streamed Responses events; +8. tool-call accumulation and the multi-step tool execution loop; +9. conversation or response continuation; +10. stable, testable error types. + +For the first sample, items 1-6 and a non-streaming response are sufficient. Items 7-9 should be separate follow-on capabilities. + +## 4. Scenario A: Local C++ Agent Using a Foundry Project + +### Recommended Architecture + +```mermaid +flowchart LR + U[Developer / console input] + CLI[C++ console host] + CORE[Shared AgentCore] + ID[azure-identity-cpp] + HTTP[HTTPS + JSON client] + FP[Foundry Project] + MODEL[Model deployment] + + U --> CLI + CLI --> CORE + CORE --> ID + CORE --> HTTP + ID -->|token for ai.azure.com| HTTP + HTTP -->|POST project/openai/v1/responses| FP + FP --> MODEL + MODEL --> FP + FP --> HTTP + HTTP --> CORE + CORE --> CLI +``` + +### Suggested Internal API + +```cpp +struct AgentRequest +{ + std::string input; +}; + +struct AgentResponse +{ + std::string text; + std::string responseId; +}; + +class AgentCore +{ +public: + virtual ~AgentCore() = default; + virtual AgentResponse Run(const AgentRequest& request) = 0; +}; +``` + +`FoundryResponsesAgent` would implement `AgentCore` and own the credential, endpoint, deployment name, instructions, HTTP pipeline, and response parsing. The console executable would only read configuration, call `Run`, and print the result. + +### Authentication Behavior + +For parity with the two current local samples: + +- use `DefaultAzureCredential` as the default so the same code can work locally and in the hosted container; +- document `az login` as the expected local developer credential; +- optionally allow an explicit `AzureCliCredential` development mode if exact C# behavior is desired; +- never use API keys as the default sample path. + +`DefaultAzureCredential` is a better shared-core choice than the C# local sample's narrower `AzureCliCredential`, because it also supports workload or managed identity in hosted mode.[^12] + +### Minimum Local Validation + +- missing `FOUNDRY_PROJECT_ENDPOINT` produces an actionable startup error; +- malformed project endpoint is rejected before the first request; +- missing deployment name either produces an error or intentionally uses `gpt-5-mini`, matching repository convention; +- the token is requested for `https://ai.azure.com/.default`; +- the final URL preserves `/api/projects/`; +- one prompt returns assistant text; +- 401, 403, 404, 429, timeout, and malformed-response paths remain distinguishable; +- no credential, token, or full authorization header is logged. + +## 5. Scenario B: The Same C++ Agent as a Foundry Hosted Agent + +### Recommended Protocol: Invocations `2.0.0` + +Foundry Hosted Agents expose Responses, Invocations, Invocations WebSocket, Activity, and preview A2A paths. The platform documentation explicitly routes custom streaming protocols such as AG-UI through Invocations.[^4] + +For C++ today: + +- **Invocations is the lowest-risk baseline** because the platform accepts application-defined request and response bodies. +- **Responses offers better platform parity** but requires a correct OpenAI Responses-compatible server implementation that C++ does not currently receive from an official adapter. +- **Invocations WebSocket** is intended for duplex real-time scenarios such as voice and is unnecessary for this text agent. +- **A2A** is preview and should not be the first hosting contract. + +The initial sample should therefore mirror the Go hosted sample's deployment shape: + +```yaml +services: + cpp-agent: + project: . + host: azure.ai.agent + language: docker + docker: + remoteBuild: true + uses: + - foundry-project + env: + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + kind: hosted + protocols: + - protocol: invocations + version: 2.0.0 +``` + +This is the demonstrated custom-container route in the existing repository.[^10] + +### Container Runtime Contract + +The authoritative runtime contract requires the container to: + +- bind plain HTTP to `0.0.0.0`; +- use port `8088` by default and honor `PORT` when supplied; +- expose `GET /readiness` and return `200 OK`; +- expose `POST /invocations` for the declared Invocations protocol; +- handle graceful termination and flush pending state under `$HOME`; +- run in the supported Linux AMD64 container environment.[^5][^19] + +Important distinction: + +- the `{"status":"ready"}` readiness body and 405 behavior in the Go sample are good conventions, but only the successful `GET /readiness` status is the platform requirement; +- running as root is a workaround documented by the current Go sample for its root-owned `/home/session` mount, not a universal language requirement; +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` is explicitly passed through the repository's `azure.yaml`; it should not be assumed to be an intrinsic platform variable. + +### Hosted Architecture + +```mermaid +flowchart TB + CLIENT[Foundry client / Playground] + GATEWAY[Foundry Agent endpoint] + CONTAINER[C++ Linux AMD64 container] + READY[GET /readiness] + INVOKE[POST /invocations] + CORE[Shared AgentCore] + ID[DefaultAzureCredential] + PROJECT[Foundry Project OpenAI v1] + MODEL[Model deployment] + + CLIENT --> GATEWAY + GATEWAY --> INVOKE + GATEWAY --> READY + READY --> CONTAINER + INVOKE --> CONTAINER + CONTAINER --> CORE + CORE --> ID + CORE -->|POST /api/projects/.../openai/v1/responses| PROJECT + PROJECT --> MODEL + MODEL --> PROJECT + PROJECT --> CORE + CORE --> CONTAINER + CONTAINER --> GATEWAY + GATEWAY --> CLIENT +``` + +### Initial Request Contract + +For the smallest useful parity sample, accept: + +1. raw UTF-8 text; +2. a JSON string containing the prompt; and +3. optionally, a small JSON object with `input`. + +Return `text/plain` for these forms. This duplicates the useful part of the Go sample without requiring AG-UI on day one.[^9] + +AG-UI streaming can then be added as a separately tested path using the community C++ SDK. The SDK is substantial and covers the protocol event model, but it is community-tier source with no published package, so the repository should pin an exact commit and describe that support boundary.[^13] + +### Session and Concurrency Design + +The Go sample keeps one in-memory session protected by a mutex. That is understandable for a small demonstration but should not be copied blindly into a production C++ service.[^9] + +Recommended C++ behavior: + +- make the plain-text endpoint stateless by default; +- when a request contains a platform/session/thread identifier, key state by that identifier; +- never share one conversation history across unrelated callers; +- bound the number and lifetime of in-memory sessions; +- use `$HOME` only when persistence is intentional; +- serialize concurrent mutations per session, not across the whole process; +- define shutdown behavior for in-flight model calls. + +## 6. Implementation Options + +### Option A: Pure C++ with Raw REST + +**Stack:** `azure-identity-cpp` + libcurl/CPR or `azure-core-cpp` HTTP + nlohmann/json + cpp-httplib/Drogon. + +**Advantages** + +- one language and one native runtime; +- smallest conceptual dependency on unsupported agent frameworks; +- transparent protocol behavior; +- portable local executable; +- deployable through the same Docker/`azure.yaml` path as Go. + +**Disadvantages** + +- highest amount of application-owned protocol code; +- no Microsoft-supported agent abstraction; +- manual SSE, tools, session, retry, and telemetry behavior; +- greater maintenance exposure when APIs evolve. + +**Best fit:** repository sample, proof of concept, constrained agent, or organization committed to owning a native Foundry adapter. + +### Option B: A Typed C++ Foundry Wrapper + +Build a small internal library over `azure-core-cpp` that exposes typed Responses request/response models and policies. + +**Advantages** + +- isolates Foundry details from the sample hosts; +- gives tests a stable seam; +- can later add streaming and tools without changing entry points; +- creates a reusable asset for other C++ applications. + +**Disadvantages** + +- still an unsupported client owned by the project; +- code generation or hand-maintained models add build complexity; +- does not solve server-side hosting semantics automatically. + +**Best fit:** production organization choosing pure C++ for strategic reasons. + +### Option C: Community OpenAI or Agent Library + +Use a community OpenAI C++ client or native agent framework and configure the Foundry base URL and bearer token. + +**Advantages** + +- less initial HTTP/JSON boilerplate; +- some libraries provide streaming or tool-call helpers. + +**Disadvantages** + +- Azure/Foundry authentication and endpoint behavior are usually not first class; +- maintenance quality varies; +- a community agent API can become a second compatibility surface; +- it does not remove the need for the Foundry Hosted Agent server contract. + +**Best fit:** experimentation after repository and security review. Not the recommended baseline. + +### Option D: Supported-Runtime Sidecar or Bridge + +Keep the agent orchestration in .NET, Python, or Go and expose a small local HTTP/gRPC/C ABI to the C++ application. + +```mermaid +flowchart LR + CPP[C++ application or native library] + BRIDGE[.NET / Python / Go agent adapter] + FOUNDRY[Microsoft Foundry Project] + HOST[Hosted Agent protocol adapter] + + CPP <-->|local HTTP, gRPC, C ABI, or MCP| BRIDGE + BRIDGE --> FOUNDRY + HOST --> BRIDGE +``` + +**Advantages** + +- best parity with supported agent frameworks; +- supported token, Responses, streaming, and tool behavior; +- lower API-evolution risk; +- C++ can remain focused on native domain logic. + +**Disadvantages** + +- two language runtimes and two dependency graphs; +- process lifecycle and local transport must be managed; +- extra diagnostic and deployment complexity. + +**Best fit:** production systems needing full capabilities or Microsoft-supported orchestration. + +### Option E: C++ as an MCP Tool or A2A Sub-Agent + +Expose the C++ capability to a supported Foundry agent rather than making C++ the orchestrator. + +**Advantages** + +- excellent fit for existing native libraries, simulation engines, signal processing, or high-performance workloads; +- keeps conversation and model orchestration in a supported stack; +- clean service boundary. + +**Disadvantages** + +- does not demonstrate a C++-owned top-level agent; +- no official Microsoft C++ MCP or A2A SDK; +- A2A is still preview in Foundry. + +**Best fit:** production composition when C++ is the specialized capability rather than the user-facing agent runtime. + +## 7. Decision Matrix + +Scores are relative recommendations, where 5 is strongest. + +| Approach | Local parity | Hosted parity | Rich streaming/tools | Microsoft supportability | Native purity | Maintenance burden | Overall use | +|---|---:|---:|---:|---:|---:|---:|---| +| Raw REST, pure C++ | 4 | 4 with Invocations | 2 | 2 | 5 | 2 | Best educational baseline | +| Typed internal C++ wrapper | 4 | 4 with Invocations | 3 | 2 | 5 | 3 | Best long-term pure-C++ investment | +| Community OpenAI/agent library | 3 | 3 | 3 | 1 | 5 | 2 | Experimental | +| Supported-runtime bridge | 5 | 5 | 5 | 4 | 2 | 4 | Best production default | +| C++ MCP/A2A component | 2 | 3 | 4 | 2 | 4 | 3 | Best when C++ is a tool/sub-agent | +| Foundry Local C++ | 1 for this goal | 0 | 3 | 1-2 | 5 | 2 | Different on-device use case | + +## 8. Recommended Repository Direction + +### Baseline Sample Architecture + +Create two projects backed by one shared library: + +```text +MAF-Agent-CPP-05/ + CMakeLists.txt + vcpkg.json + include/foundry_agent/ + src/ + agent.cpp + config.cpp + http_transport.cpp + main.cpp + tests/ + +MAF-Agent-CPP-06/ + CMakeLists.txt + vcpkg.json + Dockerfile + azure.yaml + .agentignore + include/foundry_host/ + src/ + host.cpp + main.cpp + tests/ +``` + +### Recommended Dependency Baseline + +- C++20; +- CMake with a checked-in `vcpkg.json`; +- `azure-identity-cpp`; +- libcurl for HTTPS; +- nlohmann/json; +- cpp-httplib for the educational server; +- Catch2 for offline unit tests; +- OpenTelemetry C++ as a follow-on, not a prerequisite; +- AG-UI community C++ only in a later streaming milestone. + +### Why Invocations First + +The initial C++ sample should not attempt to reproduce the entire OpenAI Responses server contract. Invocations: + +- is already demonstrated by the Go sample; +- permits a small plain-text contract; +- allows the same agent core to be exercised locally and remotely; +- reduces the first implementation to auth, one model request, one HTTP server, readiness, and deployment; +- leaves AG-UI streaming and tool calls as explicit later layers. + +### Production Recommendation + +Use pure C++ only when one or more of these are true: + +- the process cannot embed another runtime; +- native footprint or ABI requirements dominate; +- the organization is prepared to maintain a Foundry adapter; +- the agent is intentionally simple and its protocol surface is tightly controlled. + +Prefer a .NET, Python, or Go bridge when: + +- platform-managed Responses semantics are required; +- tools, MCP, A2A, or multi-agent orchestration are central; +- the application requires rapid alignment with new Foundry features; +- formal vendor support and examples matter more than single-language purity. + +## 9. Progressive Validation Plan + +### Gate 1: Local Model Call + +- acquire an Entra token through `DefaultAzureCredential`; +- prove the token scope is `https://ai.azure.com/.default`; +- call `/openai/v1/responses`; +- parse one non-streaming text response; +- compare behavior with C# and Go console samples. + +### Gate 2: Local Hosted-Agent Contract + +- run the C++ server locally; +- verify bind address and `PORT`; +- verify `GET /readiness`; +- verify `POST /invocations` with raw text and JSON string; +- test SIGTERM and in-flight request shutdown; +- test concurrent requests and absence of cross-session state leakage. + +### Gate 3: Container + +- build a Linux AMD64 image; +- verify CA certificates and TLS dependencies in the final image; +- run as non-root first, then document a root requirement only if the Foundry session mount proves it necessary; +- scan the final dependency and license inventory; +- run the same contract tests against the container. + +### Gate 4: Foundry Deployment + +- deploy through `language: docker`, `remoteBuild: true`; +- confirm the agent identity can acquire the Foundry token; +- invoke through the Foundry Invocations endpoint; +- verify readiness, logs, request IDs, and failures; +- compare output with local mode. + +### Gate 5: Optional Feature Parity + +- streamed Responses client; +- AG-UI SSE output; +- tool calling with bounded loop count; +- per-session conversation state; +- MCP toolbox connection; +- OpenTelemetry spans and Application Insights correlation; +- evaluation dataset equivalent to the C# hosted sample. + +## 10. Key Risks + +| Risk | Severity | Mitigation | +|---|---|---| +| No official C++ Foundry/Agent Framework SDK | High | Keep a small adapter boundary; pin API behavior with integration tests | +| Incorrect token audience or endpoint construction | High | Assert exact scope and URL; compare with upstream Go/.NET source[^18] | +| Manual token refresh and 401 replay | High | Centralize credential policy and test forced expiry | +| SSE and tool-call complexity | High | Ship non-streaming first; add event conformance tests before enabling | +| Cross-user session leakage | High | Stateless default or session-keyed bounded store | +| C++ TLS/ABI dependency mismatch in Linux image | Medium | Build and run in one distro family; pin compiler and packages | +| Community AG-UI/MCP dependency maturity | Medium | Pin commits, vendor only after review, maintain protocol-level tests | +| Foundry contract and `azd` extension evolution | Medium | Pin tool versions where possible and keep deployment smoke tests | +| Misrepresenting Foundry Local as cloud Foundry support | Medium | Document it as a separate on-device product path | + +## 11. Capability Verdict + +### Educational / Repository Sample + +**Go.** A pure C++ pair is technically justified and valuable because it demonstrates the lower-level, language-neutral boundary of Foundry: + +- local cloud inference is a standard authenticated HTTPS call; +- hosted deployment is a custom-container contract; +- the absence of a C++ agent SDK becomes an explicit teaching point rather than a hidden limitation. + +The first version should intentionally limit itself to non-streaming text, no tools, stateless invocations, and Invocations protocol `2.0.0`. + +### Production + +**Conditional go.** A pure C++ agent is viable only if the team accepts ownership of the missing SDK and protocol layers. For a general-purpose production agent with tools, streaming, multi-turn state, and rapid Foundry feature adoption, a supported-runtime bridge is the lower-risk architecture. + +## Confidence Assessment + +### High Confidence + +- The repository's C# and Go local/hosted behavior and configuration. +- The absence of C++ in the current Foundry SDK and Microsoft Agent Framework surfaces. +- The absence of AI/Foundry clients in the Azure SDK for C++. +- The exact project-scoped Responses URL and `https://ai.azure.com/.default` token scope used by the current Go and .NET adapters. +- The custom-container `azure.yaml` pattern and Invocations `2.0.0` declaration. +- The Hosted Agent network, readiness, and route contract. + +### Medium Confidence + +- `azure-identity-cpp` behavior inside the Foundry-managed hosted sandbox. The credential supports the required mechanisms, but this repository does not yet prove the C++ runtime path. +- The best C++ HTTP/server library choice. The recommended libraries are mature, but the final choice depends on footprint, concurrency, and repository conventions. +- The operational suitability of the AG-UI community C++ SDK. Its source and tests are substantial, but it lacks a published package and first-party support commitment. + +### Requires Experiment + +- Managed/workload identity acquisition from a deployed C++ Hosted Agent. +- Whether the sample can run non-root while using the platform's session filesystem. +- End-to-end Invocations behavior through the Foundry Playground for each chosen content type. +- Application Insights correlation using OpenTelemetry C++ without an official Foundry adapter. +- Tool-call and streaming conformance if those features are added. + +## Footnotes + +[^1]: [Microsoft Foundry Agent Service overview](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) and [Hosted Agent quickstart](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent), updated August 27, 2026. These list current supported SDK and quickstart paths; C++ is absent. + +[^2]: [microsoft/agent-framework README](https://github.com/microsoft/agent-framework/blob/947d933f2385b3f38ff40bef5b0c0245acdf3798/README.md#L1-L10). The framework describes .NET and Python support and links to Go; no C++ implementation is present. + +[^3]: [Azure SDK for C++ `sdk/` tree](https://github.com/Azure/azure-sdk-for-cpp/tree/9dacd081b645f5449eafb08676bed1542cf33a23/sdk). The current tree contains no AI Projects, Foundry, OpenAI, or agent package. + +[^4]: [Hosted agents in Foundry Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents). The protocol table covers Responses, Invocations, Invocations WebSocket, Activity, and preview A2A, and routes AG-UI-style custom streaming through Invocations. + +[^5]: [Hosted Agent runtime contract](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agent-contract), updated August 27, 2026. + +[^6]: [MAF-Agent-CS-01/Program.cs:1-15](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-CS-01/Program.cs#L1-L15). + +[^7]: [MAF-Agent-GO-03/main.go:18-56](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-GO-03/main.go#L18-L56). + +[^8]: [MAF-Agent-CS-02/Program.cs:1-18](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-CS-02/Program.cs#L1-L18) and [MAF-Agent-CS-02/azure.yaml:1-29](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-CS-02/azure.yaml#L1-L29). + +[^9]: [MAF-Agent-GO-04/main.go:46-176](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-GO-04/main.go#L46-L176), [MAF-Agent-GO-04/main_test.go](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-GO-04/main_test.go). + +[^10]: [MAF-Agent-GO-04/azure.yaml:1-29](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-GO-04/azure.yaml#L1-L29). + +[^11]: [Foundry SDK overview](https://learn.microsoft.com/en-us/azure/foundry/how-to/develop/sdk-overview) and [Build with runtime components](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/runtime-components), current August 2026 documentation. + +[^12]: [Azure Identity client library for C++ README](https://github.com/Azure/azure-sdk-for-cpp/blob/9dacd081b645f5449eafb08676bed1542cf33a23/sdk/identity/azure-identity/README.md). + +[^13]: [AG-UI repository SDK table](https://github.com/ag-ui-protocol/ag-ui/blob/a0d5a7f93866cfad78cf78dc6938bc31f05fe038/README.md) and [community C++ SDK](https://github.com/ag-ui-protocol/ag-ui/tree/a0d5a7f93866cfad78cf78dc6938bc31f05fe038/sdks/community/c%2B%2B). + +[^14]: [Hosted agents in Foundry Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents), protocol table and A2A preview status. + +[^15]: [OpenTelemetry C++](https://opentelemetry.io/docs/languages/cpp/), current August 2026 status page. + +[^16]: [microsoft/foundry-local `sdk/cpp/README.md`:5](https://github.com/microsoft/foundry-local/blob/80b10e0c824556e41f4a756159abab7b19dee348/sdk/cpp/README.md#L5), [v1 C++ SDK source](https://github.com/microsoft/foundry-local/tree/80b10e0c824556e41f4a756159abab7b19dee348/sdk/cpp), and [v2 C++ source](https://github.com/microsoft/foundry-local/tree/80b10e0c824556e41f4a756159abab7b19dee348/sdk_v2/cpp). + +[^17]: [Foundry Local SDK reference](https://learn.microsoft.com/en-us/azure/foundry-local/reference/reference-sdk-current), updated August 2026. + +[^18]: [microsoft/agent-framework-go/provider/foundryprovider/agent.go:16-20](https://github.com/microsoft/agent-framework-go/blob/7cdbf69e/provider/foundryprovider/agent.go#L16-L20), [agent.go:79-95](https://github.com/microsoft/agent-framework-go/blob/7cdbf69e/provider/foundryprovider/agent.go#L79-L95), [agent.go:127-134](https://github.com/microsoft/agent-framework-go/blob/7cdbf69e/provider/foundryprovider/agent.go#L127-L134), and `Azure/azure-sdk-for-net:sdk/ai/Azure.AI.Extensions.OpenAI/src/Custom/OpenAI/ProjectOpenAIClient.cs` at source revision `3f8b5440`. + +[^19]: [MAF-Agent-GO-04/Dockerfile:1-18](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-GO-04/Dockerfile#L1-L18) and [MAF-Agent-GO-04/README.md](https://github.com/Azure-Samples/microsoft-foundry-hosted-agents/blob/47d3f1d13905a3f087d15fee275f8153fa754420/MAF-Agent-GO-04/README.md).