From 5973adbfa2efd9b0048eea436dfb4aa676e354cf Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Mon, 22 Jun 2026 17:12:15 +0800 Subject: [PATCH 01/12] Add foundry-hosted-agent-copilotkit skill and Forgewright App Builder agent Adds a skill and companion agent for building complete agentic web apps on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack, with native human-in-the-loop approval on consequential tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry-hosted-agent-copilotkit.agent.md | 58 +++++ docs/README.agents.md | 1 + docs/README.skills.md | 1 + .../foundry-hosted-agent-copilotkit/SKILL.md | 209 ++++++++++++++++++ .../references/architecture.md | 118 ++++++++++ .../references/hosted-deploy.md | 60 +++++ .../references/patterns-7.md | 73 ++++++ .../references/troubleshooting.md | 61 +++++ 8 files changed, 581 insertions(+) create mode 100644 agents/foundry-hosted-agent-copilotkit.agent.md create mode 100644 skills/foundry-hosted-agent-copilotkit/SKILL.md create mode 100644 skills/foundry-hosted-agent-copilotkit/references/architecture.md create mode 100644 skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md create mode 100644 skills/foundry-hosted-agent-copilotkit/references/patterns-7.md create mode 100644 skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md diff --git a/agents/foundry-hosted-agent-copilotkit.agent.md b/agents/foundry-hosted-agent-copilotkit.agent.md new file mode 100644 index 000000000..1cbc65e2c --- /dev/null +++ b/agents/foundry-hosted-agent-copilotkit.agent.md @@ -0,0 +1,58 @@ +--- +description: "Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project." +model: "gpt-5" +tools: ["codebase", "terminalCommand"] +name: foundry-hosted-agent-copilotkit +--- + +You are **Forgewright**, an expert builder of agentic web apps on the **Azure AI +Foundry hosted-agent + AG-UI + CopilotKit** stack. From a single prompt ("build me an +assistant that can … with approval before …") you produce a complete, runnable, +verified app — you do the work, you do not hand the user manual steps. + +Always drive the build through the **`foundry-hosted-agent-copilotkit` skill**: read +its `SKILL.md` and `references/` in full before acting, and follow its rules, +anti-patterns, and Definition of Done exactly. + +## Architecture you build to (non-negotiable) + +- ALL intelligence — `FoundryChatClient` (Responses), every `@tool`, HITL, and history + — runs in ONE **Foundry HOSTED agent** (`build_hosted_agent()`). +- A **light bridge** (Container App, no LLM/tools) speaks AG-UI to the UI, forwards + each turn to the hosted agent, translates Responses → AG-UI, and forwards + `mcp_approval_response` on HITL approve so the gated tool re-executes server-side. +- **CopilotKit v2** hooks are the UI layer only: `useAgent`, `useFrontendTool`, + `useRenderTool`, `useHumanInTheLoop`. + +## Your workflow + +1. **Scaffold** the canonical template into a new runnable app (never start from a + blank repo). +2. **Customize only the marked extension points**: agent instructions + tools (≥1 read + tool, ≥1 `@tool(approval_mode="always_require")` consequential tool) and the + CopilotKit components. Map "needs approval before X" to the gated tool. +3. **Leave the load-bearing parts unchanged**: the `HostedProxyAgent` bridge wiring, + `build_hosted_agent()` with `FoundryChatClient`, the catch-all CopilotKit route, and + the `{ accepted, steps }` HITL contract. +4. **Prove it**: run the structural check and the smoke E2E (the bridge against the + REAL agent run locally via `azd ai agent run`). Both MUST pass. For the deployed + path, require a live browser E2E of HITL approve **and** reject. + +## Guidelines + +- **Never declare success on an unverified build.** `azd` reporting SUCCESS, a dev + server starting, or one chat reply is NOT proof. Done = structural + smoke green, + plus a live browser E2E for server-side patterns in scope. +- Use `FoundryChatClient` for the hosted agent — the Responses `OpenAIChatClient` + 500s on hosted approve-resume. +- Resolve HITL with `{ accepted, steps }`, never `{ approved }`. +- Set `useSingleEndpoint={false}` and use the catch-all `[[...slug]]` CopilotKit route. +- A consequential tool without `approval_mode="always_require"` is a bug — it has no + HITL gate. +- Use **MCR** base images in every Dockerfile (Docker Hub pulls rate-limit on ACR). +- Never commit secrets, endpoints, or app-specific hard-coding. +- This stack requires a paid **Azure AI Foundry** project, `az login`, and the `azd` + Foundry extension — state this prerequisite up front; there is no fully-offline path. +- When a framework limitation blocks you, consult the + [microsoft/agent-framework](https://github.com/microsoft/agent-framework) repo and + its open issues before writing a workaround. diff --git a/docs/README.agents.md b/docs/README.agents.md index 147e24b7f..f285ae5c1 100644 --- a/docs/README.agents.md +++ b/docs/README.agents.md @@ -99,6 +99,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-agents) for guidelines on how to | [Expert React Frontend Engineer](../agents/expert-react-frontend-engineer.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fexpert-react-frontend-engineer.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fexpert-react-frontend-engineer.agent.md) | Expert React 19.2 frontend engineer specializing in modern hooks, Server Components, Actions, TypeScript, and performance optimization | | | [Expert Vue.js Frontend Engineer](../agents/vuejs-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fvuejs-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fvuejs-expert.agent.md) | Expert Vue.js frontend engineer specializing in Vue 3 Composition API, reactivity, state management, testing, and performance with TypeScript | | | [Fedora Linux Expert](../agents/fedora-linux-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffedora-linux-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffedora-linux-expert.agent.md) | Fedora (Red Hat family) Linux specialist focused on dnf, SELinux, and modern systemd-based workflows. | | +| [Foundry Hosted Agent Copilotkit](../agents/foundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md) | Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project. | | | [Frontend Performance Investigator](../agents/frontend-performance-investigator.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffrontend-performance-investigator.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffrontend-performance-investigator.agent.md) | Runtime web-performance specialist for diagnosing Core Web Vitals, Lighthouse regressions, layout shifts, long tasks, and slow network paths with Chrome DevTools MCP. | | | [Gem Browser Tester](../agents/gem-browser-tester.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-browser-tester.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-browser-tester.agent.md) | E2E browser testing, UI/UX validation, visual regression. | | | [Gem Code Simplifier](../agents/gem-code-simplifier.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-code-simplifier.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-code-simplifier.agent.md) | Refactoring specialist — removes dead code, reduces complexity, consolidates duplicates. | | diff --git a/docs/README.skills.md b/docs/README.skills.md index 789e7eca7..515c24bd1 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -174,6 +174,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [fluentui-blazor](../skills/fluentui-blazor/SKILL.md)
`gh skills install github/awesome-copilot fluentui-blazor` | Guide for using the Microsoft Fluent UI Blazor component library (Microsoft.FluentUI.AspNetCore.Components NuGet package) in Blazor applications. Use this when the user is building a Blazor app with Fluent UI components, setting up the library, using FluentUI components like FluentButton, FluentDataGrid, FluentDialog, FluentToast, FluentNavMenu, FluentTextField, FluentSelect, FluentAutocomplete, FluentDesignTheme, or any component prefixed with "Fluent". Also use when troubleshooting missing providers, JS interop issues, or theming. | `references/DATAGRID.md`
`references/LAYOUT-AND-NAVIGATION.md`
`references/SETUP.md`
`references/THEMING.md` | | [folder-structure-blueprint-generator](../skills/folder-structure-blueprint-generator/SKILL.md)
`gh skills install github/awesome-copilot folder-structure-blueprint-generator` | Comprehensive technology-agnostic prompt for analyzing and documenting project folder structures. Auto-detects project types (.NET, Java, React, Angular, Python, Node.js, Flutter), generates detailed blueprints with visualization options, naming conventions, file placement patterns, and extension templates for maintaining consistent code organization across diverse technology stacks. | None | | [foundry-agent-sync](../skills/foundry-agent-sync/SKILL.md)
`gh skills install github/awesome-copilot foundry-agent-sync` | Create and synchronize prompt-based AI agents directly within Azure AI Foundry via REST API, from a local JSON manifest. Unlike scaffolding skills that only generate local code, this skill registers agents in the Foundry service itself — making them immediately available for invocation. Use when the user asks to create agents in Foundry, sync, deploy, register, or push agents to Foundry, update agent instructions, or scaffold the manifest and sync script for a new repository. Triggers: 'create agent in foundry', 'sync foundry agents', 'deploy agents to foundry', 'register agents in foundry', 'push agents', 'create foundry agent manifest', 'scaffold agent sync'. | None | +| [foundry-hosted-agent-copilotkit](../skills/foundry-hosted-agent-copilotkit/SKILL.md)
`gh skills install github/awesome-copilot foundry-hosted-agent-copilotkit` | Build a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack: a Next.js/CopilotKit v2 chat UI over a light FastAPI/AG-UI bridge that forwards every turn to ONE Microsoft Agent Framework agent hosted in Azure AI Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). Triggers: agentic app, CopilotKit app, AG-UI bridge, Foundry hosted agent, Microsoft Agent Framework, human-in-the-loop/HITL approval, approval_mode always_require, confirm_changes. Also for fixing the known traps: HITL approve-resume 400 'No tool output found', confirm_changes mis-wired, AG-UI snapshot cards vanishing, CopilotKit catch-all route 404/422, useSingleEndpoint, keyless Foundry 401 audience, Docker Hub rate-limit on ACR build. | `references/architecture.md`
`references/hosted-deploy.md`
`references/patterns-7.md`
`references/troubleshooting.md` | | [freecad-scripts](../skills/freecad-scripts/SKILL.md)
`gh skills install github/awesome-copilot freecad-scripts` | Expert skill for writing FreeCAD Python scripts, macros, and automation. Use when asked to create FreeCAD models, parametric objects, Part/Mesh/Sketcher scripts, workbench tools, GUI dialogs with PySide, Coin3D scenegraph manipulation, or any FreeCAD Python API task. Covers FreeCAD scripting basics, geometry creation, FeaturePython objects, interface tools, and macro development. | `references/geometry-and-shapes.md`
`references/gui-and-interface.md`
`references/parametric-objects.md`
`references/scripting-fundamentals.md`
`references/workbenches-and-advanced.md` | | [from-the-other-side-anitta](../skills/from-the-other-side-anitta/SKILL.md)
`gh skills install github/awesome-copilot from-the-other-side-anitta` | Rigorous challenge profile for Anitta: assumption checks, evidence calibration, and defensible reasoning patterns for Ember collaboration. | None | | [from-the-other-side-quinn](../skills/from-the-other-side-quinn/SKILL.md)
`gh skills install github/awesome-copilot from-the-other-side-quinn` | Collaboration profile for Quinn: curious, energetic, and implementation-focused partnership patterns for Ember sessions with Alison. | None | diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md new file mode 100644 index 000000000..baff17059 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -0,0 +1,209 @@ +--- +name: foundry-hosted-agent-copilotkit +description: "Build a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack: a Next.js/CopilotKit v2 chat UI over a light FastAPI/AG-UI bridge that forwards every turn to ONE Microsoft Agent Framework agent hosted in Azure AI Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). Triggers: agentic app, CopilotKit app, AG-UI bridge, Foundry hosted agent, Microsoft Agent Framework, human-in-the-loop/HITL approval, approval_mode always_require, confirm_changes. Also for fixing the known traps: HITL approve-resume 400 'No tool output found', confirm_changes mis-wired, AG-UI snapshot cards vanishing, CopilotKit catch-all route 404/422, useSingleEndpoint, keyless Foundry 401 audience, Docker Hub rate-limit on ACR build." +--- + +# Forgewright — Foundry hosted agent + AG-UI + CopilotKit apps + +Build an agentic web app on the **hosted-agent-first** standard: ALL intelligence +(`FoundryChatClient` + tools + HITL + history) runs in an **Azure AI Foundry HOSTED +agent** (Responses protocol). A **light bridge** (Container App, no LLM/tools) speaks +AG-UI to a Next.js/CopilotKit UI, forwards every turn to the hosted agent, translates +Responses → AG-UI, and absorbs framework bugs. CopilotKit (**v2** hooks) is the UI +layer: chat, generative cards, forms, approval, and shared/predictive state. + +> **Prerequisite (paid service):** this stack targets **Azure AI Foundry**. You need +> an Azure subscription, a provisioned Foundry project, `az login`, and the `azd` +> Foundry extension. There is no fully-offline path — local dev runs the *real* agent +> via `azd ai agent run`. + +``` + Next.js + CopilotKit v2 (frontend/) Foundry HOSTED agent = the BRAIN + useAgent / useFrontendTool / src/agent.py build_hosted_agent(): + useRenderTool / useHumanInTheLoop FoundryChatClient (Responses) + route.ts (CopilotSseRuntime + HttpAgent) ALL @tools + HITL + history + │ AG-UI / SSE ▲ Responses (stream) + + ▼ │ mcp_approval_response + BRIDGE (backend/bridge_app.py) │ + HostedProxyAgent → forwards turns to the hosted agent, translates + Responses→AG-UI, forwards mcp_approval_response on approve (tool re-executes). + Same code drives the LOCAL agent (`azd ai agent run`, DIRECT mode) and the + DEPLOYED agent (platform mode) — no mock anywhere. + (+ SSE keepalive, optional API key.) + GOVERNANCE: build_hosted_agent() + ResponsesHostServer (azd) publishes the agent. +``` + +**Golden rule:** `azd` SUCCESS, a dev server starting, or one chat reply is **not** +proof. Because all logic is server-side, you are done only when the structural and +end-to-end checks pass AND a **live** browser E2E against the deployed hosted agent +passes for the patterns in scope. Never declare success on an unverified build. + +## 0. Orient + +- `LOAD references/architecture.md` — hosted-first topology; what lives where; the + native-path test matrix proving why the hand-rolled bridge is the minimum. +- `LOAD references/patterns-7.md` — the 7 AG-UI dojo patterns on this stack + (Agentic Chat, Backend Tool Rendering, HITL, Tool-Based Generative UI, Agentic + Generative UI, Shared State, Predictive State) with source citations. +- `LOAD references/troubleshooting.md` — every known trap → symptom → fix. +- `LOAD references/hosted-deploy.md` — Foundry hosted-agent deploy gotchas (azd, + remote build, dependency pinning). + +The canonical, runnable template + scaffolding scripts live in the companion repo +**[lordlinus/forgewright](https://github.com/lordlinus/forgewright)** under +`templates/agentic-copilot-foundry/`, with vendored AG-UI dojo source under +`reference/dojo/`. Read both before changing anything; do not reinvent the bridge, +the patches, or the state machinery. + +## 1. Scaffold (always start here) + +Instantiate the canonical template into a new, runnable app (lowercase-hyphen name) +and rewrite the agent-name tokens (`AGENT_NAME`, the CopilotKit agent, the route, the +hosted yaml) so they stay consistent: + +```bash +scripts/new-app.sh [target-dir] # from the forgewright repo +``` + +The result already runs and already passes the bridge end-to-end smoke check. + +## 2. Customize to the user's prompt — extension points + +Edit `src/agent.py` (the hosted brain via `build_hosted_agent()`): +- Instructions — the agent's behavior for the requested domain. +- Tools — keep **≥1 read tool** (no side effects) and **≥1 consequential tool** + decorated `@tool(approval_mode="always_require")`. Map the user's "needs approval + before X" to the gated tool. For shared/predictive/generative-UI features, add the + `state_schema` + `predict_state_config` shape from `references/patterns-7.md`. +- Update the smoke script's domain prompts (read prompt / action prompt / state field + / read tool) to match your tools so the E2E exercises the chosen patterns against + the real agent. + +Edit `frontend/components/` (CopilotKit **v2** hooks — see `references/patterns-7.md`): +- `useFrontendTool` (client tools / tool-based generative UI), `useRenderTool` + (backend tool cards), `useHumanInTheLoop` (HITL approval; keep the + `{ accepted, steps }` contract), `useAgent` (shared / predictive state). + +**Do NOT touch** (load-bearing and proven): +- the bridge wiring in `backend/bridge_app.py` (`HostedProxyAgent` + the one + snapshot-split workaround); +- `build_hosted_agent()` (`FoundryChatClient`, Responses) in `src/agent.py`; +- the CopilotKit bridge route `frontend/app/api/copilotkit/[[...slug]]/route.ts`. + +## 3. Prove it + +```bash +make verify # structural: bridge wiring, FoundryChatClient, HITL contract, names, MCR base +make smoke # the BRIDGE against the REAL agent running locally via `azd ai agent + # run` — read works, action PAUSES, approve executes, reject doesn't, + # state deltas flow for the shared/predictive patterns in scope. + # Needs `az login` + a provisioned Foundry project. +``` + +Both must be green. Then `make local` (dev loop) and, in a Foundry-enabled tenant, +`make up` (azd → hosted agent) followed by a **live browser E2E** — the real DoD, +since all logic is server-side. + +## Load-bearing rules (why the template is shaped this way) + +### The bridge forwards HITL to the hosted agent (hand-rolled — and necessary) +- **Deployed:** `bridge_app.py` mounts `HostedProxyAgent` (a `SupportsAgentRun`) on + the AG-UI endpoint. It forwards each turn to the deployed Foundry hosted agent over + streaming Responses, translates the output to AG-UI (text, tool cards, + `confirm_changes`), and on HITL approve forwards an `mcp_approval_response` so the + gated tool **re-executes server-side**. `bridge_app.py` neutralises ag-ui's LOCAL + approval interception so the decision reaches the agent. +- **Why hand-rolled, not the native `add_agent_framework_fastapi_endpoint(FoundryAgent)`:** + re-verified live on the latest packages (matrix in `references/architecture.md`). + The native path needs `allow_preview=True` just to reach the hosted-agent endpoint, + and even then — with or without the ag-ui patches — HITL **approve does NOT + re-execute** the tool: the `FoundryAgent` client has no client-side + `mcp_approval_response`. The hand-rolled forwarder fills exactly that one gap. + Tracked upstream as + [microsoft/agent-framework#6652](https://github.com/microsoft/agent-framework/issues/6652); + retire the shim when it closes. +- **Local dev (`make local`/`make smoke`):** `azd ai agent run` runs the REAL agent + (`ResponsesHostServer` + `FoundryChatClient`) on your machine, connected to your + Foundry project's model; the bridge points at it in **DIRECT mode** + (`HOSTED_AGENT_DIRECT_URL`), driving the SAME `HostedProxyAgent` path as production — + no mock anywhere. +- Why a bridge at all: you **cannot** point `@ag-ui/client` at a deployed hosted + agent — `ResponsesHostServer` speaks OpenAI Responses, not AG-UI. +- The bridge must NOT send `x-ms-user-isolation-key` (deployed agents use Entra + isolation → 400). An SSE keep-alive keeps the stream alive during silent tools. + +### Client choice (load-bearing) +- **`build_hosted_agent` → `FoundryChatClient` (Responses)** — the single brain, the + SAME code locally (`azd ai agent run`) and deployed (`azd up`). Required so the + hosted `mcp_approval_request`/`mcp_approval_response` re-executes the gated tool + (verified live: 100→125 deployed, 100→110 local). No mock client. The Responses + `OpenAIChatClient` / Chat Completions path 500s on hosted approve-resume — do not + use it here. + +### Framework workarounds — minimal, re-check each upgrade +`bridge_app.py` patches (both proven load-bearing by the smoke E2E): (a) route HITL +approvals to the hosted agent (not local); (b) split multi-tool snapshot messages +(CopilotKit v1 renders only `toolCalls[0]`). Re-run the native-path matrix in +`references/architecture.md` on each upgrade and delete a patch the moment the +framework closes the gap. + +### The 7 AG-UI patterns +See `references/patterns-7.md`. Through the deployed/local hosted bridge: Agentic +Chat, Backend Tool Rendering, HITL (forwarded). Shared/predictive state through the +bridge is roadmap (native only when the AG-UI adapter wraps an in-process agent). + +### HITL contract +- A `@tool(approval_mode="always_require")` tool surfaces as a `confirm_changes` + tool call (with `function_name`, `function_arguments`, `steps`). The frontend + (`useHumanInTheLoop`) resolves it with `{ accepted: boolean, steps }` (NOT + `{ approved }`). The framework's native approval flow re-executes on accept. + +### CopilotKit (UI layer) +- **v2 React hooks** (`@copilotkit/react-core/v2`): `useAgent`, `useAgentContext`, + `useFrontendTool`, `useRenderTool`, `useHumanInTheLoop`. +- **Bridge route:** catch-all `app/api/copilotkit/[[...slug]]/route.ts`; import + `@copilotkit/runtime/v2`; `createCopilotHonoHandler` (multi-route, not the + single-route Next endpoint); re-export POST/GET/PATCH/DELETE; + ``. Any miss → Threads 404/405/422. + +### Containers +Use **MCR** base images (`mcr.microsoft.com/devcontainers/python:3.12`, +`.../typescript-node:20`). Docker Hub anonymous pulls hit `toomanyrequests` on +`az acr build` / ACR Tasks. + +## Anti-patterns + +- **Hand-rolling a NEW Responses→AG-UI proxy from scratch.** Reuse the proven + `HostedProxyAgent`. (The framework-native + `add_agent_framework_fastapi_endpoint(FoundryAgent(...))` does NOT forward HITL + approve — that is why the hand-rolled forwarder exists.) +- Putting business logic the agent should own into the bridge — the bridge is just + the framework endpoint + SSE keepalive + optional upload. +- Using the Responses `OpenAIChatClient` for `build_hosted_agent` — use + `FoundryChatClient`. +- Resolving approval with `{ approved }` instead of `{ accepted, steps }`. +- `useSingleEndpoint` left at its default `true` (Threads/Info 404). +- A consequential tool **without** `approval_mode="always_require"` (no HITL gate). +- Docker Hub base images in any Dockerfile. +- Declaring success because a server started — run the structural + smoke checks, and + for the deployed (server-side) path a live browser E2E. + +## Definition of Done + +The app is **not** done until all are true (evidence-backed): + +- [ ] Structural check green (bridge mounts `HostedProxyAgent`; `build_hosted_agent` + uses `FoundryChatClient`; the bridge forwards HITL; HITL contract; names; MCR). +- [ ] Smoke E2E green: the bridge against the REAL agent (run locally via + `azd ai agent run`) shows read works; the consequential prompt PAUSES; approve + executes; reject does not; and for shared/predictive patterns in scope, state + flows. +- [ ] `src/agent.py` has `build_hosted_agent()` (`FoundryChatClient`), ≥1 read tool + and ≥1 `approval_mode="always_require"` tool. +- [ ] Agent name is consistent across `src/agent.py`, the route, the CopilotKit + provider, and the hosted yaml. +- [ ] No secrets, endpoints, or app-specific hard-coding committed. +- [ ] **Live** (the deployed path drives a server-side agent): `make up` succeeds, the + bridge runs with `HOSTED_AGENT_NAME` → `HostedProxyAgent` → the deployed agent, + and a **real browser E2E** passes for the patterns in scope — HITL approve **and** + reject, plus any shared/predictive state round-trip and generative-UI cards. diff --git a/skills/foundry-hosted-agent-copilotkit/references/architecture.md b/skills/foundry-hosted-agent-copilotkit/references/architecture.md new file mode 100644 index 000000000..c4752d35c --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/architecture.md @@ -0,0 +1,118 @@ +# Architecture — Foundry hosted agent + CopilotKit, via a HITL-forwarding bridge + +**Goal:** an Azure AI Foundry HOSTED agent (all tools + HITL + history server-side) +with a CopilotKit UI showing rich generative UI — tool-render cards, human-in-the- +loop approval, shared/predictive state. + +**Why a bridge at all:** you **cannot** point `@ag-ui/client` at a deployed hosted +agent — its endpoint speaks the OpenAI **Responses** protocol, not AG-UI. AND the +framework's *native* path (`add_agent_framework_fastapi_endpoint(FoundryAgent(...))`) +resolves the HITL `confirm_changes` **locally** and never forwards the approval, so +the gated tool **does not re-execute** (verified live). So the bridge is a small +hand-rolled forwarder: it translates Responses→AG-UI AND forwards the HITL decision +as an `mcp_approval_response`, which re-executes the tool server-side. + +``` + Browser — Next.js + CopilotKit (v2 hooks) + useAgent / useFrontendTool / useRenderTool / useHumanInTheLoop + app/api/copilotkit/[[...slug]]/route.ts (CopilotSseRuntime + HttpAgent) + │ AG-UI / SSE + ▼ + BRIDGE (Container App — backend/bridge_app.py) + LOCAL/DEPLOYED: HostedProxyAgent (SupportsAgentRun) — forwards each turn to the + hosted agent (hosted_client, streaming Responses), translates → AG-UI + (text, tool cards, confirm_changes), and forwards mcp_approval_response + on approve (bridge_app patches neutralise ag-ui's local interception). + LOCAL DEV: `azd ai agent run` runs the agent on your machine; bridge → DIRECT + mode (HOSTED_AGENT_DIRECT_URL). DEPLOYED: bridge → platform mode. No mock. + │ POST .../agents//endpoint/protocols/openai/responses (stream) + ▼ + FOUNDRY HOSTED AGENT (the brain — azd → host: azure.ai.agent) + src/agent.py build_hosted_agent(): FoundryChatClient (Responses), store=False + ALL @tools + @tool(approval_mode="always_require") HITL + history server-side +``` + +## Validated live (deployed agent agentic-copilot-foundry, swec-proj-default) + +- Read tool → runs server-side; tool-render card in AG-UI. +- HITL trigger → `mcp_approval_request` → bridge surfaces `confirm_changes` (pause). +- **Approve → bridge sends `mcp_approval_response{approve:true}` → tool re-executes + server-side, state changes (100→125).** No "No tool output found". +- Reject → `approve:false` → tool does NOT execute (state unchanged). +- Two gotchas found live: the bridge must NOT send `x-ms-user-isolation-key` + (deployed agents use Entra isolation → 400); and `build_hosted_agent` MUST use + `FoundryChatClient` (Chat Completions 500s on hosted approve-resume). + +## Why the bridge is the MINIMUM (native-path test matrix) + +Is the hand-rolled bridge over-engineering? We tested every alternative against the +real agent on the **latest** packages (agent-framework-core 1.9.0, +agent-framework-foundry 1.8.2, agent-framework-ag-ui 1.0.0rc5). `make smoke` = 15 +assertions (read, HITL pause, approve re-executes, reject, C9, C10). + +| Configuration | Result | +| --- | --- | +| **Bridge (HostedProxyAgent + 2 patches)** | **15/15** ✓ | +| Bridge, HITL approval routing patch removed | approve does NOT change state ✗ — patch REQUIRED | +| Bridge, `DISABLE_C9_SPLIT=1` | C9 fails (snapshot lumps multiple tool_calls) ✗ — split REQUIRED | +| Native `add_agent_framework_fastapi_endpoint(FoundryAgent(...))` | 400 "Hosted agents can only be called through the agent endpoint" ✗ | +| Native + `allow_preview=True` | surfaces the approval, but **approve does NOT re-execute** (state unchanged); C9 fails ✗ | +| Native + `allow_preview=True` + the 2 patches | **still** approve does NOT re-execute ✗ | + +**Conclusion:** the native `FoundryAgent` client has no client-side +`mcp_approval_response` — it cannot complete hosted HITL no matter how it's +configured. We still use `agent-framework-ag-ui` (`add_agent_framework_fastapi_endpoint`) +for the AG-UI translation; we just feed it a `SupportsAgentRun` shim +(`HostedProxyAgent`) that forwards the approval, plus two ag-ui patches `make smoke` +proves are load-bearing. Nothing else is hand-rolled. **Tracked upstream as +[microsoft/agent-framework#6652](https://github.com/microsoft/agent-framework/issues/6652)** — +re-run this matrix on each package bump and retire the shim + the HITL-routing patch +the moment #6652 closes (the native `FoundryAgent` path then suffices). + +## Client choice (the load-bearing rule) + +- **Hosted agent (`build_hosted_agent`) → `FoundryChatClient` (Responses).** Required + so the hosted runtime's `mcp_approval_request`/`mcp_approval_response` re-executes + the gated tool. Chat Completions 500s on resume here. +- **Local dev → `azd ai agent run`**: the Foundry extension runs the REAL agent + (`ResponsesHostServer` + `FoundryChatClient`) on your machine, connected to your + Foundry project's model. `make smoke`/`make local` point the bridge at it in DIRECT + mode (`HOSTED_AGENT_DIRECT_URL` → POST `/responses` with `previous_response_id` + chaining), so it drives the SAME `HostedProxyAgent` path as production. No mock — + needs `az login` + a provisioned project (`make up` once). + +## File map + +``` +/ +├── src/ +│ └── agent.py ONE agent. build_hosted_agent() → FoundryChatClient +│ (the single brain — same code local + deployed). Read tools +│ + ≥1 @tool(approval_mode="always_require"). +├── backend/ THE BRIDGE (deployed Container App). +│ ├── bridge_app.py AG-UI endpoint → HostedProxyAgent (DIRECT local / +│ │ platform deployed). + SSE keepalive + optional API key. +│ ├── hosted_proxy.py HostedProxyAgent: forward turns + translate Responses → +│ │ AG-UI; surface confirm_changes; forward mcp_approval_response. +│ ├── hosted_client.py streaming Responses driver: platform (conversation + +│ │ agent_session_id, keyless) OR DIRECT (local azd ai agent run). +│ ├── requirements.txt bridge deps only (httpx pin; no foundry/openai — runs no model). +│ └── Dockerfile MCR base; deploys uvicorn bridge_app:app. +├── hosted/ azd → Foundry HOSTED agent (Responses) — the deployed brain. +│ ├── azure.yaml host: azure.ai.agent; azure.ai.agents pinned; context=root. +│ └── responses/ main.py = ResponsesHostServer(build_hosted_agent()), … +├── frontend/ Next.js + CopilotKit v2 (useAgent/useFrontendTool/ +│ useRenderTool/useHumanInTheLoop). +├── scripts/ verify.sh (structural), smoke.py (E2E vs the real local agent), +│ lib-agentrun.sh (azd ai agent run + bridge DIRECT). +└── Makefile(+.targets) preflight / local / verify / smoke / up / deploy / clean. +``` + +## Proving it (Definition of Done) + +`azd` SUCCESS / a server starting is **not** proof. Done = `make verify` + +`make smoke` (the bridge against the REAL agent run locally via `azd ai agent run`) +green, AND — because the deployed path drives a server-side agent — a **live** +browser E2E: deploy with `azd`, run the bridge with `HOSTED_AGENT_NAME` set, and +confirm read + HITL approve (tool re-executes, state changes) **and** reject (no +change) in a real browser. diff --git a/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md b/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md new file mode 100644 index 000000000..1386e0f76 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md @@ -0,0 +1,60 @@ +# Hosted deploy — publish the agent as a Foundry hosted agent (azd) + +The `build_hosted_agent()` that backs the deployed brain (FoundryChatClient, +Responses) is published as an **Azure AI Foundry hosted agent**. This runs from +`hosted/` and needs an Azure subscription + a Foundry-enabled tenant. The SAME +`build_hosted_agent()` runs locally for development via `azd ai agent run`. + +## Prerequisites + +- `az login` into the **tenant that owns the Foundry project** (a 403 on + `Microsoft.MachineLearningServices/workspaces/agents/action` means the wrong + tenant). +- The azd `azure.ai.agents` extension: + `azd extension install azure.ai.agents` (the template pins `>=0.1.0-preview`). +- An `azd` environment with a region/model selected. + +## Deploy + +```bash +cd hosted +azd env new # first time +azd env set AZURE_LOCATION +# (model deployment name comes from hosted/azure.yaml `deployments` + agent.yaml) +make up # == azd up : provision + remote-build the image + publish the agent +``` + +`make up` builds the image with **remote build** (so no local Docker needed) from +the template root context (so the shared `src/agent.py` is included), provisions +the model deployment declared in `hosted/azure.yaml`, and publishes the hosted +agent described by `agent.yaml` / `agent.manifest.yaml`. + +## Gotchas (also in troubleshooting.md) + +- **Docker Hub rate limit** on build → the Dockerfiles use `mcr.microsoft.com` + base images. Keep it that way. +- **helloworld placeholder deployed** → you ran `azd provision` only; run + `make up` (provision + deploy). +- **401 "audience is incorrect"** at runtime → the agent must request the + `https://ai.azure.com/.default` audience (the template's `build_hosted_agent` + already does). + +## Prove the hosted agent (live) + +Deployment SUCCESS is not proof. Run the agent (e.g. via the VS Code Foundry +toolkit `azd ai agent run`, or the Foundry portal playground) and confirm that +**one consequential action pauses for human approval** before executing — the +same HITL contract you verified locally with `make smoke`. + +## Connecting a frontend to the hosted agent (the light bridge) + +In production the chat UI does NOT run the agent — it talks to the deployed Foundry +hosted agent through the **light bridge** (`backend/bridge_app.py`, the +`backend/Dockerfile` default). Deploy the bridge as a Container App and point the +CopilotKit runtime's `AG_UI_BACKEND_URL` at it; set `FOUNDRY_PROJECT_ENDPOINT` + +`HOSTED_AGENT_NAME` (the deployed agent) on the bridge so `HostedProxyAgent` can reach +it keyless. Run a single replica (per-thread conversation/session cache is +in-memory) or externalise the cache. The CopilotKit `route.ts` bridge is unchanged. + +For the local dev loop, `make local` runs the SAME agent locally via +`azd ai agent run` and points the bridge (`bridge_app:app`) at it — no mock. diff --git a/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md b/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md new file mode 100644 index 000000000..ff3061d9b --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md @@ -0,0 +1,73 @@ +# The 7 AG-UI patterns on the hosted-agent + light-bridge stack + +These are the AG-UI dojo "Microsoft Agent Framework Python" feature patterns, +adapted to our standard (intelligence in the Foundry HOSTED agent; a light bridge; +CopilotKit **v2** UI hooks). Canonical source is vendored under `reference/dojo/`: +backend agents from `microsoft/agent-framework` +(`python/packages/ag-ui/agent_framework_ag_ui_examples/agents/*`) and the v2 +frontend pages from `ag-ui-protocol/ag-ui` +(`apps/dojo/src/app/[integrationId]/feature/(v2)/*`). + +CopilotKit **v2** hooks (`@copilotkit/react-core/v2`): +`useAgent`, `useAgentContext`, `useFrontendTool`, `useRenderTool`, +`useHumanInTheLoop`. + +| # | Pattern | Hosted-agent side | CopilotKit v2 UI | Through the bridge | +|---|---|---|---|---| +| 1 | Agentic Chat (frontend tools) | plain `Agent` (no server tool needed) | `useFrontendTool({name,parameters,handler})` (runs in browser) + `useAgentContext` | native — client tool, agent just emits the tool call | +| 2 | Backend Tool Rendering | `@tool` (executes server-side) | `useRenderTool({name,parameters,render})` | native — `function_call`/`function_call_output` forwarded | +| 3 | HITL approval | `@tool(approval_mode="always_require")` | `useHumanInTheLoop({name,render})` → `respond({accepted, steps})` | native function-approval; surfaces as `confirm_changes` | +| 5 | Tool-Based Generative UI | `FunctionTool(func=None)` (declaration-only) + `tool_choice="required"` | `useFrontendTool({name,handler,render,followUp:false})` | native — stream tool-call args to the renderer | +| 4 | Agentic Generative UI | `predict_state_config` + `require_confirmation=False`; stream step status via tool args | `useAgent({updates:[OnStateChanged]})` → `agent.state` | **bridge synthesizes** StateDelta/Snapshot from arg-deltas | +| 6 | Shared State | `AgentFrameworkAgent(state_schema, predict_state_config, require_confirmation=False)` | `useAgent` + `agent.setState()` | **bridge synthesizes** state + **forwards** `setState` → hosted input | +| 7 | Predictive State Updates | same as #6 but `require_confirmation=True` (default) + `@tool(approval_mode="always_require")` | `useAgent` + `useHumanInTheLoop` (confirm/reject) | synthesized streaming state + HITL confirm | + +## How it works on this stack + +- **Native adapter (reference):** `add_agent_framework_fastapi_endpoint(agent)` + natively emits all AG-UI events — text, TOOL_CALL_* cards, function-approval HITL, + and StateSnapshot/Delta (via `state_schema`+`predict_state_config`) — *when it + wraps a plain in-process `Agent`*. The templates don't run the agent in-process; + they keep all logic in the Foundry hosted agent and reach it through the bridge. +- **Deployed (hosted agent):** the bridge is `HostedProxyAgent`, NOT the native + `add_agent_framework_fastapi_endpoint(FoundryAgent(...))`. The native FoundryAgent + path translates read/cards/HITL-*pause*, but on HITL **approve it does NOT + re-execute** the hosted tool (it resolves `confirm_changes` locally; the Foundry + client has no `mcp_approval_response` forwarding — verified live). `HostedProxyAgent` + forwards `mcp_approval_response` to the hosted agent so the gated tool re-executes + server-side. Use it for any deployed app with HITL. + +| # | Pattern | Hosted-agent side | CopilotKit v2 UI | Through the bridge | +|---|---|---|---|---| +| 1 | Agentic Chat | plain Agent | `useFrontendTool` | native | +| 2 | Backend Tool Rendering | `@tool` | `useRenderTool` | HostedProxyAgent forwards function_call/result | +| 3 | HITL approval | `@tool(approval_mode="always_require")` | `useHumanInTheLoop` → `{accepted, steps}` | bridge forwards mcp_approval_response (re-executes) | +| 5 | Tool-Based Generative UI | `FunctionTool(func=None)` | `useFrontendTool` render | stream tool-call args | +| 4 / 6 / 7 | Agentic Generative / Shared / Predictive State | `state_schema` + `predict_state_config` | `useAgent` + `setState` | bridge relays text/tool-arg deltas; state synthesis through the deployed bridge is roadmap (native only when the adapter wraps an in-process agent) | + +## HITL contract + +The gated tool surfaces as `confirm_changes` (with `function_name`, +`function_arguments`, `steps`); the UI (`useHumanInTheLoop`) resolves +`{ accepted: boolean, steps }`. Deployed: Accept → `mcp_approval_response{approve:true}` +(tool re-executes server-side), Reject → `approve:false`. + +## Framework workarounds (minimal; re-check each upgrade) + +`bridge_app.py` patches: (a) neutralise ag-ui's local approval interception +so approvals reach the hosted agent; (b) split multi-tool snapshot messages +(CopilotKit v1 renders only `toolCalls[0]`; set `DISABLE_C9_SPLIT=1` on a v2 frontend). +The bridge must NOT send `x-ms-user-isolation-key` (deployed agents use Entra +isolation → 400). + +## Roadmap: shared / predictive state through the DEPLOYED bridge + +Shared State, Predictive State, and Agentic Generative UI are emitted **natively by +the AG-UI adapter when it wraps an in-process `Agent`** (via `state_schema` + +`predict_state_config`). Through the DEPLOYED bridge they are **not yet wired**: the +hosted Responses stream would need `HostedProxyAgent` to relay +`response.function_call_arguments.delta` as growing tool-call args, and to forward +`useAgent.setState` (RunInput.state) to the hosted agent. The plumbing is understood +(the AG-UI adapter synthesises StateDelta/Snapshot from streaming tool-call args) but +is left as a follow-up — current templates ship the validated read + tool-render + +HITL through the deployed bridge. diff --git a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md new file mode 100644 index 000000000..6bf4f3f0a --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md @@ -0,0 +1,61 @@ +# Troubleshooting — known traps → symptom → fix + +Each row is a real failure mode encoded as a check in `scripts/verify.sh` or +`scripts/smoke.py`. Fix the cause; do not work around it. + +## HITL / approval + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Approve a tool → `RUN_ERROR` 400/500 **"No tool output found for function call"** | the hosted agent uses Chat Completions (`OpenAIChatClient`/`OpenAIChatCompletionClient`) instead of Responses | `build_hosted_agent` MUST use `FoundryChatClient` (Responses) so the hosted `mcp_approval_response` re-executes the tool. `verify.sh` checks for `FoundryChatClient`. | +| Approval card never appears | `confirm_changes` not registered via the v2 `useHumanInTheLoop` hook | Keep the `confirm_changes` `useHumanInTheLoop({ name: "confirm_changes", ... })` from the template verbatim. | +| Clicking Approve does nothing / tool never runs | Resolving with `{ approved }` | Resolve with `{ accepted: boolean, steps }`. Backend detection is `"accepted" in parsed`. | +| Approve works once, next message 400s with orphaned `call_…` | (pre-rc5) stale approval payload re-sent | Handled NATIVELY on agent-framework-ag-ui rc5 — do not re-add the old hand-rolled patches. | +| Consequential tool runs WITHOUT asking | Tool missing `approval_mode="always_require"` | Decorate the consequential tool. `verify.sh` requires at least one. | + +## AG-UI rendering + +| Symptom | Cause | Fix | +| --- | --- | --- | +| HITL approve doesn't re-execute the tool server-side (state unchanged after approve) | ag-ui resolves `confirm_changes` **locally** before the proxy sees it | `bridge_app.py` neutralises `_is_confirm_changes_response` + `_resolve_approval_responses`, so the decision reaches `HostedProxyAgent`, which forwards `mcp_approval_response` to the hosted agent. **Proven load-bearing: disabling it → approve doesn't change state.** | +| Approval/tool card vanishes at RUN_FINISHED when a turn made several tool calls | ag-ui's snapshot builder lumps multiple tool_calls into one assistant message; CopilotKit **v1** renders only `toolCalls[0]` | `bridge_app.py` splits multi-tool snapshot messages (`_build_messages_snapshot`); `smoke.py` C9 guards it. **Proven load-bearing: `DISABLE_C9_SPLIT=1` fails C9.** (v2 renders all tool calls, but the split keeps the snapshot correct for both frontends.) | +| Replayed history 400s / orphaned tool call (C10) | raw AG-UI history replayed to the hosted agent | the proxy does **not** replay raw history — `_find_approval_decision` / `_latest_user_text` derive the turn input (latest user text, or an `mcp_approval_response`). `smoke.py` C10 asserts no error. No `normalize_*` patch needed. | + +## CopilotKit bridge + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `GET /api/copilotkit/threads` 404 | missing catch-all dir | route lives in `app/api/copilotkit/[[...slug]]/route.ts`. | +| Threads 405 on every request | single-route endpoint | use `createCopilotHonoHandler` (multi-route) and re-export POST/GET/PATCH/DELETE. | +| Threads panel 422 "Missing CopilotKitIntelligence configuration" | lib `CopilotRuntime` wraps the runner in `TelemetryAgentRunner` | use the v2 `CopilotSseRuntime` with a raw `InMemoryAgentRunner`. | +| "Agent `` not found" / Info 404 | `useSingleEndpoint` defaulted to `true` | set ``. | +| `` doesn't match | name drift | keep `AGENT_NAME` == route const == provider == hosted yaml. `verify.sh` checks it. | +| `next build` type error: `HttpAgent` missing `pendingInterrupts` | `@ag-ui/client` older than the version CopilotKit resolves | pin `@ag-ui/client` to the version `@copilotkit/runtime` depends on (e.g. `0.0.56`). | +| Browser console: "Failed to execute 'fetch' on 'Window': Illegal invocation" (`agent_run_failed_event`); the agent never runs | CopilotKit v2 (`ɵcreateThreadStore` + `@ag-ui/client` HttpAgent) captures the global `fetch` as a bare reference and calls it with the wrong `this`; `CopilotKitCore` exposes no `fetch` option | bind the global fetch to `window` before any module loads — an inline `` script in `app/layout.tsx`: `if(!window.fetch.__bound){var f=window.fetch.bind(window);f.__bound=true;window.fetch=f;}`. `verify.sh` checks it; proven in a real browser (control reproduces, fix → 0 errors). | + +## Foundry connection + +| Symptom | Cause | Fix | +| --- | --- | --- | +| 401 "audience is incorrect" | default `cognitiveservices.azure.com` scope on the project path | request the `https://ai.azure.com/.default` audience. | +| 403 `workspaces/agents/action` | `az` logged into the wrong tenant for the project | `az login --tenant ` (or set the project's tenant). | +| Run the agent locally for dev | no deployed agent yet | `azd ai agent run` runs the REAL agent on your machine (what `make local`/`make smoke` use, via the bridge's DIRECT mode); needs `az login` + a provisioned project (`make up` once). | + +## Containers / azd + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `az acr build` fails `toomanyrequests` | Docker Hub base image | use `mcr.microsoft.com/devcontainers/...` base images. | +| azd deploys the helloworld placeholder | ran `azd provision` only | run `make up` (= `azd up` = provision + deploy). | +| hosted image missing `src/agent.py` | build context too narrow | `hosted/azure.yaml` sets `context: ..` (template root). | + +## Bridge (the framework-native AG-UI endpoint) + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Approval card vanishes at RUN_FINISHED | multi-tool snapshot; CopilotKit v1 renders only `toolCalls[0]` | keep the snapshot-split in `bridge_app.py`; `make smoke` C9 guards it. | +| HITL approve does nothing / state doesn't change | ag-ui resolved the approval locally (the routing patch was removed/disabled) | the hosted bridge needs **two** patches — HITL approval routing **and** the snapshot split — both proven load-bearing on rc5 (disabling either fails smoke). Keep both. | +| `useAgent().state` stays empty | `state_schema`/`predict_state_config` not passed to the endpoint, or no tool writes the state key | set `AGENT_STATE_SCHEMA`/`AGENT_PREDICT_STATE` in `src/agent.py` and write the key from a tool. | +| Deployed bridge can't reach the agent | `FOUNDRY_PROJECT_ENDPOINT` / `HOSTED_AGENT_NAME` unset | set both; the bridge (`hosted_client`) reaches the deployed agent keyless. | +| Python `@tool` didn't run "in Foundry" | FoundryAgent runs Python `@tool` callables CLIENT-SIDE; only Foundry-native tools run server-side | expected — define server-side tools on the deployed agent; keep `@tool`s for client-side/HITL. | +| UI 500 mid-run on a long silent tool | a gateway dropped the idle SSE | keep `SSEKeepAliveMiddleware` (`: ping` ~10s). | From daf94efb6aad203cb24bcbcc85c49c4b94501f75 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Mon, 22 Jun 2026 20:14:00 +0800 Subject: [PATCH 02/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- agents/foundry-hosted-agent-copilotkit.agent.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/foundry-hosted-agent-copilotkit.agent.md b/agents/foundry-hosted-agent-copilotkit.agent.md index 1cbc65e2c..1af3bf784 100644 --- a/agents/foundry-hosted-agent-copilotkit.agent.md +++ b/agents/foundry-hosted-agent-copilotkit.agent.md @@ -1,8 +1,8 @@ --- -description: "Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project." -model: "gpt-5" -tools: ["codebase", "terminalCommand"] -name: foundry-hosted-agent-copilotkit +description: 'Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid).' +model: 'gpt-5' +tools: ['codebase', 'terminalCommand'] +name: 'Forgewright App Builder' --- You are **Forgewright**, an expert builder of agentic web apps on the **Azure AI From 576ad91d07dc046aa98f7f38d647980253ae1bc8 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 10:10:58 +0800 Subject: [PATCH 03/12] Remove external forgewright repo reference; fix agent name/filename validator errors - Made the skill self-contained: replaced the pointer to the companion lordlinus/forgewright repo (scripts/new-app.sh, vendored reference/dojo/) with an inline project layout and direct upstream source citations (microsoft/agent-framework, ag-ui-protocol/ag-ui). - Renamed the agent from 'Forgewright App Builder' to 'foundry-hosted-agent-copilotkit' (lowercase-hyphen, matches the .agent.md filename) to fix the two validator errors reported by CI. - Dropped the 'Forgewright' persona/branding throughout the skill and agent files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry-hosted-agent-copilotkit.agent.md | 11 ++++--- docs/README.agents.md | 2 +- .../foundry-hosted-agent-copilotkit/SKILL.md | 32 ++++++++++++------- .../references/patterns-7.md | 4 +-- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/agents/foundry-hosted-agent-copilotkit.agent.md b/agents/foundry-hosted-agent-copilotkit.agent.md index 1af3bf784..ce9384d36 100644 --- a/agents/foundry-hosted-agent-copilotkit.agent.md +++ b/agents/foundry-hosted-agent-copilotkit.agent.md @@ -2,11 +2,11 @@ description: 'Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid).' model: 'gpt-5' tools: ['codebase', 'terminalCommand'] -name: 'Forgewright App Builder' +name: 'foundry-hosted-agent-copilotkit' --- -You are **Forgewright**, an expert builder of agentic web apps on the **Azure AI -Foundry hosted-agent + AG-UI + CopilotKit** stack. From a single prompt ("build me an +You are an expert builder of agentic web apps on the **Azure AI Foundry +hosted-agent + AG-UI + CopilotKit** stack. From a single prompt ("build me an assistant that can … with approval before …") you produce a complete, runnable, verified app — you do the work, you do not hand the user manual steps. @@ -26,8 +26,9 @@ anti-patterns, and Definition of Done exactly. ## Your workflow -1. **Scaffold** the canonical template into a new runnable app (never start from a - blank repo). +1. **Scaffold** the project layout described in the skill's `SKILL.md` and + `references/` (Next.js/CopilotKit v2 frontend, light FastAPI/AG-UI bridge, + `src/agent.py` hosted-agent brain) — never start from an ad-hoc layout. 2. **Customize only the marked extension points**: agent instructions + tools (≥1 read tool, ≥1 `@tool(approval_mode="always_require")` consequential tool) and the CopilotKit components. Map "needs approval before X" to the gated tool. diff --git a/docs/README.agents.md b/docs/README.agents.md index f285ae5c1..d29a83bad 100644 --- a/docs/README.agents.md +++ b/docs/README.agents.md @@ -99,7 +99,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-agents) for guidelines on how to | [Expert React Frontend Engineer](../agents/expert-react-frontend-engineer.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fexpert-react-frontend-engineer.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fexpert-react-frontend-engineer.agent.md) | Expert React 19.2 frontend engineer specializing in modern hooks, Server Components, Actions, TypeScript, and performance optimization | | | [Expert Vue.js Frontend Engineer](../agents/vuejs-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fvuejs-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fvuejs-expert.agent.md) | Expert Vue.js frontend engineer specializing in Vue 3 Composition API, reactivity, state management, testing, and performance with TypeScript | | | [Fedora Linux Expert](../agents/fedora-linux-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffedora-linux-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffedora-linux-expert.agent.md) | Fedora (Red Hat family) Linux specialist focused on dnf, SELinux, and modern systemd-based workflows. | | -| [Foundry Hosted Agent Copilotkit](../agents/foundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md) | Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project. | | +| [Foundry Hosted Agent Copilotkit](../agents/foundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md) | Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). | | | [Frontend Performance Investigator](../agents/frontend-performance-investigator.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffrontend-performance-investigator.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffrontend-performance-investigator.agent.md) | Runtime web-performance specialist for diagnosing Core Web Vitals, Lighthouse regressions, layout shifts, long tasks, and slow network paths with Chrome DevTools MCP. | | | [Gem Browser Tester](../agents/gem-browser-tester.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-browser-tester.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-browser-tester.agent.md) | E2E browser testing, UI/UX validation, visual regression. | | | [Gem Code Simplifier](../agents/gem-code-simplifier.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-code-simplifier.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-code-simplifier.agent.md) | Refactoring specialist — removes dead code, reduces complexity, consolidates duplicates. | | diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md index baff17059..62dbe545c 100644 --- a/skills/foundry-hosted-agent-copilotkit/SKILL.md +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -3,7 +3,7 @@ name: foundry-hosted-agent-copilotkit description: "Build a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack: a Next.js/CopilotKit v2 chat UI over a light FastAPI/AG-UI bridge that forwards every turn to ONE Microsoft Agent Framework agent hosted in Azure AI Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). Triggers: agentic app, CopilotKit app, AG-UI bridge, Foundry hosted agent, Microsoft Agent Framework, human-in-the-loop/HITL approval, approval_mode always_require, confirm_changes. Also for fixing the known traps: HITL approve-resume 400 'No tool output found', confirm_changes mis-wired, AG-UI snapshot cards vanishing, CopilotKit catch-all route 404/422, useSingleEndpoint, keyless Foundry 401 audience, Docker Hub rate-limit on ACR build." --- -# Forgewright — Foundry hosted agent + AG-UI + CopilotKit apps +# Foundry hosted agent + AG-UI + CopilotKit apps Build an agentic web app on the **hosted-agent-first** standard: ALL intelligence (`FoundryChatClient` + tools + HITL + history) runs in an **Azure AI Foundry HOSTED @@ -49,23 +49,31 @@ passes for the patterns in scope. Never declare success on an unverified build. - `LOAD references/hosted-deploy.md` — Foundry hosted-agent deploy gotchas (azd, remote build, dependency pinning). -The canonical, runnable template + scaffolding scripts live in the companion repo -**[lordlinus/forgewright](https://github.com/lordlinus/forgewright)** under -`templates/agentic-copilot-foundry/`, with vendored AG-UI dojo source under -`reference/dojo/`. Read both before changing anything; do not reinvent the bridge, -the patches, or the state machinery. +Read all four reference docs before writing any code; they encode the +load-bearing rules, the framework traps, and the Definition of Done that keep +this stack correct — do not reinvent the bridge, the patches, or the state +machinery from scratch each time. ## 1. Scaffold (always start here) -Instantiate the canonical template into a new, runnable app (lowercase-hyphen name) -and rewrite the agent-name tokens (`AGENT_NAME`, the CopilotKit agent, the route, the -hosted yaml) so they stay consistent: +Create the project with this layout (lowercase-hyphen app name), then wire it +per the architecture in `references/architecture.md`: -```bash -scripts/new-app.sh [target-dir] # from the forgewright repo +``` +/ + frontend/ # Next.js + CopilotKit v2 + app/api/copilotkit/[[...slug]]/route.ts # catch-all bridge route + components/ # useAgent / useFrontendTool / + # useRenderTool / useHumanInTheLoop + backend/ + bridge_app.py # HostedProxyAgent — forwards turns + mcp_approval_response + src/agent.py # build_hosted_agent() -> FoundryChatClient (Responses) + azure.yaml / infra/ # azd config to provision + publish the hosted agent ``` -The result already runs and already passes the bridge end-to-end smoke check. +Keep the agent-name token (`AGENT_NAME`, the CopilotKit agent id, the route, the +hosted yaml) consistent across every file. The result must run end-to-end and +pass the checks in step 3 before you customize anything. ## 2. Customize to the user's prompt — extension points diff --git a/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md b/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md index ff3061d9b..b20fd10fb 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md +++ b/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md @@ -2,8 +2,8 @@ These are the AG-UI dojo "Microsoft Agent Framework Python" feature patterns, adapted to our standard (intelligence in the Foundry HOSTED agent; a light bridge; -CopilotKit **v2** UI hooks). Canonical source is vendored under `reference/dojo/`: -backend agents from `microsoft/agent-framework` +CopilotKit **v2** UI hooks). Canonical source: backend agents from +`microsoft/agent-framework` (`python/packages/ag-ui/agent_framework_ag_ui_examples/agents/*`) and the v2 frontend pages from `ag-ui-protocol/ag-ui` (`apps/dojo/src/app/[integrationId]/feature/(v2)/*`). From 8ea048a0b35cdea01b056462f06bffe825361af4 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 10:14:49 +0800 Subject: [PATCH 04/12] Fix vally-lint orphan-files: link reference docs from SKILL.md Vally's link-reachability check requires actual markdown links, not just backtick-quoted paths, to consider a reference file 'reachable'. Convert the four 'LOAD references/*.md' bullets to markdown links. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry-hosted-agent-copilotkit/SKILL.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md index 62dbe545c..48d34e117 100644 --- a/skills/foundry-hosted-agent-copilotkit/SKILL.md +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -40,14 +40,17 @@ passes for the patterns in scope. Never declare success on an unverified build. ## 0. Orient -- `LOAD references/architecture.md` — hosted-first topology; what lives where; the - native-path test matrix proving why the hand-rolled bridge is the minimum. -- `LOAD references/patterns-7.md` — the 7 AG-UI dojo patterns on this stack - (Agentic Chat, Backend Tool Rendering, HITL, Tool-Based Generative UI, Agentic - Generative UI, Shared State, Predictive State) with source citations. -- `LOAD references/troubleshooting.md` — every known trap → symptom → fix. -- `LOAD references/hosted-deploy.md` — Foundry hosted-agent deploy gotchas (azd, - remote build, dependency pinning). +- `LOAD` [`references/architecture.md`](references/architecture.md) — hosted-first + topology; what lives where; the native-path test matrix proving why the + hand-rolled bridge is the minimum. +- `LOAD` [`references/patterns-7.md`](references/patterns-7.md) — the 7 AG-UI + dojo patterns on this stack (Agentic Chat, Backend Tool Rendering, HITL, + Tool-Based Generative UI, Agentic Generative UI, Shared State, Predictive + State) with source citations. +- `LOAD` [`references/troubleshooting.md`](references/troubleshooting.md) — + every known trap → symptom → fix. +- `LOAD` [`references/hosted-deploy.md`](references/hosted-deploy.md) — Foundry + hosted-agent deploy gotchas (azd, remote build, dependency pinning). Read all four reference docs before writing any code; they encode the load-bearing rules, the framework traps, and the Definition of Done that keep From fe2f595178c19736c3a4fdad93fce6b67eb7e8c9 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 11:47:11 +0800 Subject: [PATCH 05/12] Correct skill content based on a real end-to-end build test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the skill through a full, independent end-to-end build (a fresh Copilot CLI session with only this skill installed, no other context) that scaffolded an expense-approval agentic app and verified it against a real local Foundry hosted agent (structural check, smoke test, and a real Playwright browser E2E of HITL pause/approve/reject — all passed with screenshot evidence). The build surfaced several inaccuracies this commit fixes: - The skill described a bridge (`HostedProxyAgent`, `bridge_app.py`, two ag-ui patches) as proven/shipped code to reuse. No code ships with this skill. Reframed as a design with two valid implementation strategies (native add_agent_framework_fastapi_endpoint shim, or a verified-working hand-rolled AG-UI translation), and added an up-front notice that this is a rulebook, not a template. - Added the real bootstrap command for hosted/: `azd ai agent init -m ` / `azd ai agent sample list` — previously undocumented, discovered by the test build. - Corrected CopilotKit-side guidance that live testing proved wrong: the provider component is `CopilotKit` (not `CopilotKitProvider`); the current client can default to single-route mode (opposite of what troubleshooting.md said); the HITL resolve payload shape (`{accepted, steps}`) is a convention you define yourself, not a CopilotKit-enforced contract; the CopilotKit-facing agent registry key (often literally "default") is a separate identifier from the Foundry hosted agent's own name — the two do not have to match. - Flagged that the newest CopilotKit npm prerelease can be a broken/ deprecated CI accident; recommend checking for deprecation warnings and pinning a known-good stable line. - Removed made-up-sounding Makefile targets (`make up`/`make local`) that don't exist without a shipped template; replaced with plain `azd`/script guidance. - Added a note that `python3 -m venv` can fail in sandboxed dev environments without sudo, and `uv` is a reliable fallback. - Softened all 'proven'/'native' claims to explicitly point at confirming behavior against the reader's own installed package versions, since this stack's packages move fast. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry-hosted-agent-copilotkit.agent.md | 39 ++- .../foundry-hosted-agent-copilotkit/SKILL.md | 287 +++++++++++------- .../references/architecture.md | 120 ++++---- .../references/hosted-deploy.md | 72 +++-- .../references/patterns-7.md | 99 +++--- .../references/troubleshooting.md | 69 +++-- 6 files changed, 418 insertions(+), 268 deletions(-) diff --git a/agents/foundry-hosted-agent-copilotkit.agent.md b/agents/foundry-hosted-agent-copilotkit.agent.md index ce9384d36..ef866003f 100644 --- a/agents/foundry-hosted-agent-copilotkit.agent.md +++ b/agents/foundry-hosted-agent-copilotkit.agent.md @@ -18,24 +18,30 @@ anti-patterns, and Definition of Done exactly. - ALL intelligence — `FoundryChatClient` (Responses), every `@tool`, HITL, and history — runs in ONE **Foundry HOSTED agent** (`build_hosted_agent()`). -- A **light bridge** (Container App, no LLM/tools) speaks AG-UI to the UI, forwards - each turn to the hosted agent, translates Responses → AG-UI, and forwards - `mcp_approval_response` on HITL approve so the gated tool re-executes server-side. -- **CopilotKit v2** hooks are the UI layer only: `useAgent`, `useFrontendTool`, - `useRenderTool`, `useHumanInTheLoop`. +- A **light bridge** (Container App, no LLM/tools, written by you — no template + ships) speaks AG-UI to the UI, forwards each turn to the hosted agent, + translates Responses → AG-UI, and forwards `mcp_approval_response` on HITL + approve so the gated tool re-executes server-side. +- **CopilotKit** hooks are the UI layer only: `useAgent`, `useFrontendTool`, + `useRenderTool`, `useHumanInTheLoop` — confirm exact names/behavior against + your installed CopilotKit version before trusting the skill's examples + verbatim; this library moves fast. ## Your workflow 1. **Scaffold** the project layout described in the skill's `SKILL.md` and - `references/` (Next.js/CopilotKit v2 frontend, light FastAPI/AG-UI bridge, - `src/agent.py` hosted-agent brain) — never start from an ad-hoc layout. + `references/` (Next.js/CopilotKit frontend, a bridge you write, `src/agent.py` + hosted-agent brain). Bootstrap the `hosted/` folder with + `azd ai agent init -m ` (`azd ai agent sample list` to + discover manifests) rather than hand-writing it. 2. **Customize only the marked extension points**: agent instructions + tools (≥1 read tool, ≥1 `@tool(approval_mode="always_require")` consequential tool) and the CopilotKit components. Map "needs approval before X" to the gated tool. -3. **Leave the load-bearing parts unchanged**: the `HostedProxyAgent` bridge wiring, - `build_hosted_agent()` with `FoundryChatClient`, the catch-all CopilotKit route, and - the `{ accepted, steps }` HITL contract. -4. **Prove it**: run the structural check and the smoke E2E (the bridge against the +3. **Leave the load-bearing parts unchanged**: `build_hosted_agent()` with + `FoundryChatClient`, and the bridge's HITL-forwarding behavior (every + approve/reject decision must reach the hosted agent as an + `mcp_approval_response`). +4. **Prove it**: write and run a structural check and a smoke E2E (the bridge against the REAL agent run locally via `azd ai agent run`). Both MUST pass. For the deployed path, require a live browser E2E of HITL approve **and** reject. @@ -46,8 +52,12 @@ anti-patterns, and Definition of Done exactly. plus a live browser E2E for server-side patterns in scope. - Use `FoundryChatClient` for the hosted agent — the Responses `OpenAIChatClient` 500s on hosted approve-resume. -- Resolve HITL with `{ accepted, steps }`, never `{ approved }`. -- Set `useSingleEndpoint={false}` and use the catch-all `[[...slug]]` CopilotKit route. +- The HITL resolve payload shape (e.g. `{ accepted, steps }`) is a convention + you define yourself — CopilotKit does not enforce one — just keep the + frontend `respond(...)` call and your bridge's parser consistent. +- Use the catch-all `[[...slug]]` CopilotKit route; verify empirically whether + your installed CopilotKit client/server pair defaults to single-route or + multi-route mode and match it — do not assume either default. - A consequential tool without `approval_mode="always_require"` is a bug — it has no HITL gate. - Use **MCR** base images in every Dockerfile (Docker Hub pulls rate-limit on ACR). @@ -57,3 +67,6 @@ anti-patterns, and Definition of Done exactly. - When a framework limitation blocks you, consult the [microsoft/agent-framework](https://github.com/microsoft/agent-framework) repo and its open issues before writing a workaround. +- Treat every concrete package/API name in the skill as "true as of when it was + written" — this stack's packages (CopilotKit especially) move fast. Confirm + against your installed versions before trusting a name verbatim. diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md index 48d34e117..b38970f2a 100644 --- a/skills/foundry-hosted-agent-copilotkit/SKILL.md +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -18,19 +18,20 @@ layer: chat, generative cards, forms, approval, and shared/predictive state. > via `azd ai agent run`. ``` - Next.js + CopilotKit v2 (frontend/) Foundry HOSTED agent = the BRAIN - useAgent / useFrontendTool / src/agent.py build_hosted_agent(): - useRenderTool / useHumanInTheLoop FoundryChatClient (Responses) - route.ts (CopilotSseRuntime + HttpAgent) ALL @tools + HITL + history - │ AG-UI / SSE ▲ Responses (stream) + - ▼ │ mcp_approval_response - BRIDGE (backend/bridge_app.py) │ - HostedProxyAgent → forwards turns to the hosted agent, translates - Responses→AG-UI, forwards mcp_approval_response on approve (tool re-executes). - Same code drives the LOCAL agent (`azd ai agent run`, DIRECT mode) and the - DEPLOYED agent (platform mode) — no mock anywhere. + Next.js + CopilotKit (frontend/) Foundry HOSTED agent = the BRAIN + useAgent / useFrontendTool / src/agent.py build_hosted_agent(): + useRenderTool / useHumanInTheLoop FoundryChatClient (Responses) + route.ts (CopilotKit runtime + HttpAgent) ALL @tools + HITL + history + │ AG-UI / SSE ▲ Responses (stream) + + ▼ │ mcp_approval_response + BRIDGE (backend/ — you write this) │ + Forwards each turn to the hosted agent, translates its Responses stream to + AG-UI events, forwards mcp_approval_response on approve (tool re-executes). + Same bridge code drives the LOCAL agent (`azd ai agent run`, its local URL) + and the DEPLOYED agent (its platform endpoint) — no mock anywhere. (+ SSE keepalive, optional API key.) - GOVERNANCE: build_hosted_agent() + ResponsesHostServer (azd) publishes the agent. + GOVERNANCE: build_hosted_agent() + ResponsesHostServer (via `azd ai agent init`) + publishes the agent. ``` **Golden rule:** `azd` SUCCESS, a dev server starting, or one chat reply is **not** @@ -54,29 +55,57 @@ passes for the patterns in scope. Never declare success on an unverified build. Read all four reference docs before writing any code; they encode the load-bearing rules, the framework traps, and the Definition of Done that keep -this stack correct — do not reinvent the bridge, the patches, or the state -machinery from scratch each time. +this stack correct. + +> **This skill is a design + rulebook, not a runnable template.** It ships no +> `bridge_app.py`, no `route.ts`, no `Makefile` — you write every file from the +> architecture below. Package APIs on this stack move fast; always confirm the +> exact class/function names, hook signatures, and route-handler mode against +> your **currently installed** package versions (`pip show` / `npm ls` / +> reading `.d.ts` or the package's own bundled docs) before trusting any name +> in this skill verbatim — treat every concrete symbol here as "true as of +> when this was written," not as a guarantee. ## 1. Scaffold (always start here) -Create the project with this layout (lowercase-hyphen app name), then wire it -per the architecture in `references/architecture.md`: +**Bootstrap `hosted/` (the Foundry hosted-agent project) with the `azd` Foundry +extension's own scaffolder — do not hand-write it:** + +```bash +azd ai agent sample list # discover starter manifests +azd ai agent init -m # e.g. an agent-framework/responses/*/agent.manifest.yaml +``` + +This generates the real, currently-correct `hosted/` tree for your installed +extension version: `main.py`/`responses/main.py`, `agent.yaml`, `azure.yaml`, +`requirements.txt`, `Dockerfile`, and a full `infra/` (Bicep). Read the +generated `main.py` to see the CURRENT correct import paths and server class +name — `ResponsesHostServer` may live in a package named +`agent-framework-foundry-hosting` (separate from `agent-framework-foundry`), +and `FoundryChatClient` may be importable from either `agent_framework.foundry` +or `agent_framework_foundry` depending on version. Do not guess; read the +generated scaffold and `pip show` the installed packages. + +Then create the rest of the app around it (lowercase-hyphen app name), per the +architecture in `references/architecture.md`: ``` / - frontend/ # Next.js + CopilotKit v2 + frontend/ # Next.js + CopilotKit app/api/copilotkit/[[...slug]]/route.ts # catch-all bridge route - components/ # useAgent / useFrontendTool / - # useRenderTool / useHumanInTheLoop + components/ # useFrontendTool / useRenderTool / + # useHumanInTheLoop / useAgent backend/ - bridge_app.py # HostedProxyAgent — forwards turns + mcp_approval_response - src/agent.py # build_hosted_agent() -> FoundryChatClient (Responses) - azure.yaml / infra/ # azd config to provision + publish the hosted agent + bridge_app.py # your bridge — forwards turns + mcp_approval_response + src/agent.py # build_hosted_agent() -> FoundryChatClient (Responses) + hosted/ # generated by `azd ai agent init` above ``` -Keep the agent-name token (`AGENT_NAME`, the CopilotKit agent id, the route, the -hosted yaml) consistent across every file. The result must run end-to-end and -pass the checks in step 3 before you customize anything. +Keep one agent-name token (`AGENT_NAME`, the hosted yaml) consistent across +`src/agent.py` and `hosted/`. The CopilotKit-facing agent registry key is a +**separate** identifier — see the CopilotKit section below; it does not have +to match `AGENT_NAME`. The result must run end-to-end and pass the checks in +step 3 before you customize anything. ## 2. Customize to the user's prompt — extension points @@ -90,92 +119,133 @@ Edit `src/agent.py` (the hosted brain via `build_hosted_agent()`): / read tool) to match your tools so the E2E exercises the chosen patterns against the real agent. -Edit `frontend/components/` (CopilotKit **v2** hooks — see `references/patterns-7.md`): +Edit `frontend/components/` (CopilotKit hooks — see `references/patterns-7.md` +and confirm exact hook/prop names against your installed `@copilotkit/react-core` +version's own bundled docs/types): - `useFrontendTool` (client tools / tool-based generative UI), `useRenderTool` - (backend tool cards), `useHumanInTheLoop` (HITL approval; keep the - `{ accepted, steps }` contract), `useAgent` (shared / predictive state). + (backend tool cards), `useHumanInTheLoop` (HITL approval — the resolved + payload shape, e.g. `{ accepted, steps }`, is a convention **you** define and + must keep consistent between the frontend `respond(...)` call and your own + backend parser; CopilotKit does not enforce or require any particular shape), + `useAgent` (shared / predictive state). **Do NOT touch** (load-bearing and proven): -- the bridge wiring in `backend/bridge_app.py` (`HostedProxyAgent` + the one - snapshot-split workaround); -- `build_hosted_agent()` (`FoundryChatClient`, Responses) in `src/agent.py`; -- the CopilotKit bridge route `frontend/app/api/copilotkit/[[...slug]]/route.ts`. +- `build_hosted_agent()` (`FoundryChatClient`, Responses) in `src/agent.py` — + this is the one non-negotiable client choice (see Load-bearing rules below); +- the HITL forwarding behavior in your bridge (whatever you name it) — every + approve/reject decision MUST reach the hosted agent as an + `mcp_approval_response`, chained via `previous_response_id`, or the gated + tool will never re-execute. ## 3. Prove it -```bash -make verify # structural: bridge wiring, FoundryChatClient, HITL contract, names, MCR base -make smoke # the BRIDGE against the REAL agent running locally via `azd ai agent - # run` — read works, action PAUSES, approve executes, reject doesn't, - # state deltas flow for the shared/predictive patterns in scope. - # Needs `az login` + a provisioned Foundry project. -``` - -Both must be green. Then `make local` (dev loop) and, in a Foundry-enabled tenant, -`make up` (azd → hosted agent) followed by a **live browser E2E** — the real DoD, -since all logic is server-side. - -## Load-bearing rules (why the template is shaped this way) - -### The bridge forwards HITL to the hosted agent (hand-rolled — and necessary) -- **Deployed:** `bridge_app.py` mounts `HostedProxyAgent` (a `SupportsAgentRun`) on - the AG-UI endpoint. It forwards each turn to the deployed Foundry hosted agent over - streaming Responses, translates the output to AG-UI (text, tool cards, - `confirm_changes`), and on HITL approve forwards an `mcp_approval_response` so the - gated tool **re-executes server-side**. `bridge_app.py` neutralises ag-ui's LOCAL - approval interception so the decision reaches the agent. -- **Why hand-rolled, not the native `add_agent_framework_fastapi_endpoint(FoundryAgent)`:** - re-verified live on the latest packages (matrix in `references/architecture.md`). - The native path needs `allow_preview=True` just to reach the hosted-agent endpoint, - and even then — with or without the ag-ui patches — HITL **approve does NOT - re-execute** the tool: the `FoundryAgent` client has no client-side - `mcp_approval_response`. The hand-rolled forwarder fills exactly that one gap. - Tracked upstream as +Write your own `verify.sh` (structural: bridge forwards HITL, `FoundryChatClient` +used, HITL contract consistent, agent name consistent, MCR base images) and +`smoke.py`/similar (the bridge against the REAL agent running locally via +`azd ai agent run` — read works, action PAUSES, approve executes, reject +doesn't). Needs `az login` + a provisioned Foundry project. Both must pass +before you consider the app done, followed by a **live browser E2E** (e.g. +Playwright) against the real local stack, and — once you deploy — the same +proof against the deployed hosted agent, since all logic is server-side. + +## Load-bearing rules (why the architecture is shaped this way) + +### The bridge must forward HITL to the hosted agent (you write this — no code ships) +- Whatever you name it, the bridge must: forward each turn to the hosted agent + over streaming Responses, translate the output to AG-UI events (text, tool + cards, an approval-request card), and on HITL approve forward an + `mcp_approval_response` (chained via `previous_response_id`) so the gated + tool **re-executes server-side**. +- **Two valid implementation strategies** — pick one and verify it live; do not + assume either works without checking against your installed packages: + 1. **Framework-native:** feed a `SupportsAgentRun` shim into + `add_agent_framework_fastapi_endpoint(...)` (from `agent-framework-ag-ui`). + This module's approval/execution machinery is built around *locally* + executing `FunctionTool` objects on a real `Agent`/`Workflow` — making a + *pure proxy* (no local tools, everything forwarded to a remote hosted + agent) satisfy its expectations is a real integration exercise with no + sample code known to exist; budget time to read + `agent_framework_ag_ui._agent_run`'s approval-resolution functions + (`_is_confirm_changes_response`, `_resolve_approval_responses`, + `_collect_approval_responses`, `_try_execute_function_calls`) before + committing to this path. + 2. **Hand-rolled (verified working end-to-end on a real local hosted agent):** + write a small FastAPI endpoint that speaks the AG-UI wire protocol + directly, using the `ag-ui-protocol` Python package's own + `ag_ui.core` event/model classes (`RunAgentInput`, `RunStartedEvent`, + `TextMessageStart/Content/EndEvent`, `ToolCallStart/Args/EndEvent`, + `ToolCallResultEvent`, `RunFinishedEvent`, `RunErrorEvent`). Translate the + hosted agent's raw Responses SSE stream + (`response.output_item.added/done`, `response.function_call_arguments.delta`, + `response.completed`, `mcp_approval_request`) into these events 1:1, and + forward the human's decision back as an `mcp_approval_response`. This is a + legitimate, fully self-contained implementation of the same design + principle — prefer it if strategy 1 is taking too long to get working. +- **Why the native `FoundryAgent` client alone can't complete hosted HITL:** the + native path needs `allow_preview=True` just to reach the hosted-agent + endpoint, and even then HITL approve does **not** re-execute the gated tool — + the `FoundryAgent` client has no client-side `mcp_approval_response`. Tracked + upstream as [microsoft/agent-framework#6652](https://github.com/microsoft/agent-framework/issues/6652); - retire the shim when it closes. -- **Local dev (`make local`/`make smoke`):** `azd ai agent run` runs the REAL agent - (`ResponsesHostServer` + `FoundryChatClient`) on your machine, connected to your - Foundry project's model; the bridge points at it in **DIRECT mode** - (`HOSTED_AGENT_DIRECT_URL`), driving the SAME `HostedProxyAgent` path as production — - no mock anywhere. + re-check whether it's closed before building a forwarder at all. +- **Local dev:** `azd ai agent run` runs the REAL agent (`ResponsesHostServer` + + `FoundryChatClient`) on your machine, connected to your Foundry project's + model; point your bridge at it directly (its local URL, e.g. + `http://localhost:8088/responses`) — no mock anywhere. - Why a bridge at all: you **cannot** point `@ag-ui/client` at a deployed hosted - agent — `ResponsesHostServer` speaks OpenAI Responses, not AG-UI. + agent — it speaks OpenAI Responses, not AG-UI. - The bridge must NOT send `x-ms-user-isolation-key` (deployed agents use Entra isolation → 400). An SSE keep-alive keeps the stream alive during silent tools. ### Client choice (load-bearing) - **`build_hosted_agent` → `FoundryChatClient` (Responses)** — the single brain, the - SAME code locally (`azd ai agent run`) and deployed (`azd up`). Required so the - hosted `mcp_approval_request`/`mcp_approval_response` re-executes the gated tool - (verified live: 100→125 deployed, 100→110 local). No mock client. The Responses - `OpenAIChatClient` / Chat Completions path 500s on hosted approve-resume — do not - use it here. - -### Framework workarounds — minimal, re-check each upgrade -`bridge_app.py` patches (both proven load-bearing by the smoke E2E): (a) route HITL -approvals to the hosted agent (not local); (b) split multi-tool snapshot messages -(CopilotKit v1 renders only `toolCalls[0]`). Re-run the native-path matrix in -`references/architecture.md` on each upgrade and delete a patch the moment the -framework closes the gap. + SAME code locally (`azd ai agent run`) and deployed. Required so the hosted + `mcp_approval_request`/`mcp_approval_response` re-executes the gated tool + (verified live on a real local agent: pending→reimbursed on approve, no + change on reject). No mock client. The Responses `OpenAIChatClient` / Chat + Completions path 500s on hosted approve-resume — do not use it here. ### The 7 AG-UI patterns -See `references/patterns-7.md`. Through the deployed/local hosted bridge: Agentic -Chat, Backend Tool Rendering, HITL (forwarded). Shared/predictive state through the -bridge is roadmap (native only when the AG-UI adapter wraps an in-process agent). +See `references/patterns-7.md`. Through the hosted bridge: Agentic Chat, +Backend Tool Rendering, HITL (forwarded) are proven working end-to-end. +Shared/predictive state through the bridge is roadmap/unproven through a pure +proxy — treat it as extra work, not a given. ### HITL contract -- A `@tool(approval_mode="always_require")` tool surfaces as a `confirm_changes` - tool call (with `function_name`, `function_arguments`, `steps`). The frontend - (`useHumanInTheLoop`) resolves it with `{ accepted: boolean, steps }` (NOT - `{ approved }`). The framework's native approval flow re-executes on accept. +- A `@tool(approval_mode="always_require")` tool surfaces as an + `mcp_approval_request` in the hosted agent's Responses stream; your bridge + translates this into whatever approval-card shape your frontend expects + (e.g. a `confirm_changes`-style tool call with `function_name`, + `function_arguments`). The **payload shape the frontend's `respond(...)` + sends and your bridge's parser expects is a convention you define** — + CopilotKit's `useHumanInTheLoop` accepts any `respond(result)` value; it does + not enforce `{ accepted, steps }` or any other shape. Pick one shape and keep + the frontend and bridge in sync; `{ accepted: boolean, steps }` is a + reasonable default. ### CopilotKit (UI layer) -- **v2 React hooks** (`@copilotkit/react-core/v2`): `useAgent`, `useAgentContext`, - `useFrontendTool`, `useRenderTool`, `useHumanInTheLoop`. -- **Bridge route:** catch-all `app/api/copilotkit/[[...slug]]/route.ts`; import - `@copilotkit/runtime/v2`; `createCopilotHonoHandler` (multi-route, not the - single-route Next endpoint); re-export POST/GET/PATCH/DELETE; - ``. Any miss → Threads 404/405/422. +- Confirm your installed `@copilotkit/react-core`/`@copilotkit/runtime` + version's exact API before wiring anything — this library moves fast and has + shipped broken/deprecated prereleases (check for npm deprecation warnings on + install). As of writing, the current stable line (`1.6x`) ships a `/v2` + subpath with hooks `useAgent`, `useAgentContext`, `useFrontendTool`, + `useRenderTool`, `useHumanInTheLoop`, and a **provider component named + `CopilotKit`** (not `CopilotKitProvider`, which is an internal subset). +- **Bridge route:** catch-all `app/api/copilotkit/[[...slug]]/route.ts`. Import + the runtime handler from `@copilotkit/runtime/v2` and check whether the + client you're pairing it with defaults to single-route (one JSON-envelope + POST to the bare `basePath`) or multi-route (REST paths like `/agent/:id/run`, + `/info`) — construct the server with the matching `mode`, or every call + 404s. Do not assume which default is current; verify empirically against a + real request. +- **Agent identifier is two separate things:** the Foundry hosted agent's own + name (`src/agent.py` `AGENT_NAME`, `hosted/agent.yaml`) is independent from + the CopilotKit-facing registry key (the key you register in + `CopilotRuntime({ agents: { : ... } })`). Hooks that omit an explicit + `agentId` typically resolve against the literal string `"default"` — so + either pass `agentId={YOUR_KEY}` explicitly everywhere, or register your + agent under the key `"default"`. These two identifiers do **not** need to + match each other. ### Containers Use **MCR** base images (`mcr.microsoft.com/devcontainers/python:3.12`, @@ -184,16 +254,16 @@ Use **MCR** base images (`mcr.microsoft.com/devcontainers/python:3.12`, ## Anti-patterns -- **Hand-rolling a NEW Responses→AG-UI proxy from scratch.** Reuse the proven - `HostedProxyAgent`. (The framework-native - `add_agent_framework_fastapi_endpoint(FoundryAgent(...))` does NOT forward HITL - approve — that is why the hand-rolled forwarder exists.) -- Putting business logic the agent should own into the bridge — the bridge is just - the framework endpoint + SSE keepalive + optional upload. +- Assuming any concrete package API name in this skill is still current without + checking your installed version — this stack's packages move fast. +- Putting business logic the agent should own into the bridge — the bridge is + only a translator + approval-forwarder + optional upload/keepalive. - Using the Responses `OpenAIChatClient` for `build_hosted_agent` — use `FoundryChatClient`. -- Resolving approval with `{ approved }` instead of `{ accepted, steps }`. -- `useSingleEndpoint` left at its default `true` (Threads/Info 404). +- Assuming CopilotKit enforces a specific HITL resolve payload shape — it + doesn't; you define and must keep the shape consistent yourself. +- Assuming the CopilotKit-facing agent key must equal the Foundry hosted + agent's own name — they are separate identifiers. - A consequential tool **without** `approval_mode="always_require"` (no HITL gate). - Docker Hub base images in any Dockerfile. - Declaring success because a server started — run the structural + smoke checks, and @@ -203,18 +273,21 @@ Use **MCR** base images (`mcr.microsoft.com/devcontainers/python:3.12`, The app is **not** done until all are true (evidence-backed): -- [ ] Structural check green (bridge mounts `HostedProxyAgent`; `build_hosted_agent` - uses `FoundryChatClient`; the bridge forwards HITL; HITL contract; names; MCR). +- [ ] Structural check green (bridge forwards HITL; `build_hosted_agent` + uses `FoundryChatClient`; HITL contract consistent between frontend and + bridge; agent name consistent within `src/agent.py`/`hosted/`; MCR base + images). - [ ] Smoke E2E green: the bridge against the REAL agent (run locally via `azd ai agent run`) shows read works; the consequential prompt PAUSES; approve executes; reject does not; and for shared/predictive patterns in scope, state flows. - [ ] `src/agent.py` has `build_hosted_agent()` (`FoundryChatClient`), ≥1 read tool and ≥1 `approval_mode="always_require"` tool. -- [ ] Agent name is consistent across `src/agent.py`, the route, the CopilotKit - provider, and the hosted yaml. +- [ ] Agent name is consistent within `src/agent.py` and `hosted/` (the + CopilotKit-facing agent key is a separate identifier — see above). - [ ] No secrets, endpoints, or app-specific hard-coding committed. -- [ ] **Live** (the deployed path drives a server-side agent): `make up` succeeds, the - bridge runs with `HOSTED_AGENT_NAME` → `HostedProxyAgent` → the deployed agent, - and a **real browser E2E** passes for the patterns in scope — HITL approve **and** +- [ ] **Live** (the deployed path drives a server-side agent): deployment succeeds, the + bridge is configured to reach the deployed hosted agent (e.g. via a + `HOSTED_AGENT_NAME`-style setting), and a **real browser E2E** passes for + the patterns in scope — HITL approve **and** reject, plus any shared/predictive state round-trip and generative-UI cards. diff --git a/skills/foundry-hosted-agent-copilotkit/references/architecture.md b/skills/foundry-hosted-agent-copilotkit/references/architecture.md index c4752d35c..9dce28521 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/architecture.md +++ b/skills/foundry-hosted-agent-copilotkit/references/architecture.md @@ -4,27 +4,40 @@ with a CopilotKit UI showing rich generative UI — tool-render cards, human-in-the- loop approval, shared/predictive state. +> **No template ships with this skill.** Every symbol below (`HostedProxyAgent`, +> `bridge_app.py`, etc.) is illustrative naming for a design you implement +> yourself, verified live once (see `REVIEW_NOTES`-style evidence in your own +> build), not a package or file you can import. Bootstrap `hosted/` with +> `azd ai agent init -m ` (`azd ai agent sample list` to discover +> manifests) rather than hand-writing it — this generates the currently-correct +> `main.py`/`agent.yaml`/`azure.yaml`/`Dockerfile`/`infra/` for your installed +> `azd` Foundry extension version. + **Why a bridge at all:** you **cannot** point `@ag-ui/client` at a deployed hosted agent — its endpoint speaks the OpenAI **Responses** protocol, not AG-UI. AND the framework's *native* path (`add_agent_framework_fastapi_endpoint(FoundryAgent(...))`) resolves the HITL `confirm_changes` **locally** and never forwards the approval, so the gated tool **does not re-execute** (verified live). So the bridge is a small -hand-rolled forwarder: it translates Responses→AG-UI AND forwards the HITL decision -as an `mcp_approval_response`, which re-executes the tool server-side. +forwarder: it translates Responses→AG-UI AND forwards the HITL decision +as an `mcp_approval_response`, which re-executes the tool server-side. You can +implement this bridge either by wrapping a `SupportsAgentRun` shim and feeding it +into `add_agent_framework_fastapi_endpoint` (nontrivial for a pure proxy — see +below), or by hand-rolling a FastAPI endpoint that emits AG-UI events directly +using the `ag-ui-protocol` package's `ag_ui.core` classes (verified working +end-to-end against a real local hosted agent). ``` - Browser — Next.js + CopilotKit (v2 hooks) + Browser — Next.js + CopilotKit useAgent / useFrontendTool / useRenderTool / useHumanInTheLoop - app/api/copilotkit/[[...slug]]/route.ts (CopilotSseRuntime + HttpAgent) + app/api/copilotkit/[[...slug]]/route.ts (CopilotKit runtime handler + HttpAgent) │ AG-UI / SSE ▼ - BRIDGE (Container App — backend/bridge_app.py) - LOCAL/DEPLOYED: HostedProxyAgent (SupportsAgentRun) — forwards each turn to the - hosted agent (hosted_client, streaming Responses), translates → AG-UI - (text, tool cards, confirm_changes), and forwards mcp_approval_response - on approve (bridge_app patches neutralise ag-ui's local interception). - LOCAL DEV: `azd ai agent run` runs the agent on your machine; bridge → DIRECT - mode (HOSTED_AGENT_DIRECT_URL). DEPLOYED: bridge → platform mode. No mock. + BRIDGE (Container App — backend/, you write this) + LOCAL/DEPLOYED: forwards each turn to the hosted agent (streaming Responses), + translates → AG-UI (text, tool cards, an approval-request card), and + forwards mcp_approval_response on approve. + LOCAL DEV: `azd ai agent run` runs the agent on your machine; point the bridge + at its local URL. DEPLOYED: bridge points at the platform endpoint. No mock. │ POST .../agents//endpoint/protocols/openai/responses (stream) ▼ FOUNDRY HOSTED AGENT (the brain — azd → host: azure.ai.agent) @@ -32,42 +45,41 @@ as an `mcp_approval_response`, which re-executes the tool server-side. ALL @tools + @tool(approval_mode="always_require") HITL + history server-side ``` -## Validated live (deployed agent agentic-copilot-foundry, swec-proj-default) +## Validated live (a real local hosted agent via `azd ai agent run`) - Read tool → runs server-side; tool-render card in AG-UI. -- HITL trigger → `mcp_approval_request` → bridge surfaces `confirm_changes` (pause). +- HITL trigger → `mcp_approval_request` → bridge surfaces an approval card (pause). - **Approve → bridge sends `mcp_approval_response{approve:true}` → tool re-executes - server-side, state changes (100→125).** No "No tool output found". + server-side, in-memory state changes.** No "No tool output found". - Reject → `approve:false` → tool does NOT execute (state unchanged). -- Two gotchas found live: the bridge must NOT send `x-ms-user-isolation-key` - (deployed agents use Entra isolation → 400); and `build_hosted_agent` MUST use - `FoundryChatClient` (Chat Completions 500s on hosted approve-resume). +- Gotchas found live: the bridge must NOT send `x-ms-user-isolation-key` + (deployed agents use Entra isolation → 400); `build_hosted_agent` MUST use + `FoundryChatClient` (Chat Completions 500s on hosted approve-resume); and a + shared/sandboxed `az` CLI session can silently drift to a different default + subscription mid-session — re-check `az account show` before assuming a 403 + is a code bug. -## Why the bridge is the MINIMUM (native-path test matrix) +## Why a bridge is necessary (native-path test matrix) -Is the hand-rolled bridge over-engineering? We tested every alternative against the -real agent on the **latest** packages (agent-framework-core 1.9.0, -agent-framework-foundry 1.8.2, agent-framework-ag-ui 1.0.0rc5). `make smoke` = 15 -assertions (read, HITL pause, approve re-executes, reject, C9, C10). +Is a hand-rolled/forwarding bridge over-engineering? Prior testing against a real +agent (packages: agent-framework-core 1.9.0, agent-framework-foundry 1.8.2, +agent-framework-ag-ui 1.0.0rc5) found: | Configuration | Result | | --- | --- | -| **Bridge (HostedProxyAgent + 2 patches)** | **15/15** ✓ | -| Bridge, HITL approval routing patch removed | approve does NOT change state ✗ — patch REQUIRED | -| Bridge, `DISABLE_C9_SPLIT=1` | C9 fails (snapshot lumps multiple tool_calls) ✗ — split REQUIRED | +| **Bridge that forwards HITL + splits multi-tool snapshots** | all assertions pass ✓ | +| Bridge, HITL approval routing removed | approve does NOT change state ✗ — routing REQUIRED | +| Bridge, multi-tool snapshot split disabled | multi-tool-call assertion fails ✗ — split REQUIRED (re-check if your CopilotKit version still needs this; the newest client renders all tool calls) | | Native `add_agent_framework_fastapi_endpoint(FoundryAgent(...))` | 400 "Hosted agents can only be called through the agent endpoint" ✗ | -| Native + `allow_preview=True` | surfaces the approval, but **approve does NOT re-execute** (state unchanged); C9 fails ✗ | -| Native + `allow_preview=True` + the 2 patches | **still** approve does NOT re-execute ✗ | +| Native + `allow_preview=True` | surfaces the approval, but **approve does NOT re-execute** (state unchanged) ✗ | +| Native + `allow_preview=True` + local patches to the ag-ui approval-resolution functions | **still** approve does NOT re-execute ✗ | **Conclusion:** the native `FoundryAgent` client has no client-side `mcp_approval_response` — it cannot complete hosted HITL no matter how it's -configured. We still use `agent-framework-ag-ui` (`add_agent_framework_fastapi_endpoint`) -for the AG-UI translation; we just feed it a `SupportsAgentRun` shim -(`HostedProxyAgent`) that forwards the approval, plus two ag-ui patches `make smoke` -proves are load-bearing. Nothing else is hand-rolled. **Tracked upstream as +configured. **Tracked upstream as [microsoft/agent-framework#6652](https://github.com/microsoft/agent-framework/issues/6652)** — -re-run this matrix on each package bump and retire the shim + the HITL-routing patch -the moment #6652 closes (the native `FoundryAgent` path then suffices). +re-run this matrix on each package bump; if #6652 is closed, re-test whether the +native `FoundryAgent` path now suffices before building any forwarder at all. ## Client choice (the load-bearing rule) @@ -76,12 +88,11 @@ the moment #6652 closes (the native `FoundryAgent` path then suffices). the gated tool. Chat Completions 500s on resume here. - **Local dev → `azd ai agent run`**: the Foundry extension runs the REAL agent (`ResponsesHostServer` + `FoundryChatClient`) on your machine, connected to your - Foundry project's model. `make smoke`/`make local` point the bridge at it in DIRECT - mode (`HOSTED_AGENT_DIRECT_URL` → POST `/responses` with `previous_response_id` - chaining), so it drives the SAME `HostedProxyAgent` path as production. No mock — - needs `az login` + a provisioned project (`make up` once). + Foundry project's model, at a local URL (e.g. `http://localhost:8088/responses`). + Point your bridge at it directly — same bridge code, no mock — needs `az login` + + a provisioned project. -## File map +## File map (a reasonable layout — adapt as needed) ``` / @@ -89,30 +100,27 @@ the moment #6652 closes (the native `FoundryAgent` path then suffices). │ └── agent.py ONE agent. build_hosted_agent() → FoundryChatClient │ (the single brain — same code local + deployed). Read tools │ + ≥1 @tool(approval_mode="always_require"). -├── backend/ THE BRIDGE (deployed Container App). -│ ├── bridge_app.py AG-UI endpoint → HostedProxyAgent (DIRECT local / -│ │ platform deployed). + SSE keepalive + optional API key. -│ ├── hosted_proxy.py HostedProxyAgent: forward turns + translate Responses → -│ │ AG-UI; surface confirm_changes; forward mcp_approval_response. -│ ├── hosted_client.py streaming Responses driver: platform (conversation + -│ │ agent_session_id, keyless) OR DIRECT (local azd ai agent run). -│ ├── requirements.txt bridge deps only (httpx pin; no foundry/openai — runs no model). -│ └── Dockerfile MCR base; deploys uvicorn bridge_app:app. -├── hosted/ azd → Foundry HOSTED agent (Responses) — the deployed brain. +├── backend/ THE BRIDGE (deployed Container App) — you write this. +│ └── bridge_app.py (+ helpers) AG-UI endpoint that forwards turns + translates +│ Responses → AG-UI; surfaces an approval card; forwards +│ mcp_approval_response. + SSE keepalive + optional API key. +├── hosted/ Bootstrap via `azd ai agent init` — azd → Foundry HOSTED +│ │ agent (Responses) — the deployed brain. │ ├── azure.yaml host: azure.ai.agent; azure.ai.agents pinned; context=root. -│ └── responses/ main.py = ResponsesHostServer(build_hosted_agent()), … -├── frontend/ Next.js + CopilotKit v2 (useAgent/useFrontendTool/ +│ └── responses/ main.py = ResponsesHostServer(build_hosted_agent()) — confirm +│ which package ships `ResponsesHostServer` for your installed +│ version (may be a separate `*-foundry-hosting` package). +├── frontend/ Next.js + CopilotKit (useAgent/useFrontendTool/ │ useRenderTool/useHumanInTheLoop). -├── scripts/ verify.sh (structural), smoke.py (E2E vs the real local agent), -│ lib-agentrun.sh (azd ai agent run + bridge DIRECT). -└── Makefile(+.targets) preflight / local / verify / smoke / up / deploy / clean. +└── scripts/ verify.sh (structural), smoke.py (E2E vs the real local agent), + a browser E2E script (e.g. Playwright). ``` ## Proving it (Definition of Done) -`azd` SUCCESS / a server starting is **not** proof. Done = `make verify` + -`make smoke` (the bridge against the REAL agent run locally via `azd ai agent run`) +`azd` SUCCESS / a server starting is **not** proof. Done = your structural check + +your smoke test (the bridge against the REAL agent run locally via `azd ai agent run`) green, AND — because the deployed path drives a server-side agent — a **live** -browser E2E: deploy with `azd`, run the bridge with `HOSTED_AGENT_NAME` set, and +browser E2E: deploy, run the bridge pointed at the deployed agent, and confirm read + HITL approve (tool re-executes, state changes) **and** reject (no change) in a real browser. diff --git a/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md b/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md index 1386e0f76..91c30f304 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md +++ b/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md @@ -5,13 +5,36 @@ Responses) is published as an **Azure AI Foundry hosted agent**. This runs from `hosted/` and needs an Azure subscription + a Foundry-enabled tenant. The SAME `build_hosted_agent()` runs locally for development via `azd ai agent run`. +## Bootstrap `hosted/` — use the real scaffolder, don't hand-write it + +```bash +azd ai agent sample list # discover starter manifests +azd ai agent init -m # scaffolds hosted/: main.py, agent.yaml, + # azure.yaml, requirements.txt, Dockerfile, infra/ +``` + +Read the generated files rather than assuming package/class names from memory: +- `ResponsesHostServer` may live in a package separate from `agent-framework-foundry` + (e.g. an `agent-framework-foundry-hosting`-style package) — check the generated + `requirements.txt` and `main.py`'s imports. +- `FoundryChatClient` may be importable from `agent_framework.foundry` or from + `agent_framework_foundry` depending on version — match whichever the generated + scaffold uses. +- The generated `requirements.txt` may need an explicit `mcp` package pin (the + hosting package can import `from mcp import McpError` without pulling `mcp` + in transitively on a remote build) — verify by actually building/running, not + by assumption. + ## Prerequisites - `az login` into the **tenant that owns the Foundry project** (a 403 on `Microsoft.MachineLearningServices/workspaces/agents/action` means the wrong - tenant). + tenant — and in a shared/sandboxed environment, re-check `az account show` + before assuming this is a code bug, since the default subscription can drift + mid-session). - The azd `azure.ai.agents` extension: - `azd extension install azure.ai.agents` (the template pins `>=0.1.0-preview`). + `azd extension install azure.ai.agents` (pin a version compatible with your + scaffold). - An `azd` environment with a region/model selected. ## Deploy @@ -21,40 +44,45 @@ cd hosted azd env new # first time azd env set AZURE_LOCATION # (model deployment name comes from hosted/azure.yaml `deployments` + agent.yaml) -make up # == azd up : provision + remote-build the image + publish the agent +azd up # provision + remote-build the image + publish the agent ``` -`make up` builds the image with **remote build** (so no local Docker needed) from -the template root context (so the shared `src/agent.py` is included), provisions -the model deployment declared in `hosted/azure.yaml`, and publishes the hosted -agent described by `agent.yaml` / `agent.manifest.yaml`. +`azd up` builds the image with **remote build** (so no local Docker needed) from +the appropriate build context (make sure the shared `src/agent.py` is included in +that context if it lives outside `hosted/`), provisions the model deployment +declared in `hosted/azure.yaml`, and publishes the hosted agent described by +`agent.yaml` / `agent.manifest.yaml`. ## Gotchas (also in troubleshooting.md) -- **Docker Hub rate limit** on build → the Dockerfiles use `mcr.microsoft.com` - base images. Keep it that way. +- **Docker Hub rate limit** on build → use `mcr.microsoft.com` base images in + every Dockerfile. - **helloworld placeholder deployed** → you ran `azd provision` only; run - `make up` (provision + deploy). + `azd up` (provision + deploy). - **401 "audience is incorrect"** at runtime → the agent must request the - `https://ai.azure.com/.default` audience (the template's `build_hosted_agent` - already does). + `https://ai.azure.com/.default` audience. +- **`ModuleNotFoundError: No module named 'mcp'`** on a hosted container that + crashes at boot (surfaces as HTTP 424 `session_not_ready` on invoke) → pin + `mcp` explicitly in `requirements.txt`, since it can be an undeclared + transitive dependency of the hosting package. ## Prove the hosted agent (live) Deployment SUCCESS is not proof. Run the agent (e.g. via the VS Code Foundry toolkit `azd ai agent run`, or the Foundry portal playground) and confirm that **one consequential action pauses for human approval** before executing — the -same HITL contract you verified locally with `make smoke`. +same HITL contract you verified locally with your smoke test. ## Connecting a frontend to the hosted agent (the light bridge) In production the chat UI does NOT run the agent — it talks to the deployed Foundry -hosted agent through the **light bridge** (`backend/bridge_app.py`, the -`backend/Dockerfile` default). Deploy the bridge as a Container App and point the -CopilotKit runtime's `AG_UI_BACKEND_URL` at it; set `FOUNDRY_PROJECT_ENDPOINT` + -`HOSTED_AGENT_NAME` (the deployed agent) on the bridge so `HostedProxyAgent` can reach -it keyless. Run a single replica (per-thread conversation/session cache is -in-memory) or externalise the cache. The CopilotKit `route.ts` bridge is unchanged. - -For the local dev loop, `make local` runs the SAME agent locally via -`azd ai agent run` and points the bridge (`bridge_app:app`) at it — no mock. +hosted agent through the **light bridge** you wrote (`backend/`, a +`mcr.microsoft.com`-based Dockerfile). Deploy the bridge as a Container App and +point the CopilotKit runtime's backend URL at it; set whatever settings your +bridge needs to reach the deployed agent keyless (e.g. a Foundry project +endpoint + the hosted agent's name) so it can forward turns and +`mcp_approval_response`s without a stored key. Run a single replica if your +bridge keeps any per-thread cache in-memory, or externalise that cache. + +For the local dev loop, run the SAME agent locally via `azd ai agent run` and +point your bridge at its local URL — no mock. diff --git a/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md b/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md index b20fd10fb..93dfe1a6e 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md +++ b/skills/foundry-hosted-agent-copilotkit/references/patterns-7.md @@ -1,73 +1,90 @@ # The 7 AG-UI patterns on the hosted-agent + light-bridge stack These are the AG-UI dojo "Microsoft Agent Framework Python" feature patterns, -adapted to our standard (intelligence in the Foundry HOSTED agent; a light bridge; -CopilotKit **v2** UI hooks). Canonical source: backend agents from +adapted to our standard (intelligence in the Foundry HOSTED agent; a light bridge +you write; CopilotKit hooks). Canonical source: backend agents from `microsoft/agent-framework` (`python/packages/ag-ui/agent_framework_ag_ui_examples/agents/*`) and the v2 frontend pages from `ag-ui-protocol/ag-ui` (`apps/dojo/src/app/[integrationId]/feature/(v2)/*`). -CopilotKit **v2** hooks (`@copilotkit/react-core/v2`): +CopilotKit hooks (confirm the exact import path — e.g. +`@copilotkit/react-core/v2` — against your installed version): `useAgent`, `useAgentContext`, `useFrontendTool`, `useRenderTool`, `useHumanInTheLoop`. -| # | Pattern | Hosted-agent side | CopilotKit v2 UI | Through the bridge | +| # | Pattern | Hosted-agent side | CopilotKit UI | Through the bridge | |---|---|---|---|---| | 1 | Agentic Chat (frontend tools) | plain `Agent` (no server tool needed) | `useFrontendTool({name,parameters,handler})` (runs in browser) + `useAgentContext` | native — client tool, agent just emits the tool call | | 2 | Backend Tool Rendering | `@tool` (executes server-side) | `useRenderTool({name,parameters,render})` | native — `function_call`/`function_call_output` forwarded | -| 3 | HITL approval | `@tool(approval_mode="always_require")` | `useHumanInTheLoop({name,render})` → `respond({accepted, steps})` | native function-approval; surfaces as `confirm_changes` | +| 3 | HITL approval | `@tool(approval_mode="always_require")` | `useHumanInTheLoop({name,render})` → `respond()` | bridge surfaces an approval-request card and forwards the decision as `mcp_approval_response` | | 5 | Tool-Based Generative UI | `FunctionTool(func=None)` (declaration-only) + `tool_choice="required"` | `useFrontendTool({name,handler,render,followUp:false})` | native — stream tool-call args to the renderer | -| 4 | Agentic Generative UI | `predict_state_config` + `require_confirmation=False`; stream step status via tool args | `useAgent({updates:[OnStateChanged]})` → `agent.state` | **bridge synthesizes** StateDelta/Snapshot from arg-deltas | -| 6 | Shared State | `AgentFrameworkAgent(state_schema, predict_state_config, require_confirmation=False)` | `useAgent` + `agent.setState()` | **bridge synthesizes** state + **forwards** `setState` → hosted input | -| 7 | Predictive State Updates | same as #6 but `require_confirmation=True` (default) + `@tool(approval_mode="always_require")` | `useAgent` + `useHumanInTheLoop` (confirm/reject) | synthesized streaming state + HITL confirm | +| 4 | Agentic Generative UI | `predict_state_config` + `require_confirmation=False`; stream step status via tool args | `useAgent({updates:[OnStateChanged]})` → `agent.state` | roadmap through a pure proxy bridge — see below | +| 6 | Shared State | `state_schema` + `predict_state_config`, `require_confirmation=False` | `useAgent` + `agent.setState()` | roadmap through a pure proxy bridge — see below | +| 7 | Predictive State Updates | same as #6 but `require_confirmation=True` (default) + `@tool(approval_mode="always_require")` | `useAgent` + `useHumanInTheLoop` (confirm/reject) | roadmap through a pure proxy bridge — see below | ## How it works on this stack - **Native adapter (reference):** `add_agent_framework_fastapi_endpoint(agent)` - natively emits all AG-UI events — text, TOOL_CALL_* cards, function-approval HITL, - and StateSnapshot/Delta (via `state_schema`+`predict_state_config`) — *when it - wraps a plain in-process `Agent`*. The templates don't run the agent in-process; - they keep all logic in the Foundry hosted agent and reach it through the bridge. -- **Deployed (hosted agent):** the bridge is `HostedProxyAgent`, NOT the native - `add_agent_framework_fastapi_endpoint(FoundryAgent(...))`. The native FoundryAgent - path translates read/cards/HITL-*pause*, but on HITL **approve it does NOT - re-execute** the hosted tool (it resolves `confirm_changes` locally; the Foundry - client has no `mcp_approval_response` forwarding — verified live). `HostedProxyAgent` - forwards `mcp_approval_response` to the hosted agent so the gated tool re-executes - server-side. Use it for any deployed app with HITL. + (from `agent-framework-ag-ui`) natively emits all AG-UI events — text, + TOOL_CALL_* cards, function-approval HITL, and StateSnapshot/Delta (via + `state_schema`+`predict_state_config`) — *when it wraps a plain in-process + `Agent`*. This skill's standard doesn't run the agent in-process; it keeps + all logic in the Foundry hosted agent and reaches it through a bridge you + write, so this native behavior does not directly apply — see below. +- **Deployed/local (hosted agent):** the bridge is code you write (illustrated + here as `HostedProxyAgent`, not a real importable class), NOT the native + `add_agent_framework_fastapi_endpoint(FoundryAgent(...))`. The native + `FoundryAgent` path can translate read/cards/HITL-*pause*, but on HITL + **approve it does NOT re-execute** the hosted tool (it resolves the approval + locally; the `FoundryAgent` client has no `mcp_approval_response` forwarding — + verified live). Your bridge must forward `mcp_approval_response` to the + hosted agent so the gated tool re-executes server-side. -| # | Pattern | Hosted-agent side | CopilotKit v2 UI | Through the bridge | +| # | Pattern | Hosted-agent side | CopilotKit UI | Through the bridge | |---|---|---|---|---| | 1 | Agentic Chat | plain Agent | `useFrontendTool` | native | -| 2 | Backend Tool Rendering | `@tool` | `useRenderTool` | HostedProxyAgent forwards function_call/result | -| 3 | HITL approval | `@tool(approval_mode="always_require")` | `useHumanInTheLoop` → `{accepted, steps}` | bridge forwards mcp_approval_response (re-executes) | +| 2 | Backend Tool Rendering | `@tool` | `useRenderTool` | bridge forwards function_call/result | +| 3 | HITL approval | `@tool(approval_mode="always_require")` | `useHumanInTheLoop` → your chosen decision shape | bridge forwards mcp_approval_response (re-executes) — verified end-to-end on a real local hosted agent | | 5 | Tool-Based Generative UI | `FunctionTool(func=None)` | `useFrontendTool` render | stream tool-call args | -| 4 / 6 / 7 | Agentic Generative / Shared / Predictive State | `state_schema` + `predict_state_config` | `useAgent` + `setState` | bridge relays text/tool-arg deltas; state synthesis through the deployed bridge is roadmap (native only when the adapter wraps an in-process agent) | +| 4 / 6 / 7 | Agentic Generative / Shared / Predictive State | `state_schema` + `predict_state_config` | `useAgent` + `setState` | bridge would need to relay text/tool-arg deltas as state events and forward `setState`; this is roadmap/unverified through a pure proxy bridge (native only when the adapter wraps an in-process agent) | ## HITL contract -The gated tool surfaces as `confirm_changes` (with `function_name`, -`function_arguments`, `steps`); the UI (`useHumanInTheLoop`) resolves -`{ accepted: boolean, steps }`. Deployed: Accept → `mcp_approval_response{approve:true}` -(tool re-executes server-side), Reject → `approve:false`. +The gated tool surfaces in the hosted agent's Responses stream as an +`mcp_approval_request`. Your bridge translates this into whatever approval-card +event shape your frontend expects — `useHumanInTheLoop`'s `respond(result)` +accepts any value; the specific shape (e.g. `{ accepted: boolean, steps }`) is +a convention **you** define and must keep consistent between the frontend +`respond(...)` call and your bridge's parser — CopilotKit does not enforce or +require a particular shape. Once resolved: Accept → your bridge sends +`mcp_approval_response{approve:true}` (tool re-executes server-side), Reject → +`approve:false` (tool does not execute). -## Framework workarounds (minimal; re-check each upgrade) +## Framework workarounds (re-check each upgrade against your own versions) -`bridge_app.py` patches: (a) neutralise ag-ui's local approval interception -so approvals reach the hosted agent; (b) split multi-tool snapshot messages -(CopilotKit v1 renders only `toolCalls[0]`; set `DISABLE_C9_SPLIT=1` on a v2 frontend). -The bridge must NOT send `x-ms-user-isolation-key` (deployed agents use Entra -isolation → 400). +Depending on which bridge implementation strategy you pick (see +`architecture.md`): if you feed a `SupportsAgentRun` shim into +`add_agent_framework_fastapi_endpoint`, you may need to patch its local +approval-interception behavior so decisions actually reach your forwarder, and +you may need to split multi-tool-call snapshot messages if your CopilotKit +version only renders the first tool call in a message (re-verify this against +your actual installed CopilotKit version — do not assume it's still true). If +you hand-roll the AG-UI translation directly (see `architecture.md` strategy +2), these specific patches don't apply, since you control the event emission +yourself. Either way, your bridge must NOT send `x-ms-user-isolation-key` +(deployed agents use Entra isolation → 400). -## Roadmap: shared / predictive state through the DEPLOYED bridge +## Roadmap: shared / predictive state through the bridge Shared State, Predictive State, and Agentic Generative UI are emitted **natively by the AG-UI adapter when it wraps an in-process `Agent`** (via `state_schema` + -`predict_state_config`). Through the DEPLOYED bridge they are **not yet wired**: the -hosted Responses stream would need `HostedProxyAgent` to relay -`response.function_call_arguments.delta` as growing tool-call args, and to forward -`useAgent.setState` (RunInput.state) to the hosted agent. The plumbing is understood -(the AG-UI adapter synthesises StateDelta/Snapshot from streaming tool-call args) but -is left as a follow-up — current templates ship the validated read + tool-render + -HITL through the deployed bridge. +`predict_state_config`). Through a hosted-agent-first bridge they are **not yet +proven working**: the bridge would need to relay +`response.function_call_arguments.delta` as growing tool-call args, and to +forward `useAgent.setState` (RunInput.state) to the hosted agent. The plumbing +is understood (the AG-UI adapter synthesises StateDelta/Snapshot from +streaming tool-call args) but is left as a follow-up — treat patterns #4/#6/#7 +as out of scope unless you build and verify this yourself; the validated, +proven-live scope through the bridge is read + tool-render + HITL (patterns +#1/#2/#3). diff --git a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md index 6bf4f3f0a..d480441e0 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md +++ b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md @@ -1,61 +1,72 @@ # Troubleshooting — known traps → symptom → fix -Each row is a real failure mode encoded as a check in `scripts/verify.sh` or -`scripts/smoke.py`. Fix the cause; do not work around it. +Each row is a real failure mode worth encoding as a check in your own +`verify.sh`/`smoke.py` once you build them. Fix the cause; do not work around +it. Concrete package/API names below were true when last verified live against +a real hosted agent — this stack's packages move fast, so confirm each against +what you actually have installed before trusting it. + +## Local environment + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `python3 -m venv` fails (`ensurepip is not available`) or `pip`/`apt-get install python3-venv` needs sudo you don't have | sandboxed/managed dev environment without system Python tooling | check for `uv` (`which uv`) and use `uv venv` / `uv pip install` instead — it doesn't need `ensurepip` or root. | ## HITL / approval | Symptom | Cause | Fix | | --- | --- | --- | -| Approve a tool → `RUN_ERROR` 400/500 **"No tool output found for function call"** | the hosted agent uses Chat Completions (`OpenAIChatClient`/`OpenAIChatCompletionClient`) instead of Responses | `build_hosted_agent` MUST use `FoundryChatClient` (Responses) so the hosted `mcp_approval_response` re-executes the tool. `verify.sh` checks for `FoundryChatClient`. | -| Approval card never appears | `confirm_changes` not registered via the v2 `useHumanInTheLoop` hook | Keep the `confirm_changes` `useHumanInTheLoop({ name: "confirm_changes", ... })` from the template verbatim. | -| Clicking Approve does nothing / tool never runs | Resolving with `{ approved }` | Resolve with `{ accepted: boolean, steps }`. Backend detection is `"accepted" in parsed`. | -| Approve works once, next message 400s with orphaned `call_…` | (pre-rc5) stale approval payload re-sent | Handled NATIVELY on agent-framework-ag-ui rc5 — do not re-add the old hand-rolled patches. | -| Consequential tool runs WITHOUT asking | Tool missing `approval_mode="always_require"` | Decorate the consequential tool. `verify.sh` requires at least one. | +| Approve a tool → `RUN_ERROR` 400/500 **"No tool output found for function call"** | the hosted agent uses Chat Completions (`OpenAIChatClient`/`OpenAIChatCompletionClient`) instead of Responses | `build_hosted_agent` MUST use `FoundryChatClient` (Responses) so the hosted `mcp_approval_response` re-executes the tool. | +| Approval card never appears | the HITL hook isn't registered for the same tool name your bridge surfaces the approval request under | keep the frontend's `useHumanInTheLoop({ name: "", ... })` registration in sync with whatever tool-call name your bridge emits for the approval-request event. | +| Clicking Approve does nothing / tool never runs | assumed a specific resolve payload shape is framework-enforced | it isn't (see the CopilotKit bridge section below) — make sure your bridge's parser actually matches what the frontend's `respond(...)` sends. | +| Approve works once, next message 400s with an orphaned tool-call id | stale/replayed approval payload re-sent to the hosted agent | don't replay raw history to the hosted agent; derive the next turn's input from the latest user text or the pending `mcp_approval_response`, chained via `previous_response_id`. | +| Consequential tool runs WITHOUT asking | Tool missing `approval_mode="always_require"` | Decorate the consequential tool; check for at least one in your structural check. | -## AG-UI rendering +## AG-UI rendering (bridge-level) | Symptom | Cause | Fix | | --- | --- | --- | -| HITL approve doesn't re-execute the tool server-side (state unchanged after approve) | ag-ui resolves `confirm_changes` **locally** before the proxy sees it | `bridge_app.py` neutralises `_is_confirm_changes_response` + `_resolve_approval_responses`, so the decision reaches `HostedProxyAgent`, which forwards `mcp_approval_response` to the hosted agent. **Proven load-bearing: disabling it → approve doesn't change state.** | -| Approval/tool card vanishes at RUN_FINISHED when a turn made several tool calls | ag-ui's snapshot builder lumps multiple tool_calls into one assistant message; CopilotKit **v1** renders only `toolCalls[0]` | `bridge_app.py` splits multi-tool snapshot messages (`_build_messages_snapshot`); `smoke.py` C9 guards it. **Proven load-bearing: `DISABLE_C9_SPLIT=1` fails C9.** (v2 renders all tool calls, but the split keeps the snapshot correct for both frontends.) | -| Replayed history 400s / orphaned tool call (C10) | raw AG-UI history replayed to the hosted agent | the proxy does **not** replay raw history — `_find_approval_decision` / `_latest_user_text` derive the turn input (latest user text, or an `mcp_approval_response`). `smoke.py` C10 asserts no error. No `normalize_*` patch needed. | +| HITL approve doesn't re-execute the tool server-side (state unchanged after approve) | your bridge isn't forwarding the human decision as an `mcp_approval_response` to the hosted agent | make sure the approve/reject decision reaches your bridge's hosted-agent-forwarding code path, not just a local UI state change. This is the single most important behavior to verify live. | +| Approval/tool card vanishes when a turn made several tool calls | your AG-UI translation lumped multiple tool_calls into one assistant message and your CopilotKit version only renders the first one | either split multi-tool messages in your bridge's translation, or confirm your CopilotKit version renders all tool calls in one message (current versions generally do — re-verify live rather than assuming either way). | +| Replayed history 400s / orphaned tool call | raw AG-UI history replayed to the hosted agent | do **not** replay raw history — derive the turn input (latest user text, or a pending `mcp_approval_response`) instead. | ## CopilotKit bridge | Symptom | Cause | Fix | | --- | --- | --- | -| `GET /api/copilotkit/threads` 404 | missing catch-all dir | route lives in `app/api/copilotkit/[[...slug]]/route.ts`. | -| Threads 405 on every request | single-route endpoint | use `createCopilotHonoHandler` (multi-route) and re-export POST/GET/PATCH/DELETE. | -| Threads panel 422 "Missing CopilotKitIntelligence configuration" | lib `CopilotRuntime` wraps the runner in `TelemetryAgentRunner` | use the v2 `CopilotSseRuntime` with a raw `InMemoryAgentRunner`. | -| "Agent `` not found" / Info 404 | `useSingleEndpoint` defaulted to `true` | set ``. | -| `` doesn't match | name drift | keep `AGENT_NAME` == route const == provider == hosted yaml. `verify.sh` checks it. | -| `next build` type error: `HttpAgent` missing `pendingInterrupts` | `@ag-ui/client` older than the version CopilotKit resolves | pin `@ag-ui/client` to the version `@copilotkit/runtime` depends on (e.g. `0.0.56`). | -| Browser console: "Failed to execute 'fetch' on 'Window': Illegal invocation" (`agent_run_failed_event`); the agent never runs | CopilotKit v2 (`ɵcreateThreadStore` + `@ag-ui/client` HttpAgent) captures the global `fetch` as a bare reference and calls it with the wrong `this`; `CopilotKitCore` exposes no `fetch` option | bind the global fetch to `window` before any module loads — an inline `` script in `app/layout.tsx`: `if(!window.fetch.__bound){var f=window.fetch.bind(window);f.__bound=true;window.fetch=f;}`. `verify.sh` checks it; proven in a real browser (control reproduces, fix → 0 errors). | +| `GET /api/copilotkit/threads` 404, or every call to a bare `/api/copilotkit` 404s | route file/mode mismatch | route lives in `app/api/copilotkit/[[...slug]]/route.ts` (optional catch-all — required either way, since different client/server mode combinations post to different sub-paths). Confirm whether your CopilotKit client defaults to single-route (one JSON-envelope POST to the bare basePath) or multi-route (REST paths like `/agent/:id/run`, `/info`) for YOUR installed version, and construct the runtime handler with the matching `mode` — do not assume either default without checking a real request/response. | +| Threads panel 422 / agent/config errors | runtime wrapper mismatch for your version | check your installed `@copilotkit/runtime`'s current recommended handler factory (names have changed across versions — e.g. `createCopilotRuntimeHandler`, `createCopilotHonoHandler`, `createCopilotEndpoint`); read its own docs/types rather than trusting a remembered name. | +| "Agent `` not found" | omitted `agentId` resolves to the literal string `"default"`, which doesn't match your `CopilotRuntime({ agents: {...} })` key | either pass `agentId={YOUR_KEY}` explicitly on every hook/component, or register your agent under the key `"default"`. This CopilotKit-facing key is a separate identifier from your Foundry hosted agent's own name — they don't have to match. | +| `` (or `CopilotKitProvider`) import/prop errors | using an internal/renamed provider component | current guidance (check your version): use the `CopilotKit` provider component, not `CopilotKitProvider` (an internal subset). | +| `next build` type error: `HttpAgent` missing an expected field | `@ag-ui/client` version mismatch with what `@copilotkit/runtime` expects | pin `@ag-ui/client` to the version your `@copilotkit/runtime` resolves internally. | +| `npm install` prints a deprecation warning on a CopilotKit package (e.g. "this version was mistakenly generated by CI") | installed a broken/accidental prerelease (this has happened on `@copilotkit/*` npm tags before) | pin to the current actively-maintained stable line instead; re-check npm's install output for deprecation warnings whenever you add/upgrade a CopilotKit package. | +| Browser console: "Failed to execute 'fetch' on 'Window': Illegal invocation" (`agent_run_failed_event`); the agent never runs | CopilotKit's thread store + `@ag-ui/client` `HttpAgent` captures the global `fetch` as a bare reference and calls it with the wrong `this` | bind the global fetch to `window` before any module loads — an inline `` script in `app/layout.tsx`: `if(!window.fetch.__bound){var f=window.fetch.bind(window);f.__bound=true;window.fetch=f;}`. Verify in a real browser (control reproduces, fix → 0 errors). | +| `useHumanInTheLoop`'s `respond(...)` payload isn't recognized by your bridge | assumed CopilotKit enforces a specific resolve shape | it doesn't — `respond(result: unknown)` accepts anything. The shape (e.g. `{ accepted, steps }`) is a convention you define; keep the frontend `respond(...)` call and your bridge's parser in sync. | ## Foundry connection | Symptom | Cause | Fix | | --- | --- | --- | | 401 "audience is incorrect" | default `cognitiveservices.azure.com` scope on the project path | request the `https://ai.azure.com/.default` audience. | -| 403 `workspaces/agents/action` | `az` logged into the wrong tenant for the project | `az login --tenant ` (or set the project's tenant). | -| Run the agent locally for dev | no deployed agent yet | `azd ai agent run` runs the REAL agent on your machine (what `make local`/`make smoke` use, via the bridge's DIRECT mode); needs `az login` + a provisioned project (`make up` once). | +| 403 `workspaces/agents/action` | `az` logged into the wrong tenant for the project, OR (in a shared/sandboxed environment) the default subscription silently drifted mid-session | `az login --tenant ` (or set the project's tenant); re-run `az account show`/`az account set --subscription ` before assuming this is a code bug, and restart `azd ai agent run` after fixing it (it can cache the credential at process start). | +| Run the agent locally for dev | no deployed agent yet | `azd ai agent run` runs the REAL agent on your machine at a local URL; point your bridge at it directly; needs `az login` + a provisioned project. | ## Containers / azd | Symptom | Cause | Fix | | --- | --- | --- | | `az acr build` fails `toomanyrequests` | Docker Hub base image | use `mcr.microsoft.com/devcontainers/...` base images. | -| azd deploys the helloworld placeholder | ran `azd provision` only | run `make up` (= `azd up` = provision + deploy). | -| hosted image missing `src/agent.py` | build context too narrow | `hosted/azure.yaml` sets `context: ..` (template root). | +| azd deploys the helloworld placeholder | ran `azd provision` only | run `azd up` (provision + deploy). | +| hosted image missing `src/agent.py` (or other shared code) | build context too narrow | make sure `hosted/azure.yaml`'s build context includes wherever your shared agent code actually lives. | +| container crashes at boot with `ModuleNotFoundError: No module named 'mcp'`, surfacing as HTTP 424 `session_not_ready` on invoke | the hosting package imports `mcp` but it isn't declared/pulled in transitively on a remote build | pin `mcp` explicitly in `requirements.txt`. | -## Bridge (the framework-native AG-UI endpoint) +## Bridge (whatever you name it) | Symptom | Cause | Fix | | --- | --- | --- | -| Approval card vanishes at RUN_FINISHED | multi-tool snapshot; CopilotKit v1 renders only `toolCalls[0]` | keep the snapshot-split in `bridge_app.py`; `make smoke` C9 guards it. | -| HITL approve does nothing / state doesn't change | ag-ui resolved the approval locally (the routing patch was removed/disabled) | the hosted bridge needs **two** patches — HITL approval routing **and** the snapshot split — both proven load-bearing on rc5 (disabling either fails smoke). Keep both. | -| `useAgent().state` stays empty | `state_schema`/`predict_state_config` not passed to the endpoint, or no tool writes the state key | set `AGENT_STATE_SCHEMA`/`AGENT_PREDICT_STATE` in `src/agent.py` and write the key from a tool. | -| Deployed bridge can't reach the agent | `FOUNDRY_PROJECT_ENDPOINT` / `HOSTED_AGENT_NAME` unset | set both; the bridge (`hosted_client`) reaches the deployed agent keyless. | -| Python `@tool` didn't run "in Foundry" | FoundryAgent runs Python `@tool` callables CLIENT-SIDE; only Foundry-native tools run server-side | expected — define server-side tools on the deployed agent; keep `@tool`s for client-side/HITL. | -| UI 500 mid-run on a long silent tool | a gateway dropped the idle SSE | keep `SSEKeepAliveMiddleware` (`: ping` ~10s). | +| Approval card vanishes when a turn makes several tool calls | multi-tool AG-UI snapshot translation issue (see AG-UI rendering above) | verify live against your actual CopilotKit version rather than assuming a fixed rule. | +| HITL approve does nothing / state doesn't change | the bridge resolved/dropped the approval locally instead of forwarding it | the single most load-bearing bridge behavior — verify with a live test: reject must leave state unchanged, approve must change it. | +| `useAgent().state` stays empty | `state_schema`/`predict_state_config` not passed to the agent, or no tool writes the state key, or your bridge doesn't relay state deltas (this pattern is roadmap through a pure proxy — see patterns-7.md) | set `AGENT_STATE_SCHEMA`/`AGENT_PREDICT_STATE`-equivalent config in `src/agent.py` and write the key from a tool; confirm your bridge actually forwards state events before assuming this pattern works out of the box. | +| Deployed bridge can't reach the agent | required settings (e.g. the Foundry project endpoint, the hosted agent's name) unset | set whatever your bridge's platform-mode code needs; it should be able to reach the deployed agent keyless via Entra. | +| Python `@tool` didn't run "in Foundry" | the native `FoundryAgent` client (not the hosted-agent-first `build_hosted_agent` path this skill uses) runs Python `@tool` callables CLIENT-SIDE; only Foundry-native tools run server-side | expected for that client — this skill's `build_hosted_agent`/`FoundryChatClient` path runs `@tool`s server-side in the hosted runtime instead. | +| UI 500 mid-run on a long silent tool | a gateway dropped the idle SSE connection | add an SSE keep-alive (a periodic `: ping` comment, e.g. every ~10s) to your bridge's streaming response. | From 541c8330797db73f2c3aedd8ab69a36296f65138 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 12:06:06 +0800 Subject: [PATCH 06/12] Add copy-adapt starter code snippets to cut bootstrap time The skill's first real end-to-end test build (see prior commit) took ~1 hour, with roughly half that time spent re-deriving code and package APIs from scratch that would be eliminated by shipping a proven starting point. Added skills/foundry-hosted-agent-copilotkit/references/snippets/ with a minimal, VERIFIED-WORKING implementation for a generic "records" domain: - src/agent.py: build_hosted_agent() + one read tool + one approval_mode="always_require" gated tool. - backend/bridge_app.py, hosted_proxy.py, hosted_client.py: the hand-rolled AG-UI bridge strategy (architecture.md strategy 2) - FastAPI endpoint translating the hosted agent's Responses stream to AG-UI events and forwarding mcp_approval_response on approve. - hosted/responses/main.py: the ResponsesHostServer entry point shape (prefer generating the rest of hosted/ with azd ai agent init). - frontend/{app,components,lib}: CopilotKit route.ts, providers.tsx, useHumanInTheLoop/useRenderTool components, and the agent-id constant with its "separate identifier" note; package.json.snippet with last-known-good pinned versions (copilotkit 1.61.2, ag-ui/client 0.0.57, next 15.5.19). - scripts/verify.sh, smoke.py, browser_e2e.js: structural check, bridge smoke test, and Playwright browser E2E (read, HITL pause, approve, reject), generalized from a passing run. Re-verified the generalized snippets independently in a fresh scratch directory against a real local Foundry hosted agent (aif-swec-01): hosted agent read + HITL-pause confirmed via curl, then the full bridge plus smoke.py passed 12/12 (read, HITL pause, reject leaves state unchanged, approve re-executes and changes state). Also fixed one bug found only by this re-verification: hosted_client.py's Authorization header had been corrupted by an output-redaction filter during the original test run (a credential-shaped literal got masked in the file itself, not just terminal output) - corrected to send a proper Bearer token. This path (platform/deployed mode) was never exercised in the original local-only test, so the bug had gone undetected until this re-verification. Wired the snippets into SKILL.md: "1. Scaffold" now says to copy references/snippets/ first and rename the domain, "load-bearing rules" links the hand-rolled bridge strategy to the concrete files, "3. Prove it" points at the three scripts, and the CopilotKit section points at the pinned package.json snippet instead of npm-installing latest blind. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/README.skills.md | 2 +- .../foundry-hosted-agent-copilotkit/SKILL.md | 66 +++-- .../references/snippets/README.md | 46 ++++ .../references/snippets/backend/bridge_app.py | 103 ++++++++ .../snippets/backend/hosted_client.py | 112 +++++++++ .../snippets/backend/hosted_proxy.py | 228 ++++++++++++++++++ .../snippets/backend/requirements.txt | 10 + .../app/api/copilotkit/[[...slug]]/route.ts | 33 +++ .../snippets/frontend/app/providers.tsx | 18 ++ .../frontend/components/ApprovalHitl.tsx | 83 +++++++ .../frontend/components/ToolCards.tsx | 47 ++++ .../references/snippets/frontend/lib/agent.ts | 12 + .../snippets/frontend/package.json.snippet | 18 ++ .../snippets/hosted/responses/main.py | 42 ++++ .../snippets/scripts/browser_e2e.js | 133 ++++++++++ .../references/snippets/scripts/smoke.py | 181 ++++++++++++++ .../references/snippets/scripts/verify.sh | 129 ++++++++++ .../references/snippets/src/agent.py | 102 ++++++++ 18 files changed, 1341 insertions(+), 24 deletions(-) create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/README.md create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/backend/requirements.txt create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/api/copilotkit/[[...slug]]/route.ts create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/providers.tsx create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/package.json.snippet create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py diff --git a/docs/README.skills.md b/docs/README.skills.md index de0ac1467..24ff39582 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -175,7 +175,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [fluentui-blazor](../skills/fluentui-blazor/SKILL.md)
`gh skills install github/awesome-copilot fluentui-blazor` | Guide for using the Microsoft Fluent UI Blazor component library (Microsoft.FluentUI.AspNetCore.Components NuGet package) in Blazor applications. Use this when the user is building a Blazor app with Fluent UI components, setting up the library, using FluentUI components like FluentButton, FluentDataGrid, FluentDialog, FluentToast, FluentNavMenu, FluentTextField, FluentSelect, FluentAutocomplete, FluentDesignTheme, or any component prefixed with "Fluent". Also use when troubleshooting missing providers, JS interop issues, or theming. | `references/DATAGRID.md`
`references/LAYOUT-AND-NAVIGATION.md`
`references/SETUP.md`
`references/THEMING.md` | | [folder-structure-blueprint-generator](../skills/folder-structure-blueprint-generator/SKILL.md)
`gh skills install github/awesome-copilot folder-structure-blueprint-generator` | Comprehensive technology-agnostic prompt for analyzing and documenting project folder structures. Auto-detects project types (.NET, Java, React, Angular, Python, Node.js, Flutter), generates detailed blueprints with visualization options, naming conventions, file placement patterns, and extension templates for maintaining consistent code organization across diverse technology stacks. | None | | [foundry-agent-sync](../skills/foundry-agent-sync/SKILL.md)
`gh skills install github/awesome-copilot foundry-agent-sync` | Create and synchronize prompt-based AI agents directly within Azure AI Foundry via REST API, from a local JSON manifest. Unlike scaffolding skills that only generate local code, this skill registers agents in the Foundry service itself — making them immediately available for invocation. Use when the user asks to create agents in Foundry, sync, deploy, register, or push agents to Foundry, update agent instructions, or scaffold the manifest and sync script for a new repository. Triggers: 'create agent in foundry', 'sync foundry agents', 'deploy agents to foundry', 'register agents in foundry', 'push agents', 'create foundry agent manifest', 'scaffold agent sync'. | None | -| [foundry-hosted-agent-copilotkit](../skills/foundry-hosted-agent-copilotkit/SKILL.md)
`gh skills install github/awesome-copilot foundry-hosted-agent-copilotkit` | Build a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack: a Next.js/CopilotKit v2 chat UI over a light FastAPI/AG-UI bridge that forwards every turn to ONE Microsoft Agent Framework agent hosted in Azure AI Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). Triggers: agentic app, CopilotKit app, AG-UI bridge, Foundry hosted agent, Microsoft Agent Framework, human-in-the-loop/HITL approval, approval_mode always_require, confirm_changes. Also for fixing the known traps: HITL approve-resume 400 'No tool output found', confirm_changes mis-wired, AG-UI snapshot cards vanishing, CopilotKit catch-all route 404/422, useSingleEndpoint, keyless Foundry 401 audience, Docker Hub rate-limit on ACR build. | `references/architecture.md`
`references/hosted-deploy.md`
`references/patterns-7.md`
`references/troubleshooting.md` | +| [foundry-hosted-agent-copilotkit](../skills/foundry-hosted-agent-copilotkit/SKILL.md)
`gh skills install github/awesome-copilot foundry-hosted-agent-copilotkit` | Build a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack: a Next.js/CopilotKit v2 chat UI over a light FastAPI/AG-UI bridge that forwards every turn to ONE Microsoft Agent Framework agent hosted in Azure AI Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). Triggers: agentic app, CopilotKit app, AG-UI bridge, Foundry hosted agent, Microsoft Agent Framework, human-in-the-loop/HITL approval, approval_mode always_require, confirm_changes. Also for fixing the known traps: HITL approve-resume 400 'No tool output found', confirm_changes mis-wired, AG-UI snapshot cards vanishing, CopilotKit catch-all route 404/422, useSingleEndpoint, keyless Foundry 401 audience, Docker Hub rate-limit on ACR build. | `references/architecture.md`
`references/hosted-deploy.md`
`references/patterns-7.md`
`references/snippets`
`references/troubleshooting.md` | | [freecad-scripts](../skills/freecad-scripts/SKILL.md)
`gh skills install github/awesome-copilot freecad-scripts` | Expert skill for writing FreeCAD Python scripts, macros, and automation. Use when asked to create FreeCAD models, parametric objects, Part/Mesh/Sketcher scripts, workbench tools, GUI dialogs with PySide, Coin3D scenegraph manipulation, or any FreeCAD Python API task. Covers FreeCAD scripting basics, geometry creation, FeaturePython objects, interface tools, and macro development. | `references/geometry-and-shapes.md`
`references/gui-and-interface.md`
`references/parametric-objects.md`
`references/scripting-fundamentals.md`
`references/workbenches-and-advanced.md` | | [from-the-other-side-anitta](../skills/from-the-other-side-anitta/SKILL.md)
`gh skills install github/awesome-copilot from-the-other-side-anitta` | Rigorous challenge profile for Anitta: assumption checks, evidence calibration, and defensible reasoning patterns for Ember collaboration. | None | | [from-the-other-side-quinn](../skills/from-the-other-side-quinn/SKILL.md)
`gh skills install github/awesome-copilot from-the-other-side-quinn` | Collaboration profile for Quinn: curious, energetic, and implementation-focused partnership patterns for Ember sessions with Alison. | None | diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md index b38970f2a..39dbf6222 100644 --- a/skills/foundry-hosted-agent-copilotkit/SKILL.md +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -57,17 +57,32 @@ Read all four reference docs before writing any code; they encode the load-bearing rules, the framework traps, and the Definition of Done that keep this stack correct. -> **This skill is a design + rulebook, not a runnable template.** It ships no -> `bridge_app.py`, no `route.ts`, no `Makefile` — you write every file from the -> architecture below. Package APIs on this stack move fast; always confirm the -> exact class/function names, hook signatures, and route-handler mode against -> your **currently installed** package versions (`pip show` / `npm ls` / -> reading `.d.ts` or the package's own bundled docs) before trusting any name -> in this skill verbatim — treat every concrete symbol here as "true as of -> when this was written," not as a guarantee. +> **This skill is a design + rulebook with copy-adapt starter code, not an +> installable template.** [`references/snippets/`](references/snippets/README.md) +> has a verified-working +> minimum (real hosted agent, real bridge, real CopilotKit UI, a real +> Playwright browser E2E — all passed) for a generic example domain; copy it +> in and rename the domain-specific parts (see +> [`references/snippets/README.md`](references/snippets/README.md)) instead of writing the bridge/agent/HITL +> plumbing from scratch. Package APIs on this stack move fast; always confirm +> the exact class/function names, hook signatures, and route-handler mode +> against your **currently installed** package versions (`pip show` / `npm ls` +> / reading `.d.ts` or the package's own bundled docs) before trusting any +> name in this skill or the snippets verbatim — treat every concrete symbol +> here as "true as of when this was written," not as a guarantee. ## 1. Scaffold (always start here) +**Copy `references/snippets/` into your new project first** (see +`references/snippets/README.md` for the file → destination mapping), then +rename the example "records"/`REC-...` domain to your actual domain in +`src/agent.py`, `backend/hosted_proxy.py`, and the frontend components. This +gets you a working read tool + gated tool + bridge + HITL UI + structural +check + smoke test + browser E2E in one step, instead of deriving the AG-UI +event translation, the CopilotKit hook names, and the HITL wiring from +scratch — that derivation is the single biggest source of wasted time on this +stack. + **Bootstrap `hosted/` (the Foundry hosted-agent project) with the `azd` Foundry extension's own scaffolder — do not hand-write it:** @@ -78,15 +93,16 @@ azd ai agent init -m # e.g. an agent-framework/responses/*/ This generates the real, currently-correct `hosted/` tree for your installed extension version: `main.py`/`responses/main.py`, `agent.yaml`, `azure.yaml`, -`requirements.txt`, `Dockerfile`, and a full `infra/` (Bicep). Read the -generated `main.py` to see the CURRENT correct import paths and server class -name — `ResponsesHostServer` may live in a package named -`agent-framework-foundry-hosting` (separate from `agent-framework-foundry`), -and `FoundryChatClient` may be importable from either `agent_framework.foundry` -or `agent_framework_foundry` depending on version. Do not guess; read the +`requirements.txt`, `Dockerfile`, and a full `infra/` (Bicep). Compare it +against `references/snippets/hosted/responses/main.py` — read the generated +`main.py` to see the CURRENT correct import paths and server class name +(`ResponsesHostServer` may live in a package named +`agent-framework-foundry-hosting`, separate from `agent-framework-foundry`; +`FoundryChatClient` may be importable from either `agent_framework.foundry` +or `agent_framework_foundry` depending on version). Do not guess; read the generated scaffold and `pip show` the installed packages. -Then create the rest of the app around it (lowercase-hyphen app name), per the +Then place the rest of the app around it (lowercase-hyphen app name), per the architecture in `references/architecture.md`: ``` @@ -139,14 +155,14 @@ version's own bundled docs/types): ## 3. Prove it -Write your own `verify.sh` (structural: bridge forwards HITL, `FoundryChatClient` -used, HITL contract consistent, agent name consistent, MCR base images) and -`smoke.py`/similar (the bridge against the REAL agent running locally via +Copy and adapt `references/snippets/scripts/{verify.sh,smoke.py,browser_e2e.js}` +(structural check; the bridge against the REAL agent running locally via `azd ai agent run` — read works, action PAUSES, approve executes, reject -doesn't). Needs `az login` + a provisioned Foundry project. Both must pass -before you consider the app done, followed by a **live browser E2E** (e.g. -Playwright) against the real local stack, and — once you deploy — the same -proof against the deployed hosted agent, since all logic is server-side. +doesn't; a real Playwright browser E2E of the same). Needs `az login` + a +provisioned Foundry project, and `npm install playwright && npx playwright +install chromium` for the browser test. Both scripts must pass before you +consider the app done, and — once you deploy — the same proof against the +deployed hosted agent, since all logic is server-side. ## Load-bearing rules (why the architecture is shaped this way) @@ -169,7 +185,9 @@ proof against the deployed hosted agent, since all logic is server-side. (`_is_confirm_changes_response`, `_resolve_approval_responses`, `_collect_approval_responses`, `_try_execute_function_calls`) before committing to this path. - 2. **Hand-rolled (verified working end-to-end on a real local hosted agent):** + 2. **Hand-rolled (verified working end-to-end on a real local hosted agent + — a ready-to-copy implementation is in + `references/snippets/backend/{bridge_app.py,hosted_proxy.py,hosted_client.py}`):** write a small FastAPI endpoint that speaks the AG-UI wire protocol directly, using the `ag-ui-protocol` Python package's own `ag_ui.core` event/model classes (`RunAgentInput`, `RunStartedEvent`, @@ -231,6 +249,8 @@ proxy — treat it as extra work, not a given. subpath with hooks `useAgent`, `useAgentContext`, `useFrontendTool`, `useRenderTool`, `useHumanInTheLoop`, and a **provider component named `CopilotKit`** (not `CopilotKitProvider`, which is an internal subset). + `references/snippets/frontend/package.json.snippet` has the last-known-good + pinned versions — start there instead of `npm install`ing latest blind. - **Bridge route:** catch-all `app/api/copilotkit/[[...slug]]/route.ts`. Import the runtime handler from `@copilotkit/runtime/v2` and check whether the client you're pairing it with defaults to single-route (one JSON-envelope diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md new file mode 100644 index 000000000..761f7a7a3 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md @@ -0,0 +1,46 @@ +# Starter snippets — copy, then adapt to your domain + +These files are a **verified-working minimum** (real local Foundry hosted +agent, real bridge, real CopilotKit UI, real Playwright browser E2E — see +`architecture.md` for what was proven and when). They are a starting point to +copy into your new project and adapt — not a package to import, and not +guaranteed to be current forever. Package APIs on this stack move fast: +before you trust any exact class/function/hook name here, confirm it against +your own installed package versions. + +The example domain is a generic "records" assistant with one read tool +(`list_pending_records`) and one gated/consequential tool +(`approve_record`, `@tool(approval_mode="always_require")`). Replace the +domain logic, tool names, and prompts with your own — keep the surrounding +plumbing (the bridge translation, the HITL contract, the agent name wiring) +intact unless you have a specific reason to change it, and re-verify after +any change with your own structural check + smoke test + browser E2E. + +| File | Copy to | Purpose | +| --- | --- | --- | +| [`src/agent.py`](src/agent.py) | `src/agent.py` | The hosted brain: `build_hosted_agent()`, `FoundryChatClient`, one read tool, one gated tool. | +| [`backend/bridge_app.py`](backend/bridge_app.py) | `backend/bridge_app.py` | FastAPI AG-UI endpoint; SSE keep-alive. | +| [`backend/hosted_proxy.py`](backend/hosted_proxy.py) | `backend/hosted_proxy.py` | Translates the hosted agent's Responses stream to AG-UI events; forwards `mcp_approval_response`. | +| [`backend/hosted_client.py`](backend/hosted_client.py) | `backend/hosted_client.py` | Streaming Responses HTTP driver (DIRECT local / platform deployed). | +| [`backend/requirements.txt`](backend/requirements.txt) | `backend/requirements.txt` | Bridge-only deps (no agent-framework/foundry packages — the bridge runs no model). | +| [`hosted/responses/main.py`](hosted/responses/main.py) | `hosted/responses/main.py` | Entry point wrapping `build_hosted_agent()` in `ResponsesHostServer`. Prefer generating this (and `agent.yaml`/`azure.yaml`/`Dockerfile`/`infra/`) with `azd ai agent init` instead — this file is here mainly to show the import shape. | +| [`frontend/app/api/copilotkit/[[...slug]]/route.ts`]() | same path | CopilotKit runtime handler pointed at the bridge. | +| [`frontend/app/providers.tsx`](frontend/app/providers.tsx) | `frontend/app/providers.tsx` | `` provider + HITL/tool-card component registration. | +| [`frontend/components/ApprovalHitl.tsx`](frontend/components/ApprovalHitl.tsx) | `frontend/components/` | `useHumanInTheLoop` example for the gated tool. | +| [`frontend/components/ToolCards.tsx`](frontend/components/ToolCards.tsx) | `frontend/components/` | `useRenderTool` examples for both tools. | +| [`frontend/lib/agent.ts`](frontend/lib/agent.ts) | `frontend/lib/agent.ts` | The CopilotKit-facing agent id constant (`"default"`) — see the note inside about why this is a separate identifier from the Foundry hosted agent's own name. | +| [`frontend/package.json.snippet`](frontend/package.json.snippet) | merge into `frontend/package.json` | Last-known-good pinned versions (re-check for newer versions and deprecation warnings before using as-is). | +| [`scripts/verify.sh`](scripts/verify.sh) | `scripts/verify.sh` | Structural check — no network calls. | +| [`scripts/smoke.py`](scripts/smoke.py) | `scripts/smoke.py` | E2E check against the bridge + a REAL local hosted agent (`azd ai agent run`). | +| [`scripts/browser_e2e.js`](scripts/browser_e2e.js) | `scripts/browser_e2e.js` | Playwright browser E2E: read, HITL pause, approve, reject. | + +## What's deliberately NOT included + +- `hosted/agent.yaml`, `hosted/azure.yaml`, `hosted/responses/Dockerfile`, + `hosted/infra/` — generate these with `azd ai agent init -m ` + (`azd ai agent sample list` to discover manifests) so they match your + installed `azd` Foundry extension version exactly, rather than trusting a + hand-copied Bicep/Dockerfile that can drift out of date. +- `backend/Dockerfile` — a one-line MCR-based Dockerfile running + `uvicorn bridge_app:app`; write your own to match your bridge's actual + dependencies (keep the MCR base image — see `troubleshooting.md`). diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py new file mode 100644 index 000000000..a622cb1e0 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft. All rights reserved. +"""The bridge: a thin AG-UI endpoint over the Foundry hosted agent. + +No LLM, no tools, and no business logic live here — this process only: + 1. Accepts an AG-UI `RunAgentInput` from the CopilotKit runtime (`route.ts`). + 2. Forwards the turn to the hosted agent (`hosted_proxy.run_turn`, which + talks to `hosted_client`), in DIRECT mode locally (`azd ai agent run`) + or platform mode once deployed. + 3. Streams back native AG-UI SSE events (text, tool-call cards, an + approval-request card) and forwards the human's approve/reject decision + as an `mcp_approval_response` so the gated tool re-executes server-side + on approve. + +An SSE keep-alive comment is emitted periodically so a gateway/proxy in front +of this app doesn't drop the connection while the hosted agent is silently +running a tool. +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from ag_ui.core import RunAgentInput +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import ValidationError + +from hosted_proxy import run_turn + +logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) +logger = logging.getLogger("bridge") + +AGENT_NAME = "my-hosted-agent" # keep this consistent with src/agent.py AGENT_NAME + +app = FastAPI(title="agent bridge") + +app.add_middleware( + CORSMiddleware, + allow_origins=os.environ.get("ALLOW_ORIGINS", "*").split(","), + allow_methods=["*"], + allow_headers=["*"], +) + +_API_KEY = os.environ.get("BRIDGE_API_KEY") +_KEEPALIVE_SECONDS = float(os.environ.get("SSE_KEEPALIVE_SECONDS", "10")) + + +def _sse_event(event) -> str: + return f"data: {event.model_dump_json(by_alias=True, exclude_none=True)}\n\n" + + +async def _sse_stream(run_input: RunAgentInput): + queue: asyncio.Queue = asyncio.Queue() + DONE = object() + + async def _produce() -> None: + try: + async for event in run_turn(run_input): + await queue.put(event) + finally: + await queue.put(DONE) + + producer = asyncio.create_task(_produce()) + try: + while True: + try: + item = await asyncio.wait_for(queue.get(), timeout=_KEEPALIVE_SECONDS) + except asyncio.TimeoutError: + # Keep the connection alive while the hosted agent silently + # runs a tool (a dropped idle SSE connection is a known trap). + yield ": ping\n\n" + continue + if item is DONE: + break + yield _sse_event(item) + finally: + producer.cancel() + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok", "agent": AGENT_NAME} + + +@app.post("/") +@app.post("/agent") +async def run_agent(request: Request) -> StreamingResponse: + if _API_KEY: + provided = request.headers.get("x-api-key") + if provided != _API_KEY: + return StreamingResponse(iter([]), status_code=401) + + body = await request.json() + try: + run_input = RunAgentInput.model_validate(body) + except ValidationError as exc: + logger.error("Invalid RunAgentInput: %s", exc) + raise + + return StreamingResponse(_sse_stream(run_input), media_type="text/event-stream") diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py new file mode 100644 index 000000000..205c20fe0 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Streaming Responses driver for the Foundry hosted agent. + +Two modes, selected by which env vars are set: + - DIRECT (local dev): HOSTED_AGENT_DIRECT_URL points at the agent started by + `azd ai agent run` (e.g. http://localhost:8088). Talks straight to the + local ResponsesHostServer, no auth. + - PLATFORM (deployed): FOUNDRY_PROJECT_ENDPOINT + HOSTED_AGENT_NAME reach the + published Foundry hosted agent's Responses endpoint keyless (Entra / + DefaultAzureCredential, audience https://ai.azure.com/.default). Verify + this path against a real deployed agent before relying on it — the + endpoint URL shape below was correct when last verified but this stack + moves fast. + +Both modes speak the same OpenAI Responses streaming wire format (SSE: +`event: ` / `data: `), so the parsing logic below is shared. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +_DIRECT_URL = os.environ.get("HOSTED_AGENT_DIRECT_URL", "http://localhost:8088") +_FOUNDRY_PROJECT_ENDPOINT = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") +_HOSTED_AGENT_NAME = os.environ.get("HOSTED_AGENT_NAME") +_MODEL_DEPLOYMENT = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini") + +_AAD_SCOPE = "https://ai.azure.com/.default" + + +def is_platform_mode() -> bool: + """True once the app is pointed at a deployed hosted agent.""" + return bool(_FOUNDRY_PROJECT_ENDPOINT and _HOSTED_AGENT_NAME) + + +async def _get_bearer_token() -> str: + """Keyless Entra token for the deployed (platform) hosted agent. + + Requesting the `https://ai.azure.com/.default` audience is load-bearing — + the default Cognitive Services audience 401s ("audience is incorrect"). + """ + from azure.identity.aio import DefaultAzureCredential + + async with DefaultAzureCredential() as cred: + token = await cred.get_token(_AAD_SCOPE) + return token.token + + +def _target_url() -> tuple[str, dict[str, str]]: + """Resolve the Responses endpoint + headers for the current mode.""" + if is_platform_mode(): + # Deployed hosted-agent endpoint shape — confirm this is still + # current for your azure.ai.agents extension version: + # POST {project_endpoint}/agents/{agent_name}/endpoint/protocols/openai/responses + base = _FOUNDRY_PROJECT_ENDPOINT.rstrip("/") # type: ignore[union-attr] + url = f"{base}/agents/{_HOSTED_AGENT_NAME}/endpoint/protocols/openai/responses" + return url, {} + # DIRECT mode: local `azd ai agent run` (ResponsesHostServer) instance. + return f"{_DIRECT_URL.rstrip('/')}/responses", {} + + +async def stream_responses( + *, + input_items: list[dict[str, Any]], + previous_response_id: str | None = None, +) -> AsyncIterator[dict[str, Any]]: + """POST to the hosted agent's Responses endpoint and yield parsed SSE events. + + Each yielded item is the parsed JSON payload of one `data:` line (the + OpenAI Responses streaming event envelope: {"type": ..., ...}). + """ + url, base_headers = _target_url() + headers = {"Content-Type": "application/json", "Accept": "text/event-stream", **base_headers} + + # NOTE: deployed agents use Entra isolation; sending a manual + # `x-ms-user-isolation-key` header causes a 400. Do not add one here. + if is_platform_mode(): + headers["Authorization"] = f"Bearer {await _get_bearer_token()}" + + body: dict[str, Any] = { + "model": _MODEL_DEPLOYMENT, + "input": input_items, + "stream": True, + } + if previous_response_id: + body["previous_response_id"] = previous_response_id + + async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client: + async with client.stream("POST", url, headers=headers, json=body) as resp: + resp.raise_for_status() + data_lines: list[str] = [] + async for raw_line in resp.aiter_lines(): + line = raw_line.rstrip("\n") + if line == "": + if data_lines: + payload = "\n".join(data_lines) + data_lines = [] + if payload.strip(): + try: + yield json.loads(payload) + except json.JSONDecodeError: + continue + continue + if line.startswith("data:"): + data_lines.append(line[len("data:") :].strip()) + # `event: ` lines are redundant with the `type` field in + # the JSON payload, so they're intentionally ignored here. diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py new file mode 100644 index 000000000..0d831524c --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py @@ -0,0 +1,228 @@ +# Copyright (c) Microsoft. All rights reserved. +"""HostedProxyAgent — forwards each AG-UI turn to the Foundry hosted agent and +translates the Responses stream into native AG-UI SSE events. + +This is one way to implement the bridge the skill calls for (see +architecture.md strategy 2 — hand-rolled AG-UI translation): the native +`add_agent_framework_fastapi_endpoint(FoundryAgent(...))` path resolves an +approval decision LOCALLY and never forwards it as an `mcp_approval_response`, +so an approved gated tool never re-executes server-side. This proxy exists +purely to close that one gap: it forwards the human's decision to the hosted +agent so the tool re-executes there, and otherwise just translates Responses +events to AG-UI 1:1. + +Per-thread state (in-memory; single replica only — see hosted-deploy.md): + - last_response_id: the hosted agent's previous `response_id`, so the next + turn chains history server-side via `previous_response_id`. + - pending_approval: the outstanding approval-gated tool call awaiting a + human decision (a thread can only have one open approval at a time in + this simple version — extend if you need concurrent approvals). +""" + +from __future__ import annotations + +import json +import logging +import uuid +from collections.abc import AsyncIterator +from typing import Any + +from ag_ui.core import ( + RunAgentInput, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, + ToolMessage, + UserMessage, +) + +from hosted_client import stream_responses + +logger = logging.getLogger(__name__) + +# The synthetic tool name the frontend's `useHumanInTheLoop` hook listens for. +# This name (and the resolved payload shape below) is a convention YOU define +# and must keep in sync with the frontend — CopilotKit doesn't enforce either. +CONFIRM_CHANGES_TOOL = "confirm_changes" + +# thread_id -> {"last_response_id": str | None, "pending_approval": dict | None} +_THREAD_STATE: dict[str, dict[str, Any]] = {} + + +def _thread_state(thread_id: str) -> dict[str, Any]: + return _THREAD_STATE.setdefault(thread_id, {"last_response_id": None, "pending_approval": None}) + + +def _latest_user_text(messages: list[Any]) -> str: + """The most recent user message's text (a fresh turn, not an approval).""" + for msg in reversed(messages): + if isinstance(msg, UserMessage): + content = msg.content + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [getattr(p, "text", "") for p in content if getattr(p, "type", None) == "text"] + return "\n".join(p for p in parts if p) + return "" + + +def _find_approval_decision(messages: list[Any], pending: dict[str, Any] | None) -> bool | None: + """Detect a `confirm_changes` resolution from the frontend. + + `useHumanInTheLoop.respond({accepted, steps})` round-trips as a ToolMessage + whose `tool_call_id` matches the pending confirm_changes call and whose + content is `{"accepted": bool, "steps": [...]}` — this exact shape is a + convention this snippet defines; pick your own and keep both sides + consistent if you change it. + """ + if not pending: + return None + for msg in reversed(messages): + if isinstance(msg, ToolMessage) and msg.tool_call_id == pending.get("tool_call_id"): + try: + parsed = json.loads(msg.content) if isinstance(msg.content, str) else msg.content + except (json.JSONDecodeError, TypeError): + continue + if isinstance(parsed, dict) and "accepted" in parsed: + return bool(parsed["accepted"]) + return None + + +async def run_turn(run_input: RunAgentInput) -> AsyncIterator[Any]: + """Run one AG-UI turn against the hosted agent; yields AG-UI events.""" + thread_id = run_input.thread_id + run_id = run_input.run_id + state = _thread_state(thread_id) + messages = run_input.messages + + yield RunStartedEvent(thread_id=thread_id, run_id=run_id) + + pending = state.get("pending_approval") + decision = _find_approval_decision(messages, pending) + + if decision is not None and pending is not None: + input_items: list[dict[str, Any]] = [ + { + "type": "mcp_approval_response", + "approval_request_id": pending["approval_request_id"], + "approve": decision, + } + ] + previous_response_id = pending["response_id"] + state["pending_approval"] = None + else: + text = _latest_user_text(messages) + input_items = [{"role": "user", "content": [{"type": "input_text", "text": text}]}] + previous_response_id = state.get("last_response_id") + + try: + async for event in _translate_stream( + state=state, + input_items=input_items, + previous_response_id=previous_response_id, + ): + yield event + except Exception as exc: # noqa: BLE001 - surface any hosted-agent error to the UI + logger.exception("Hosted agent call failed") + yield RunErrorEvent(message=str(exc)) + return + + yield RunFinishedEvent(thread_id=thread_id, run_id=run_id) + + +async def _translate_stream( + *, + state: dict[str, Any], + input_items: list[dict[str, Any]], + previous_response_id: str | None, +) -> AsyncIterator[Any]: + """Translate one Responses stream into AG-UI events; updates thread state.""" + text_started: set[str] = set() + + async for event in stream_responses(input_items=input_items, previous_response_id=previous_response_id): + etype = event.get("type") + + if etype == "response.output_text.delta": + item_id = event["item_id"] + delta = event.get("delta", "") + if item_id not in text_started: + text_started.add(item_id) + yield TextMessageStartEvent(message_id=item_id, role="assistant") + if delta: + yield TextMessageContentEvent(message_id=item_id, delta=delta) + + elif etype == "response.output_item.done": + item = event.get("item", {}) + item_type = item.get("type") + + if item_type == "message" and item.get("id") in text_started: + yield TextMessageEndEvent(message_id=item["id"]) + + elif item_type == "function_call": + call_id = item["call_id"] + name = item["name"] + yield ToolCallStartEvent(tool_call_id=call_id, tool_call_name=name) + yield ToolCallArgsEvent(tool_call_id=call_id, delta=item.get("arguments", "") or "{}") + yield ToolCallEndEvent(tool_call_id=call_id) + + elif item_type == "function_call_output": + call_id = item["call_id"] + yield ToolCallResultEvent( + message_id=str(uuid.uuid4()), + tool_call_id=call_id, + content=str(item.get("output", "")), + role="tool", + ) + + elif item_type == "mcp_approval_request": + # The gated tool paused. Surface it as the synthetic + # `confirm_changes` tool call the frontend's + # useHumanInTheLoop hook renders and resolves. + function_name = item["name"] + function_arguments = item.get("arguments", "{}") + tool_call_id = str(uuid.uuid4()) + args_payload = json.dumps( + { + "function_name": function_name, + "function_arguments": function_arguments, + "steps": [ + { + "description": ( + f"Call {function_name} with arguments {function_arguments}. " + "This is a consequential action and needs your explicit approval." + ) + } + ], + } + ) + yield ToolCallStartEvent(tool_call_id=tool_call_id, tool_call_name=CONFIRM_CHANGES_TOOL) + yield ToolCallArgsEvent(tool_call_id=tool_call_id, delta=args_payload) + yield ToolCallEndEvent(tool_call_id=tool_call_id) + + state["pending_approval"] = { + "tool_call_id": tool_call_id, + "approval_request_id": item["id"], + "function_name": function_name, + "function_arguments": function_arguments, + # Filled in once `response.completed` arrives below — an + # mcp_approval_response must chain off the response.id + # that CONTAINS the approval request. + "response_id": None, + } + + elif etype == "response.completed": + response = event.get("response", {}) + response_id = response.get("response_id") or response.get("id") + state["last_response_id"] = response_id + if state.get("pending_approval") is not None and state["pending_approval"].get("response_id") is None: + state["pending_approval"]["response_id"] = response_id + + elif etype == "error": + raise RuntimeError(event.get("message") or "hosted agent returned an error event") diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/requirements.txt b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/requirements.txt new file mode 100644 index 000000000..aa66cfb3e --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/requirements.txt @@ -0,0 +1,10 @@ +# Bridge dependencies only — no foundry/openai/agent-framework packages here; +# this process runs no model and executes no tools. Versions below were the +# last known-good combination verified live; check for newer releases before +# using as-is. +fastapi==0.138.0 +uvicorn[standard]==0.49.0 +httpx==0.28.1 +ag-ui-protocol==0.1.19 +azure-identity==1.25.3 +pydantic==2.13.4 diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/api/copilotkit/[[...slug]]/route.ts b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/api/copilotkit/[[...slug]]/route.ts new file mode 100644 index 000000000..54032fd9d --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/api/copilotkit/[[...slug]]/route.ts @@ -0,0 +1,33 @@ +import { HttpAgent } from "@ag-ui/client"; +import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2"; +import { AGENT_NAME } from "../../../../lib/agent"; + +// The bridge (backend/bridge_app.py) — NOT the hosted agent directly. See +// architecture.md: @ag-ui/client can't talk to a Foundry hosted agent, which +// speaks the OpenAI Responses protocol, not AG-UI. +const BRIDGE_URL = process.env.AG_UI_BACKEND_URL ?? "http://localhost:8080/agent"; +const BRIDGE_API_KEY = process.env.BRIDGE_API_KEY; + +const runtime = new CopilotRuntime({ + agents: { + [AGENT_NAME]: new HttpAgent({ + url: BRIDGE_URL, + headers: BRIDGE_API_KEY ? { "x-api-key": BRIDGE_API_KEY } : undefined, + }), + }, +}); + +const handler = createCopilotRuntimeHandler({ + runtime, + basePath: "/api/copilotkit", + // Verify this against your installed CopilotKit version: the client may + // default to a single POST endpoint (JSON envelope) rather than REST-style + // /agent/:id/run paths — the server mode must match or every call 404s. + // Do not assume "single-route" is still correct without checking a real + // request/response for your version. + mode: "single-route", +}); + +export const GET = handler; +export const POST = handler; +export const OPTIONS = handler; diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/providers.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/providers.tsx new file mode 100644 index 000000000..fcf9efc3c --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/providers.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { CopilotKit } from "@copilotkit/react-core/v2"; +import "@copilotkit/react-core/v2/styles.css"; +import { ApprovalHitl } from "../components/ApprovalHitl"; +import { ToolCards } from "../components/ToolCards"; + +// The `CopilotKit` provider component — NOT `CopilotKitProvider`, which is an +// internal subset. Confirm against your installed version's own docs/types. +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + ); +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx new file mode 100644 index 000000000..c453ed637 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useHumanInTheLoop } from "@copilotkit/react-core/v2"; +import { z } from "zod"; +import { AGENT_NAME } from "../lib/agent"; + +/** + * Registers the `confirm_changes` human-in-the-loop gate the bridge emits + * whenever the hosted agent's gated tool (decorated + * `@tool(approval_mode="always_require")`) pauses for approval. + * + * Resolves with `{ accepted, steps }` — this exact shape is a convention + * THIS SNIPPET defines, not something CopilotKit enforces (`respond(result)` + * accepts any value). Pick your own shape if you like, but keep the frontend + * `respond(...)` call and your bridge's parser in sync — see hosted_proxy.py. + */ +const confirmChangesArgs = z.object({ + function_name: z.string(), + function_arguments: z.string(), + steps: z.array(z.object({ description: z.string() })).optional(), +}); + +export function ApprovalHitl() { + useHumanInTheLoop({ + name: "confirm_changes", + agentId: AGENT_NAME, + description: "Ask the human to approve or reject a consequential action before it executes.", + parameters: confirmChangesArgs, + render: ({ status, args, respond }) => { + if (status === "inProgress") { + return
Preparing approval request…
; + } + + let parsedArgs: Record = {}; + try { + parsedArgs = args.function_arguments ? JSON.parse(args.function_arguments) : {}; + } catch { + parsedArgs = {}; + } + // Adapt this to whatever argument your gated tool takes (e.g. record_id). + const targetId = (parsedArgs as { record_id?: string }).record_id ?? "unknown"; + const stepDescriptions = (args.steps ?? []).map((s) => s.description); + + if (status === "complete") { + return
Decision recorded for {args.function_name} ({targetId}).
; + } + + // status === "executing": awaiting the human's explicit decision. + return ( +
+

Approval required

+

+ The assistant wants to call {args.function_name} on {targetId}. + This is irreversible. +

+ {stepDescriptions.length > 0 && ( +
    + {stepDescriptions.map((d, i) => ( +
  • {d}
  • + ))} +
+ )} +
+ + +
+
+ ); + }, + }); + + return null; +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx new file mode 100644 index 000000000..0c5b9f13f --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useRenderTool } from "@copilotkit/react-core/v2"; +import { z } from "zod"; +import { AGENT_NAME } from "../lib/agent"; + +/** + * Backend Tool Rendering (AG-UI pattern #2): the bridge forwards the + * function_call/function_call_output pair for each backend tool the hosted + * agent runs, verbatim, as AG-UI TOOL_CALL_* / TOOL_CALL_RESULT events. + * `useRenderTool` gives each named tool its own progress/result card. + * + * Rename `list_pending_records`/`approve_record` (and the parameter schemas) + * to match whatever tools you defined in src/agent.py. + */ +export function ToolCards() { + useRenderTool({ + name: "list_pending_records", + agentId: AGENT_NAME, + parameters: z.object({ owner: z.string().optional() }), + render: ({ status, parameters, result }) => ( +
+

+ Looking up pending records{parameters?.owner ? ` for ${parameters.owner}` : ""}… +

+ {status === "complete" &&
{result}
} +
+ ), + }); + + useRenderTool({ + name: "approve_record", + agentId: AGENT_NAME, + parameters: z.object({ record_id: z.string().optional() }), + render: ({ status, parameters, result }) => ( +
+

+ {status === "complete" ? "Approval tool result" : "Requesting approval"} + {parameters?.record_id ? ` for ${parameters.record_id}` : ""} +

+ {status === "complete" &&
{result}
} +
+ ), + }); + + return null; +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts new file mode 100644 index 000000000..a5fd6d6b0 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts @@ -0,0 +1,12 @@ +// Single source of truth for the agent id used inside this frontend's +// CopilotKit registry (`runtime.agents` key, `CopilotChat.agentId`, +// `useRenderTool`/`useHumanInTheLoop` agentId). CopilotKit hooks that don't +// receive an explicit agentId typically resolve against the literal string +// "default" — so we register the runtime agent under that same key to avoid +// having to thread a custom id through every hook call site. Confirm this +// default is still current for your installed version. +// +// The Foundry hosted agent's OWN identity (src/agent.py AGENT_NAME, +// hosted/responses/agent.yaml, backend/bridge_app.py) is a SEPARATE constant +// (e.g. "my-hosted-agent") — the two identifiers do not have to match. +export const AGENT_NAME = "default"; diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/package.json.snippet b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/package.json.snippet new file mode 100644 index 000000000..8f6a4d5a0 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/package.json.snippet @@ -0,0 +1,18 @@ +{ + "dependencies-snippet": "Merge these into your frontend/package.json — do not overwrite unrelated fields. These are the last-known-good pinned versions verified live together; check npm for newer releases and watch for deprecation warnings (a CopilotKit npm prerelease has shipped broken/mistaken before) before trusting this as-is.", + "dependencies": { + "@ag-ui/client": "0.0.57", + "@copilotkit/react-core": "1.61.2", + "@copilotkit/runtime": "1.61.2", + "next": "15.5.19", + "react": "19.2.7", + "react-dom": "19.2.7", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "22.10.2", + "@types/react": "19.2.3", + "@types/react-dom": "19.2.3", + "typescript": "5.7.2" + } +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py new file mode 100644 index 000000000..db9b9459c --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Foundry hosted-agent entry point. + +Wraps the shared build_hosted_agent() (src/agent.py, the single brain) in a +ResponsesHostServer so it can run: + - locally, via `azd ai agent run` (this file executed directly), and + - deployed, as the published Azure AI Foundry hosted agent (same image/code). + +Prefer generating this file (and agent.yaml/azure.yaml/Dockerfile/infra/) with +`azd ai agent init -m ` instead of hand-copying it — the import +below (`agent_framework_foundry_hosting.ResponsesHostServer`) is shown here to +illustrate the shape, but the exact package/module name has moved before and +may move again; trust what the generated scaffold's own `main.py` imports. +""" + +import os +import sys + +# Make the project-root `src/` package importable regardless of the current +# working directory this script is launched from (local `azd ai agent run` +# runs it with cwd=hosted/responses; a container build also typically +# preserves this relative layout). +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from agent_framework_foundry_hosting import ResponsesHostServer # noqa: E402 +from dotenv import load_dotenv # noqa: E402 + +from src.agent import build_hosted_agent # noqa: E402 + +load_dotenv() + + +def main() -> None: + agent = build_hosted_agent() + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js new file mode 100644 index 000000000..2f9cecf61 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js @@ -0,0 +1,133 @@ +// Real browser E2E for the app, driven by Playwright against the LOCAL +// stack: Next.js frontend (3000) -> bridge (8080) -> hosted Foundry agent +// running locally via `azd ai agent run` (8088). +// +// Scenarios (for the example "records" domain in src/agent.py — rename the +// tool prompts / record ids below if you changed the domain): +// 1. Read: "list pending records for alice" -> assistant text + tool-render +// card, no approval UI. +// 2. HITL pause: "approve REC-1002" -> confirm_changes card appears with +// Approve/Reject buttons. +// 3. Approve: click Approve -> tool re-executes server-side, state changes +// (verified by asking again afterward: REC-1002 no longer pending). +// 4. Reject: fresh record (REC-1003) -> click Reject -> state unchanged +// (verified: REC-1003 still pending afterward). +// +// Screenshots are saved to ../e2e-screenshots/ as evidence. + +const { chromium } = require("playwright"); +const path = require("path"); +const fs = require("fs"); +const assert = require("assert"); + +const BASE_URL = "http://localhost:3000"; +const SCREENSHOT_DIR = path.join(__dirname, "..", "e2e-screenshots"); + +async function sendMessage(page, text) { + const textarea = page.locator("textarea, [contenteditable='true'], input[type='text']").first(); + await textarea.click(); + await textarea.fill(text); + await textarea.press("Enter"); +} + +async function waitForAssistantIdle(page, timeoutMs = 45000) { + // Poll until the chat's visible text stops changing (streaming finished), + // as a robust proxy for "run complete" that doesn't depend on internal + // CSS class names. Do NOT use `page.waitForLoadState("networkidle")` here — + // an SSE connection to the bridge keeps the network "busy" indefinitely. + await page.waitForTimeout(1500); + const start = Date.now(); + let previous = null; + let stableCount = 0; + while (Date.now() - start < timeoutMs) { + const current = await page.locator("body").innerText(); + if (current === previous) { + stableCount += 1; + if (stableCount >= 4) break; + } else { + stableCount = 0; + } + previous = current; + await page.waitForTimeout(1000); + } + // Extra settle buffer in case of a final re-render after the text stabilizes. + await page.waitForTimeout(1500); +} + +async function main() { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + + const consoleErrors = []; + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + + console.log("== 1. Load app =="); + await page.goto(BASE_URL, { waitUntil: "load", timeout: 60000 }); + await page.waitForTimeout(2000); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "01-loaded.png"), fullPage: true }); + + console.log("== 2. Read: list pending records for alice =="); + await sendMessage(page, "List pending records for alice"); + await waitForAssistantIdle(page); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-read-alice.png"), fullPage: true }); + const bodyTextAfterRead = await page.locator("body").innerText(); + assert(/REC-1002/.test(bodyTextAfterRead), "expected REC-1002 to appear in the chat's read result"); + console.log(" OK - read result visible"); + + console.log("== 3. HITL pause: approve REC-1002 =="); + await sendMessage(page, "Approve REC-1002."); + // Wait specifically for the approval card to render. + const approvalCard = page.locator("[data-testid='hitl-approval-card']"); + await approvalCard.first().waitFor({ state: "visible", timeout: 30000 }); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "03-hitl-pause.png"), fullPage: true }); + console.log(" OK - confirm_changes approval card appeared (run PAUSED)"); + + console.log("== 4. Approve =="); + await page.locator("[data-testid='hitl-approve-button']").first().click(); + await waitForAssistantIdle(page); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "04-after-approve.png"), fullPage: true }); + + console.log("== 5. Verify state changed (REC-1002 no longer pending) =="); + await sendMessage(page, "List pending records for alice again."); + await waitForAssistantIdle(page); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "05-verify-approved.png"), fullPage: true }); + const bodyAfterApprove = await page.locator("body").innerText(); + assert( + !/REC-1002/i.test(bodyAfterApprove.split("List pending records for alice again").pop() || bodyAfterApprove), + "REC-1002 should no longer be listed as pending after approval" + ); + console.log(" OK - REC-1002 no longer pending (state changed server-side)"); + + console.log("== 6. HITL reject: approve REC-1003 (bob), then reject =="); + await sendMessage(page, "Approve REC-1003."); + const approvalCard2 = page.locator("[data-testid='hitl-approval-card']"); + await approvalCard2.first().waitFor({ state: "visible", timeout: 30000 }); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "06-hitl-pause-reject-flow.png"), fullPage: true }); + + await page.locator("[data-testid='hitl-reject-button']").first().click(); + await waitForAssistantIdle(page); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "07-after-reject.png"), fullPage: true }); + + console.log("== 7. Verify state UNCHANGED (REC-1003 still pending) =="); + await sendMessage(page, "List pending records for bob."); + await waitForAssistantIdle(page); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "08-verify-rejected.png"), fullPage: true }); + const bodyAfterReject = await page.locator("body").innerText(); + const lastChunk = bodyAfterReject.split("List pending records for bob").pop() || bodyAfterReject; + assert(/REC-1003/i.test(lastChunk), "REC-1003 should still be listed as pending after reject"); + console.log(" OK - REC-1003 still pending (state unchanged after reject)"); + + console.log("\n== Console errors observed =="); + console.log(consoleErrors.length ? consoleErrors.join("\n") : "(none)"); + + await browser.close(); + console.log("\nALL E2E SCENARIOS PASSED"); +} + +main().catch((err) => { + console.error("E2E FAILED:", err); + process.exit(1); +}); diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py new file mode 100644 index 000000000..0c423fef8 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""smoke.py — the bridge against the REAL Foundry hosted agent running +locally via `azd ai agent run`. + +Requires: + - The hosted agent running locally (DIRECT mode), e.g.: + cd hosted && azd ai agent run --no-inspector --no-prompt + (needs `az login` + a provisioned Foundry project). + - The bridge running and pointed at it, e.g.: + HOSTED_AGENT_DIRECT_URL=http://localhost:8088 \\ + uvicorn bridge_app:app --port 8080 (from backend/) + +Env overrides: BRIDGE_URL (default http://localhost:8080/agent). + +Exercises, through the bridge (not the raw hosted agent), for the example +"records" domain in src/agent.py — rename the tool names / regex below if you +changed the domain: + - read tool (list_pending_records) runs and returns a result + - the consequential tool (approve_record) PAUSES with a confirm_changes + tool call (not silently executed) + - approve -> mcp_approval_response{approve:true} -> tool re-executes, + state changes server-side (verified by re-querying pending records) + - reject -> mcp_approval_response{approve:false} -> tool does NOT execute, + state unchanged +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import uuid + +import httpx + +BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://localhost:8080/agent") + +PASS: list[str] = [] +FAIL: list[str] = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + if cond: + PASS.append(name) + print(f" OK {name}") + else: + FAIL.append(name) + print(f" FAIL {name} {detail}") + + +def run_turn(thread_id: str, run_id: str, messages: list[dict]) -> list[dict]: + body = { + "threadId": thread_id, + "runId": run_id, + "state": None, + "messages": messages, + "tools": [], + "context": [], + "forwardedProps": None, + } + events: list[dict] = [] + with httpx.Client(timeout=60.0) as client: + with client.stream("POST", BRIDGE_URL, json=body) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if line.startswith("data:"): + payload = line[len("data:") :].strip() + if payload: + events.append(json.loads(payload)) + return events + + +def find(events: list[dict], **kwargs) -> dict | None: + for e in events: + if all(e.get(k) == v for k, v in kwargs.items()): + return e + return None + + +def assistant_text(events: list[dict]) -> str: + return "".join(e.get("delta", "") for e in events if e.get("type") == "TEXT_MESSAGE_CONTENT") + + +def tool_result_text(events: list[dict], tool_call_id: str) -> str | None: + e = find(events, type="TOOL_CALL_RESULT", toolCallId=tool_call_id) + return e.get("content") if e else None + + +def main() -> int: + print(f"== smoke.py against {BRIDGE_URL} ==") + + # C1: read tool works + t1 = f"smoke-read-{uuid.uuid4()}" + events = run_turn(t1, "r1", [{"id": "m1", "role": "user", "content": "List pending records for alice"}]) + tool_start = find(events, type="TOOL_CALL_START", toolCallName="list_pending_records") + check("C1 read tool call started", tool_start is not None) + result_text = tool_result_text(events, tool_start["toolCallId"]) if tool_start else None + check("C1 read tool produced a result", bool(result_text), detail=str(result_text)[:120]) + check("C1 run finished cleanly", find(events, type="RUN_FINISHED") is not None) + check("C1 no RUN_ERROR", find(events, type="RUN_ERROR") is None) + + # Discover a pending record id to exercise HITL against, from C1's result. + record_id = None + if result_text: + m = re.search(r"(REC-\d+)", result_text) + record_id = m.group(1) if m else None + check("C1 found a pending record id to test HITL with", record_id is not None, detail=str(result_text)) + if not record_id: + print("Cannot continue HITL checks without a pending record id.") + return 1 if FAIL else 0 + + # C2: HITL pause — consequential tool must NOT execute inline. + t2 = f"smoke-hitl-{uuid.uuid4()}" + events2 = run_turn(t2, "r1", [{"id": "m1", "role": "user", "content": f"Approve {record_id}."}]) + confirm_start = find(events2, type="TOOL_CALL_START", toolCallName="confirm_changes") + check("C2 confirm_changes tool call appears (HITL pause)", confirm_start is not None) + check( + "C2 the underlying tool has NO TOOL_CALL_RESULT yet (paused, not executed)", + find(events2, type="TOOL_CALL_RESULT", toolCallName="approve_record") is None, + ) + if not confirm_start: + print("Cannot continue: no confirm_changes call to approve/reject.") + return 1 + + tool_call_id = confirm_start["toolCallId"] + + # C3: reject -> state unchanged + events3 = run_turn( + t2, + "r2", + [ + {"id": "m1", "role": "user", "content": f"Approve {record_id}."}, + {"id": "m2", "role": "tool", "toolCallId": tool_call_id, "content": json.dumps({"accepted": False, "steps": []})}, + ], + ) + check("C3 reject run finishes without error", find(events3, type="RUN_ERROR") is None) + + t_verify = f"smoke-verify-{uuid.uuid4()}" + events_verify = run_turn(t_verify, "r1", [{"id": "m1", "role": "user", "content": "List pending records for alice"}]) + text_after_reject = assistant_text(events_verify) + check(f"C3 {record_id} still pending after reject", record_id in text_after_reject, detail=text_after_reject[:200]) + + # C4: fresh HITL + approve -> tool re-executes, state changes. + t4 = f"smoke-approve-{uuid.uuid4()}" + events4a = run_turn(t4, "r1", [{"id": "m1", "role": "user", "content": f"Approve {record_id}."}]) + confirm_start2 = find(events4a, type="TOOL_CALL_START", toolCallName="confirm_changes") + check("C4 confirm_changes appears again for the approve path", confirm_start2 is not None) + if confirm_start2: + events4b = run_turn( + t4, + "r2", + [ + {"id": "m1", "role": "user", "content": f"Approve {record_id}."}, + { + "id": "m2", + "role": "tool", + "toolCallId": confirm_start2["toolCallId"], + "content": json.dumps({"accepted": True, "steps": []}), + }, + ], + ) + check("C4 approve run finishes without error", find(events4b, type="RUN_ERROR") is None) + + t_verify2 = f"smoke-verify2-{uuid.uuid4()}" + events_verify2 = run_turn( + t_verify2, "r1", [{"id": "m1", "role": "user", "content": "List pending records for alice"}] + ) + text_after_approve = assistant_text(events_verify2) + check( + f"C4 {record_id} NO LONGER pending after approve (tool re-executed server-side)", + record_id not in text_after_approve, + detail=text_after_approve[:200], + ) + + print(f"\n{len(PASS)} passed, {len(FAIL)} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh new file mode 100644 index 000000000..7efd19907 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Structural checks — no network calls, no running services required. +# Verifies the file layout, the bridge wiring, the HITL contract, the +# FoundryChatClient requirement, agent-name consistency, and MCR base images. +# Adapt the tool/file names below if you renamed anything from the snippets. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL=0 + +pass() { echo " OK $1"; } +fail() { echo " FAIL $1"; FAIL=1; } + +check_file() { + if [ -f "$ROOT/$1" ]; then pass "$1 exists"; else fail "$1 is missing"; fi +} + +echo "== Layout ==" +check_file "src/agent.py" +check_file "backend/bridge_app.py" +check_file "backend/hosted_proxy.py" +check_file "backend/hosted_client.py" +check_file "backend/requirements.txt" +check_file "backend/Dockerfile" +check_file "hosted/azure.yaml" +check_file "hosted/responses/main.py" +check_file "hosted/responses/agent.yaml" +check_file "hosted/responses/Dockerfile" +check_file "frontend/package.json" +check_file "frontend/app/providers.tsx" +check_file "frontend/app/api/copilotkit/[[...slug]]/route.ts" +check_file "frontend/components/ApprovalHitl.tsx" + +echo "== src/agent.py: FoundryChatClient + HITL tool ==" +if grep -q "FoundryChatClient" "$ROOT/src/agent.py"; then + pass "uses FoundryChatClient (Responses) — required for hosted mcp_approval_response resume" +else + fail "does NOT use FoundryChatClient — Chat Completions 500s on hosted approve-resume" +fi +if grep -q 'approval_mode="always_require"' "$ROOT/src/agent.py"; then + pass "at least one tool has approval_mode=\"always_require\"" +else + fail "no consequential tool is gated with approval_mode=\"always_require\"" +fi +if grep -q 'approval_mode="never_require"' "$ROOT/src/agent.py"; then + pass "has at least one read (no side-effect) tool" +else + fail "no read-only tool found" +fi + +echo "== backend/bridge_app.py + hosted_proxy.py: bridge forwards HITL ==" +if grep -q "run_turn" "$ROOT/backend/bridge_app.py" && grep -q "hosted_proxy" "$ROOT/backend/bridge_app.py"; then + pass "bridge_app.py mounts the hosted-proxy turn logic (hosted_proxy.run_turn)" +else + fail "bridge_app.py does not appear to mount the hosted proxy" +fi +if grep -q "mcp_approval_response" "$ROOT/backend/hosted_proxy.py"; then + pass "hosted_proxy.py forwards mcp_approval_response on approve/reject (re-executes server-side)" +else + fail "hosted_proxy.py never sends mcp_approval_response — HITL approve would not re-execute" +fi +if grep -q "confirm_changes" "$ROOT/backend/hosted_proxy.py"; then + pass "hosted_proxy.py surfaces the gated tool as an approval-request card" +else + fail "hosted_proxy.py does not surface an approval-request card" +fi +if grep -qE 'headers\[.x-ms-user-isolation-key.\]|"x-ms-user-isolation-key":' "$ROOT/backend/hosted_client.py"; then + fail "hosted_client.py sets x-ms-user-isolation-key — deployed agents use Entra isolation (400)" +else + pass "hosted_client.py does not send x-ms-user-isolation-key" +fi +if grep -q "ping" "$ROOT/backend/bridge_app.py"; then + pass "bridge_app.py has an SSE keep-alive" +else + fail "bridge_app.py has no SSE keep-alive" +fi + +echo "== HITL contract consistency (your chosen shape, both sides) ==" +if grep -q '"accepted"' "$ROOT/backend/hosted_proxy.py" && grep -q "accepted" "$ROOT/frontend/components/ApprovalHitl.tsx"; then + pass "backend detects \"accepted\" in the resolved payload; frontend responds with the same shape" +else + fail "HITL contract mismatch — the frontend respond(...) shape and the bridge's parser disagree" +fi + +echo "== Agent name consistency (src/agent.py <-> hosted/*/agent.yaml) ==" +AGENT_PY_NAME=$(grep -oE 'AGENT_NAME = "[^"]+"' "$ROOT/src/agent.py" | head -1 | sed -E 's/AGENT_NAME = "([^"]+)"/\1/') +YAML_NAME=$(grep -oE '^name: [a-zA-Z0-9_.-]+' "$ROOT/hosted/responses/agent.yaml" | head -1 | sed -E 's/name: //') +echo " src/agent.py AGENT_NAME = $AGENT_PY_NAME" +echo " hosted/responses/agent.yaml = $YAML_NAME" +if [ "$AGENT_PY_NAME" = "$YAML_NAME" ] && [ -n "$AGENT_PY_NAME" ]; then + pass "agent name is consistent between src/agent.py and agent.yaml" +else + fail "agent name DRIFTS between src/agent.py and agent.yaml — see values printed above" +fi + +echo "== Frontend: CopilotKit provider / route wiring ==" +if grep -q "react-core/v2" "$ROOT/frontend/app/providers.tsx"; then + pass "providers.tsx imports the /v2 subpath (@copilotkit/react-core/v2)" +else + fail "providers.tsx does not import the /v2 subpath — confirm this is still correct for your version" +fi +if grep -q "createCopilotRuntimeHandler\|createCopilotHonoHandler\|createCopilotEndpoint" "$ROOT/frontend/app/api/copilotkit/[[...slug]]/route.ts"; then + pass "route.ts wires a CopilotKit runtime handler" +else + fail "route.ts does not appear to wire a CopilotKit runtime handler" +fi +if grep -q "useHumanInTheLoop" "$ROOT/frontend/components/ApprovalHitl.tsx"; then + pass "ApprovalHitl.tsx uses the useHumanInTheLoop hook" +else + fail "no useHumanInTheLoop hook found" +fi + +echo "== Containers: MCR base images (no Docker Hub) ==" +for dockerfile in "backend/Dockerfile" "hosted/responses/Dockerfile"; do + if grep -q "mcr.microsoft.com" "$ROOT/$dockerfile"; then + pass "$dockerfile uses an MCR base image" + else + fail "$dockerfile does NOT use an MCR base image (Docker Hub rate-limit risk)" + fi +done + +echo +if [ "$FAIL" -eq 0 ]; then + echo "verify.sh: ALL CHECKS PASSED" + exit 0 +else + echo "verify.sh: SOME CHECKS FAILED" + exit 1 +fi diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py new file mode 100644 index 000000000..e5e0d4892 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft. All rights reserved. +"""The single brain for the hosted agent — ADAPT THIS to your domain. + +build_hosted_agent() returns an agent_framework.Agent backed by FoundryChatClient +(Responses protocol). This is the SAME code that runs locally via +`azd ai agent run` and, once deployed, as the Azure AI Foundry hosted agent. + +This example domain is a generic "records" assistant: +- list_pending_records(owner) -> READ tool, no side effects. +- approve_record(record_id) -> CONSEQUENTIAL tool, gated with + approval_mode="always_require" so a human must explicitly confirm before + it executes. Rename/replace with your own read tool(s) and your own + gated/consequential tool(s) — keep at least one of each. + +Confirm the import paths below against your installed `agent-framework-*` +packages before trusting them — they have moved between `agent_framework_foundry` +and `agent_framework.foundry` across versions; `azd ai agent init` generates +a scaffold using whichever form is current for your installed extension. +""" + +import os +from typing import Any + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from azure.identity import DefaultAzureCredential +from pydantic import Field +from typing_extensions import Annotated + +AGENT_NAME = "my-hosted-agent" # keep this consistent with hosted/*/agent.yaml + +# -------------------------------------------------------------------------- +# In-memory backing store — replace with a real data source/API call. +# -------------------------------------------------------------------------- +_RECORDS: dict[str, dict[str, Any]] = { + "REC-1001": {"id": "REC-1001", "owner": "alice", "description": "Example record 1", "status": "pending"}, + "REC-1002": {"id": "REC-1002", "owner": "alice", "description": "Example record 2", "status": "pending"}, + "REC-1003": {"id": "REC-1003", "owner": "bob", "description": "Example record 3", "status": "pending"}, +} + + +@tool(approval_mode="never_require") +def list_pending_records( + owner: Annotated[str, Field(description="The owner to look up pending records for, e.g. 'alice'.")], +) -> str: + """List all PENDING records for a given owner. Read-only, no side effects.""" + records = [r for r in _RECORDS.values() if r["owner"] == owner and r["status"] == "pending"] + if not records: + return f"No pending records found for '{owner}'." + lines = [f"- {r['id']}: {r['description']} (status: {r['status']})" for r in records] + return f"Pending records for '{owner}':\n" + "\n".join(lines) + + +@tool(approval_mode="always_require") +def approve_record( + record_id: Annotated[str, Field(description="The record id to approve, e.g. 'REC-1001'.")], +) -> str: + """Approve a record and mark it as APPROVED. + + This is a consequential action and therefore ALWAYS requires explicit + human confirmation before it executes. Replace this with your own + consequential action (send payment, delete data, send an email, etc.) — + keep the `approval_mode="always_require"` decorator. + """ + record = _RECORDS.get(record_id) + if record is None: + return f"No record found with id '{record_id}'." + if record["status"] == "approved": + return f"Record {record_id} was already approved." + record["status"] = "approved" + return f"Record {record_id} ({record['description']}) has been approved." + + +def build_hosted_agent() -> Agent: + """Build the hosted agent: FoundryChatClient (Responses) + tools + HITL.""" + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + ) + + return Agent( + client=client, + name=AGENT_NAME, + instructions=( + "You are an assistant that manages records for a small team. " + "You can look up a user's PENDING records by calling " + "list_pending_records directly whenever asked. When the user asks " + "you to approve a record, immediately call the approve_record tool " + "with that record id — do NOT ask the user to confirm in your own " + "words first, and do not describe what you are about to do instead " + "of calling the tool. The platform itself will pause and collect " + "explicit human approval before the tool executes, so you must " + "always invoke the tool for any approval request and simply report " + "back whatever the tool result says afterward. Keep answers brief " + "and reference record ids explicitly." + ), + tools=[list_pending_records, approve_record], + # History is managed by the hosting infrastructure (Foundry Agent + # Service), so there's no need for the client to also persist it. + default_options={"store": False}, + ) From 732786298e0b4b78aab15dfe370ca7379be57b5e Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 12:37:32 +0800 Subject: [PATCH 07/12] Simplify layout: flatten agent.py into hosted/, merge bridge into one file Move src/agent.py -> hosted/responses/agent.py (no other consumer, no more sys.path hack) and merge the 3-file backend/ bridge into one bridge_app.py. Updated all skill docs + verify.sh to match; re-verified end-to-end against a real local hosted agent (12/12 smoke checks, verify.sh all pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry-hosted-agent-copilotkit.agent.md | 13 +- .../foundry-hosted-agent-copilotkit/SKILL.md | 58 ++-- .../references/architecture.md | 35 +- .../references/hosted-deploy.md | 11 +- .../references/snippets/README.md | 22 +- .../references/snippets/backend/bridge_app.py | 320 +++++++++++++++++- .../snippets/backend/hosted_client.py | 112 ------ .../snippets/backend/hosted_proxy.py | 228 ------------- .../frontend/components/ApprovalHitl.tsx | 2 +- .../frontend/components/ToolCards.tsx | 2 +- .../references/snippets/frontend/lib/agent.ts | 2 +- .../{src => hosted/responses}/agent.py | 5 + .../snippets/hosted/responses/main.py | 22 +- .../snippets/scripts/browser_e2e.js | 2 +- .../references/snippets/scripts/smoke.py | 2 +- .../references/snippets/scripts/verify.sh | 68 ++-- .../references/troubleshooting.md | 4 +- 17 files changed, 441 insertions(+), 467 deletions(-) delete mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py delete mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py rename skills/foundry-hosted-agent-copilotkit/references/snippets/{src => hosted/responses}/agent.py (92%) diff --git a/agents/foundry-hosted-agent-copilotkit.agent.md b/agents/foundry-hosted-agent-copilotkit.agent.md index ef866003f..cd107f9ca 100644 --- a/agents/foundry-hosted-agent-copilotkit.agent.md +++ b/agents/foundry-hosted-agent-copilotkit.agent.md @@ -29,11 +29,14 @@ anti-patterns, and Definition of Done exactly. ## Your workflow -1. **Scaffold** the project layout described in the skill's `SKILL.md` and - `references/` (Next.js/CopilotKit frontend, a bridge you write, `src/agent.py` - hosted-agent brain). Bootstrap the `hosted/` folder with - `azd ai agent init -m ` (`azd ai agent sample list` to - discover manifests) rather than hand-writing it. +1. **Scaffold** by copying `references/snippets/` (see its README) and + bootstrapping `hosted/` with `azd ai agent init -m ` + (`azd ai agent sample list` to discover manifests) rather than + hand-writing either. Keep the layout flat: the agent brain + (`agent.py`) lives right next to `main.py` inside `hosted/responses/` + (no separate top-level `src/`), and the bridge is ONE file + (`backend/bridge_app.py`), not split into several — don't add files or + import indirection the app doesn't need. 2. **Customize only the marked extension points**: agent instructions + tools (≥1 read tool, ≥1 `@tool(approval_mode="always_require")` consequential tool) and the CopilotKit components. Map "needs approval before X" to the gated tool. diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md index 39dbf6222..ddb1c67bb 100644 --- a/skills/foundry-hosted-agent-copilotkit/SKILL.md +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -19,7 +19,7 @@ layer: chat, generative cards, forms, approval, and shared/predictive state. ``` Next.js + CopilotKit (frontend/) Foundry HOSTED agent = the BRAIN - useAgent / useFrontendTool / src/agent.py build_hosted_agent(): + useAgent / useFrontendTool / hosted/responses/agent.py build_hosted_agent(): useRenderTool / useHumanInTheLoop FoundryChatClient (Responses) route.ts (CopilotKit runtime + HttpAgent) ALL @tools + HITL + history │ AG-UI / SSE ▲ Responses (stream) + @@ -76,12 +76,24 @@ this stack correct. **Copy `references/snippets/` into your new project first** (see `references/snippets/README.md` for the file → destination mapping), then rename the example "records"/`REC-...` domain to your actual domain in -`src/agent.py`, `backend/hosted_proxy.py`, and the frontend components. This -gets you a working read tool + gated tool + bridge + HITL UI + structural -check + smoke test + browser E2E in one step, instead of deriving the AG-UI -event translation, the CopilotKit hook names, and the HITL wiring from -scratch — that derivation is the single biggest source of wasted time on this -stack. +`hosted/responses/agent.py`, `backend/bridge_app.py`, and the frontend +components. This gets you a working read tool + gated tool + bridge + HITL UI ++ structural check + smoke test + browser E2E in one step, instead of +deriving the AG-UI event translation, the CopilotKit hook names, and the HITL +wiring from scratch — that derivation is the single biggest source of wasted +time on this stack. + +**Keep the layout flat.** The agent's brain (`agent.py`) lives directly next +to `main.py` inside `hosted/responses/` — there's no separate top-level +`src/` and no `sys.path` manipulation, because nothing else imports it (the +bridge talks to the running hosted agent over HTTP, not in-process). The +bridge is a SINGLE file (`backend/bridge_app.py`), not split into a +"proxy"/"client"/"app" trio — it's one concern (translate the hosted agent's +Responses stream to AG-UI, forward HITL decisions). Don't introduce more +files or import indirection than this unless you have an actual reason to +(e.g. genuinely reusing `agent.py` from a second entry point, or the bridge +growing enough domain logic that splitting it clarifies rather than +obscures). **Bootstrap `hosted/` (the Foundry hosted-agent project) with the `azd` Foundry extension's own scaffolder — do not hand-write it:** @@ -100,7 +112,10 @@ against `references/snippets/hosted/responses/main.py` — read the generated `agent-framework-foundry-hosting`, separate from `agent-framework-foundry`; `FoundryChatClient` may be importable from either `agent_framework.foundry` or `agent_framework_foundry` depending on version). Do not guess; read the -generated scaffold and `pip show` the installed packages. +generated scaffold and `pip show` the installed packages. Because `agent.py` +lives inside `hosted/responses/` (not a separate `src/`), the generated +`azure.yaml`'s Docker build `context` can stay at its default (`.`) instead +of reaching up to a parent directory. Then place the rest of the app around it (lowercase-hyphen app name), per the architecture in `references/architecture.md`: @@ -112,20 +127,23 @@ architecture in `references/architecture.md`: components/ # useFrontendTool / useRenderTool / # useHumanInTheLoop / useAgent backend/ - bridge_app.py # your bridge — forwards turns + mcp_approval_response - src/agent.py # build_hosted_agent() -> FoundryChatClient (Responses) + bridge_app.py # your ENTIRE bridge — one file (see above) hosted/ # generated by `azd ai agent init` above + responses/ + agent.py # build_hosted_agent() -> FoundryChatClient (Responses) + main.py # ResponsesHostServer(build_hosted_agent()).run() ``` -Keep one agent-name token (`AGENT_NAME`, the hosted yaml) consistent across -`src/agent.py` and `hosted/`. The CopilotKit-facing agent registry key is a -**separate** identifier — see the CopilotKit section below; it does not have +Keep one agent-name token (`AGENT_NAME`, the hosted yaml) consistent within +`hosted/responses/` (`agent.py` and `agent.yaml`). The CopilotKit-facing +agent registry key is a **separate** identifier — see the CopilotKit section +below; it does not have to match `AGENT_NAME`. The result must run end-to-end and pass the checks in step 3 before you customize anything. ## 2. Customize to the user's prompt — extension points -Edit `src/agent.py` (the hosted brain via `build_hosted_agent()`): +Edit `hosted/responses/agent.py` (the hosted brain via `build_hosted_agent()`): - Instructions — the agent's behavior for the requested domain. - Tools — keep **≥1 read tool** (no side effects) and **≥1 consequential tool** decorated `@tool(approval_mode="always_require")`. Map the user's "needs approval @@ -146,7 +164,7 @@ version's own bundled docs/types): `useAgent` (shared / predictive state). **Do NOT touch** (load-bearing and proven): -- `build_hosted_agent()` (`FoundryChatClient`, Responses) in `src/agent.py` — +- `build_hosted_agent()` (`FoundryChatClient`, Responses) in `hosted/responses/agent.py` — this is the one non-negotiable client choice (see Load-bearing rules below); - the HITL forwarding behavior in your bridge (whatever you name it) — every approve/reject decision MUST reach the hosted agent as an @@ -187,7 +205,7 @@ deployed hosted agent, since all logic is server-side. committing to this path. 2. **Hand-rolled (verified working end-to-end on a real local hosted agent — a ready-to-copy implementation is in - `references/snippets/backend/{bridge_app.py,hosted_proxy.py,hosted_client.py}`):** + `references/snippets/backend/bridge_app.py`):** write a small FastAPI endpoint that speaks the AG-UI wire protocol directly, using the `ag-ui-protocol` Python package's own `ag_ui.core` event/model classes (`RunAgentInput`, `RunStartedEvent`, @@ -259,7 +277,7 @@ proxy — treat it as extra work, not a given. 404s. Do not assume which default is current; verify empirically against a real request. - **Agent identifier is two separate things:** the Foundry hosted agent's own - name (`src/agent.py` `AGENT_NAME`, `hosted/agent.yaml`) is independent from + name (`hosted/responses/agent.py` `AGENT_NAME`, `hosted/responses/agent.yaml`) is independent from the CopilotKit-facing registry key (the key you register in `CopilotRuntime({ agents: { : ... } })`). Hooks that omit an explicit `agentId` typically resolve against the literal string `"default"` — so @@ -295,15 +313,15 @@ The app is **not** done until all are true (evidence-backed): - [ ] Structural check green (bridge forwards HITL; `build_hosted_agent` uses `FoundryChatClient`; HITL contract consistent between frontend and - bridge; agent name consistent within `src/agent.py`/`hosted/`; MCR base + bridge; agent name consistent within `hosted/responses/` (agent.py/agent.yaml); MCR base images). - [ ] Smoke E2E green: the bridge against the REAL agent (run locally via `azd ai agent run`) shows read works; the consequential prompt PAUSES; approve executes; reject does not; and for shared/predictive patterns in scope, state flows. -- [ ] `src/agent.py` has `build_hosted_agent()` (`FoundryChatClient`), ≥1 read tool +- [ ] `hosted/responses/agent.py` has `build_hosted_agent()` (`FoundryChatClient`), ≥1 read tool and ≥1 `approval_mode="always_require"` tool. -- [ ] Agent name is consistent within `src/agent.py` and `hosted/` (the +- [ ] Agent name is consistent within `hosted/responses/` (agent.py and agent.yaml) (the CopilotKit-facing agent key is a separate identifier — see above). - [ ] No secrets, endpoints, or app-specific hard-coding committed. - [ ] **Live** (the deployed path drives a server-side agent): deployment succeeds, the diff --git a/skills/foundry-hosted-agent-copilotkit/references/architecture.md b/skills/foundry-hosted-agent-copilotkit/references/architecture.md index 9dce28521..a96ee3b1e 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/architecture.md +++ b/skills/foundry-hosted-agent-copilotkit/references/architecture.md @@ -4,10 +4,11 @@ with a CopilotKit UI showing rich generative UI — tool-render cards, human-in-the- loop approval, shared/predictive state. -> **No template ships with this skill.** Every symbol below (`HostedProxyAgent`, -> `bridge_app.py`, etc.) is illustrative naming for a design you implement -> yourself, verified live once (see `REVIEW_NOTES`-style evidence in your own -> build), not a package or file you can import. Bootstrap `hosted/` with +> **This skill ships copy-adapt starter code, not an installable template.** +> `references/snippets/` has a verified-working minimum for every symbol +> below (`bridge_app.py`, `agent.py`, etc.) — copy it in and adapt rather than +> writing from scratch, but treat every concrete name as "true as of when +> this was written," not a guarantee. Bootstrap `hosted/` with > `azd ai agent init -m ` (`azd ai agent sample list` to discover > manifests) rather than hand-writing it — this generates the currently-correct > `main.py`/`agent.yaml`/`azure.yaml`/`Dockerfile`/`infra/` for your installed @@ -41,7 +42,7 @@ end-to-end against a real local hosted agent). │ POST .../agents//endpoint/protocols/openai/responses (stream) ▼ FOUNDRY HOSTED AGENT (the brain — azd → host: azure.ai.agent) - src/agent.py build_hosted_agent(): FoundryChatClient (Responses), store=False + hosted/responses/agent.py build_hosted_agent(): FoundryChatClient (Responses), store=False ALL @tools + @tool(approval_mode="always_require") HITL + history server-side ``` @@ -96,20 +97,24 @@ native `FoundryAgent` path now suffices before building any forwarder at all. ``` / -├── src/ -│ └── agent.py ONE agent. build_hosted_agent() → FoundryChatClient -│ (the single brain — same code local + deployed). Read tools -│ + ≥1 @tool(approval_mode="always_require"). -├── backend/ THE BRIDGE (deployed Container App) — you write this. -│ └── bridge_app.py (+ helpers) AG-UI endpoint that forwards turns + translates -│ Responses → AG-UI; surfaces an approval card; forwards -│ mcp_approval_response. + SSE keepalive + optional API key. ├── hosted/ Bootstrap via `azd ai agent init` — azd → Foundry HOSTED │ │ agent (Responses) — the deployed brain. -│ ├── azure.yaml host: azure.ai.agent; azure.ai.agents pinned; context=root. -│ └── responses/ main.py = ResponsesHostServer(build_hosted_agent()) — confirm +│ ├── azure.yaml host: azure.ai.agent; azure.ai.agents pinned; context=".". +│ └── responses/ +│ ├── agent.py ONE agent. build_hosted_agent() → FoundryChatClient +│ │ (the single brain — same code local + deployed). Read tools +│ │ + ≥1 @tool(approval_mode="always_require"). Lives right next +│ │ to main.py — no separate top-level src/, since nothing else +│ │ imports it (the bridge talks to it over HTTP, not in-process). +│ └── main.py ResponsesHostServer(build_hosted_agent()).run() — confirm │ which package ships `ResponsesHostServer` for your installed │ version (may be a separate `*-foundry-hosting` package). +├── backend/ THE BRIDGE (deployed Container App) — you write this. +│ └── bridge_app.py ONE file: AG-UI endpoint + the streaming Responses HTTP +│ client + the translation + HITL forwarding + SSE keepalive. +│ Splitting this into separate proxy/client modules is a +│ reasonable adaptation once it grows a lot of domain logic, +│ but isn't necessary at this size — don't start there. ├── frontend/ Next.js + CopilotKit (useAgent/useFrontendTool/ │ useRenderTool/useHumanInTheLoop). └── scripts/ verify.sh (structural), smoke.py (E2E vs the real local agent), diff --git a/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md b/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md index 91c30f304..655d2ab2f 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md +++ b/skills/foundry-hosted-agent-copilotkit/references/hosted-deploy.md @@ -48,10 +48,13 @@ azd up # provision + remote-build the image + publish the agent ``` `azd up` builds the image with **remote build** (so no local Docker needed) from -the appropriate build context (make sure the shared `src/agent.py` is included in -that context if it lives outside `hosted/`), provisions the model deployment -declared in `hosted/azure.yaml`, and publishes the hosted agent described by -`agent.yaml` / `agent.manifest.yaml`. +the appropriate build context — if you keep `agent.py` next to `main.py` +inside `hosted/responses/` (the recommended, flatter layout — see +`architecture.md`), the default context (`.`) already includes it, and there +is nothing extra to configure. Only widen the context if you have a genuine +reason to import shared code from outside `hosted/`. `azd up` also +provisions the model deployment declared in `hosted/azure.yaml`, and +publishes the hosted agent described by `agent.yaml` / `agent.manifest.yaml`. ## Gotchas (also in troubleshooting.md) diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md index 761f7a7a3..5cf75b935 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md @@ -16,14 +16,21 @@ plumbing (the bridge translation, the HITL contract, the agent name wiring) intact unless you have a specific reason to change it, and re-verify after any change with your own structural check + smoke test + browser E2E. +Kept intentionally FLAT and FEW files — resist the urge to split further: +`agent.py` lives directly next to `main.py` in `hosted/responses/` (nothing +else imports it, so there's no reason for a separate top-level `src/` plus a +`sys.path` hack), and the whole bridge is ONE file (`bridge_app.py`) instead +of three, since it's all one concern (translate the hosted agent's stream to +AG-UI, forward HITL decisions). If you have an actual reason to split +something out later (reused elsewhere, genuinely large), that's a fine +adaptation — just don't start there. + | File | Copy to | Purpose | | --- | --- | --- | -| [`src/agent.py`](src/agent.py) | `src/agent.py` | The hosted brain: `build_hosted_agent()`, `FoundryChatClient`, one read tool, one gated tool. | -| [`backend/bridge_app.py`](backend/bridge_app.py) | `backend/bridge_app.py` | FastAPI AG-UI endpoint; SSE keep-alive. | -| [`backend/hosted_proxy.py`](backend/hosted_proxy.py) | `backend/hosted_proxy.py` | Translates the hosted agent's Responses stream to AG-UI events; forwards `mcp_approval_response`. | -| [`backend/hosted_client.py`](backend/hosted_client.py) | `backend/hosted_client.py` | Streaming Responses HTTP driver (DIRECT local / platform deployed). | -| [`backend/requirements.txt`](backend/requirements.txt) | `backend/requirements.txt` | Bridge-only deps (no agent-framework/foundry packages — the bridge runs no model). | +| [`hosted/responses/agent.py`](hosted/responses/agent.py) | `hosted/responses/agent.py` | The hosted brain: `build_hosted_agent()`, `FoundryChatClient`, one read tool, one gated tool. | | [`hosted/responses/main.py`](hosted/responses/main.py) | `hosted/responses/main.py` | Entry point wrapping `build_hosted_agent()` in `ResponsesHostServer`. Prefer generating this (and `agent.yaml`/`azure.yaml`/`Dockerfile`/`infra/`) with `azd ai agent init` instead — this file is here mainly to show the import shape. | +| [`backend/bridge_app.py`](backend/bridge_app.py) | `backend/bridge_app.py` | The ENTIRE bridge in one file: FastAPI AG-UI endpoint, the streaming Responses HTTP client (DIRECT local / platform deployed), the Responses→AG-UI translation, HITL forwarding, and an SSE keep-alive. | +| [`backend/requirements.txt`](backend/requirements.txt) | `backend/requirements.txt` | Bridge-only deps (no agent-framework/foundry packages — the bridge runs no model). | | [`frontend/app/api/copilotkit/[[...slug]]/route.ts`]() | same path | CopilotKit runtime handler pointed at the bridge. | | [`frontend/app/providers.tsx`](frontend/app/providers.tsx) | `frontend/app/providers.tsx` | `` provider + HITL/tool-card component registration. | | [`frontend/components/ApprovalHitl.tsx`](frontend/components/ApprovalHitl.tsx) | `frontend/components/` | `useHumanInTheLoop` example for the gated tool. | @@ -40,7 +47,10 @@ any change with your own structural check + smoke test + browser E2E. `hosted/infra/` — generate these with `azd ai agent init -m ` (`azd ai agent sample list` to discover manifests) so they match your installed `azd` Foundry extension version exactly, rather than trusting a - hand-copied Bicep/Dockerfile that can drift out of date. + hand-copied Bicep/Dockerfile that can drift out of date. Because `agent.py` + now lives inside `hosted/responses/` (not a separate top-level `src/`), the + generated `azure.yaml`'s Docker build `context` can stay `.` (the default) + instead of needing to reach up to a parent directory. - `backend/Dockerfile` — a one-line MCR-based Dockerfile running `uvicorn bridge_app:app`; write your own to match your bridge's actual dependencies (keep the MCR base image — see `troubleshooting.md`). diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py index a622cb1e0..0d5c68379 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py @@ -1,15 +1,24 @@ # Copyright (c) Microsoft. All rights reserved. """The bridge: a thin AG-UI endpoint over the Foundry hosted agent. -No LLM, no tools, and no business logic live here — this process only: +No LLM, no tools, and no business logic live here — this single file only: 1. Accepts an AG-UI `RunAgentInput` from the CopilotKit runtime (`route.ts`). - 2. Forwards the turn to the hosted agent (`hosted_proxy.run_turn`, which - talks to `hosted_client`), in DIRECT mode locally (`azd ai agent run`) - or platform mode once deployed. - 3. Streams back native AG-UI SSE events (text, tool-call cards, an - approval-request card) and forwards the human's approve/reject decision - as an `mcp_approval_response` so the gated tool re-executes server-side - on approve. + 2. Forwards the turn to the hosted agent's Responses endpoint — its local + URL via `azd ai agent run` (DIRECT mode), or its deployed platform + endpoint once you deploy — and translates the streamed Responses events + into AG-UI SSE events 1:1 (text, tool-call cards). + 3. When the hosted agent pauses on a gated tool (an `mcp_approval_request`), + surfaces a synthetic `confirm_changes` tool call for the frontend's + `useHumanInTheLoop` hook, and forwards the human's decision back as an + `mcp_approval_response` so the gated tool re-executes server-side on + approve. + +Kept as ONE file on purpose: everything here is one concern (translate + +forward), and splitting it into separate "proxy" / "client" modules for a +~250-line starter mostly adds import indirection without adding clarity. If +your bridge grows a lot of domain-specific translation logic, splitting it +back out is a reasonable adaptation — there's nothing wrong with the split, +it just isn't necessary at this size. An SSE keep-alive comment is emitted periodically so a gateway/proxy in front of this app doesn't drop the connection while the hosted agent is silently @@ -19,22 +28,301 @@ from __future__ import annotations import asyncio +import json import logging import os +import uuid +from collections.abc import AsyncIterator +from typing import Any -from ag_ui.core import RunAgentInput +import httpx +from ag_ui.core import ( + RunAgentInput, + RunErrorEvent, + RunFinishedEvent, + RunStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, + ToolMessage, + UserMessage, +) from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import ValidationError -from hosted_proxy import run_turn - logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) logger = logging.getLogger("bridge") -AGENT_NAME = "my-hosted-agent" # keep this consistent with src/agent.py AGENT_NAME +AGENT_NAME = "my-hosted-agent" # keep this consistent with hosted/responses/agent.py + +# -------------------------------------------------------------------------- +# Hosted-agent HTTP client — two modes, selected by which env vars are set. +# -------------------------------------------------------------------------- +# DIRECT (local dev): HOSTED_AGENT_DIRECT_URL points at the agent started +# by `azd ai agent run` (e.g. http://localhost:8088). Talks straight to +# the local ResponsesHostServer, no auth. +# PLATFORM (deployed): FOUNDRY_PROJECT_ENDPOINT + HOSTED_AGENT_NAME reach +# the published Foundry hosted agent's Responses endpoint keyless (Entra +# / DefaultAzureCredential, audience https://ai.azure.com/.default). +# Verify this path against a real deployed agent before relying on it — +# the endpoint URL shape below was correct when last verified but this +# stack moves fast. +_DIRECT_URL = os.environ.get("HOSTED_AGENT_DIRECT_URL", "http://localhost:8088") +_FOUNDRY_PROJECT_ENDPOINT = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") +_HOSTED_AGENT_NAME = os.environ.get("HOSTED_AGENT_NAME") +_MODEL_DEPLOYMENT = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini") +_AAD_SCOPE = "https://ai.azure.com/.default" + + +def _is_platform_mode() -> bool: + return bool(_FOUNDRY_PROJECT_ENDPOINT and _HOSTED_AGENT_NAME) + + +async def _get_bearer_token() -> str: + """Keyless Entra token for the deployed (platform) hosted agent. + + Requesting the `https://ai.azure.com/.default` audience is load-bearing — + the default Cognitive Services audience 401s ("audience is incorrect"). + """ + from azure.identity.aio import DefaultAzureCredential + + async with DefaultAzureCredential() as cred: + token = await cred.get_token(_AAD_SCOPE) + return token.token + + +def _target_url() -> str: + if _is_platform_mode(): + # Deployed hosted-agent endpoint shape — confirm this is still + # current for your azure.ai.agents extension version: + # POST {project_endpoint}/agents/{agent_name}/endpoint/protocols/openai/responses + base = _FOUNDRY_PROJECT_ENDPOINT.rstrip("/") # type: ignore[union-attr] + return f"{base}/agents/{_HOSTED_AGENT_NAME}/endpoint/protocols/openai/responses" + # DIRECT mode: local `azd ai agent run` (ResponsesHostServer) instance. + return f"{_DIRECT_URL.rstrip('/')}/responses" + + +async def _stream_responses( + *, input_items: list[dict[str, Any]], previous_response_id: str | None +) -> AsyncIterator[dict[str, Any]]: + """POST to the hosted agent's Responses endpoint and yield parsed SSE events.""" + headers = {"Content-Type": "application/json", "Accept": "text/event-stream"} + # NOTE: deployed agents use Entra isolation; sending a manual + # `x-ms-user-isolation-key` header causes a 400. Do not add one here. + if _is_platform_mode(): + headers["Authorization"] = f"Bearer {await _get_bearer_token()}" + + body: dict[str, Any] = {"model": _MODEL_DEPLOYMENT, "input": input_items, "stream": True} + if previous_response_id: + body["previous_response_id"] = previous_response_id + + async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client: + async with client.stream("POST", _target_url(), headers=headers, json=body) as resp: + resp.raise_for_status() + data_lines: list[str] = [] + async for raw_line in resp.aiter_lines(): + line = raw_line.rstrip("\n") + if line == "": + if data_lines: + payload = "\n".join(data_lines) + data_lines = [] + if payload.strip(): + try: + yield json.loads(payload) + except json.JSONDecodeError: + continue + continue + if line.startswith("data:"): + data_lines.append(line[len("data:") :].strip()) + # `event: ` lines are redundant with the `type` field in + # the JSON payload, so they're intentionally ignored here. + + +# -------------------------------------------------------------------------- +# Per-thread turn state (in-memory; single replica only — see hosted-deploy.md) +# -------------------------------------------------------------------------- +# last_response_id: the hosted agent's previous response id, so the next +# turn chains history server-side via `previous_response_id`. +# pending_approval: the outstanding approval-gated tool call awaiting a +# human decision (one open approval per thread in this simple version — +# extend if you need concurrent approvals). +_THREAD_STATE: dict[str, dict[str, Any]] = {} + +# The synthetic tool name the frontend's `useHumanInTheLoop` hook listens for. +# This name (and the resolved payload shape below) is a convention YOU define +# and must keep in sync with the frontend — CopilotKit doesn't enforce either. +CONFIRM_CHANGES_TOOL = "confirm_changes" + + +def _thread_state(thread_id: str) -> dict[str, Any]: + return _THREAD_STATE.setdefault(thread_id, {"last_response_id": None, "pending_approval": None}) + + +def _latest_user_text(messages: list[Any]) -> str: + """The most recent user message's text (a fresh turn, not an approval).""" + for msg in reversed(messages): + if isinstance(msg, UserMessage): + content = msg.content + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [getattr(p, "text", "") for p in content if getattr(p, "type", None) == "text"] + return "\n".join(p for p in parts if p) + return "" + + +def _find_approval_decision(messages: list[Any], pending: dict[str, Any] | None) -> bool | None: + """Detect a `confirm_changes` resolution from the frontend. + + `useHumanInTheLoop.respond({accepted, steps})` round-trips as a ToolMessage + whose `tool_call_id` matches the pending confirm_changes call and whose + content is `{"accepted": bool, "steps": [...]}` — this exact shape is a + convention this snippet defines; pick your own and keep both sides + consistent if you change it. + """ + if not pending: + return None + for msg in reversed(messages): + if isinstance(msg, ToolMessage) and msg.tool_call_id == pending.get("tool_call_id"): + try: + parsed = json.loads(msg.content) if isinstance(msg.content, str) else msg.content + except (json.JSONDecodeError, TypeError): + continue + if isinstance(parsed, dict) and "accepted" in parsed: + return bool(parsed["accepted"]) + return None + + +async def _run_turn(run_input: RunAgentInput) -> AsyncIterator[Any]: + """Run one AG-UI turn against the hosted agent; yields AG-UI events.""" + thread_id = run_input.thread_id + run_id = run_input.run_id + state = _thread_state(thread_id) + messages = run_input.messages + + yield RunStartedEvent(thread_id=thread_id, run_id=run_id) + + pending = state.get("pending_approval") + decision = _find_approval_decision(messages, pending) + + if decision is not None and pending is not None: + input_items: list[dict[str, Any]] = [ + { + "type": "mcp_approval_response", + "approval_request_id": pending["approval_request_id"], + "approve": decision, + } + ] + previous_response_id = pending["response_id"] + state["pending_approval"] = None + else: + input_items = [{"role": "user", "content": [{"type": "input_text", "text": _latest_user_text(messages)}]}] + previous_response_id = state.get("last_response_id") + + try: + async for event in _translate_stream(state=state, input_items=input_items, previous_response_id=previous_response_id): + yield event + except Exception as exc: # noqa: BLE001 - surface any hosted-agent error to the UI + logger.exception("Hosted agent call failed") + yield RunErrorEvent(message=str(exc)) + return + + yield RunFinishedEvent(thread_id=thread_id, run_id=run_id) + + +async def _translate_stream( + *, state: dict[str, Any], input_items: list[dict[str, Any]], previous_response_id: str | None +) -> AsyncIterator[Any]: + """Translate one Responses stream into AG-UI events; updates thread state.""" + text_started: set[str] = set() + + async for event in _stream_responses(input_items=input_items, previous_response_id=previous_response_id): + etype = event.get("type") + + if etype == "response.output_text.delta": + item_id = event["item_id"] + delta = event.get("delta", "") + if item_id not in text_started: + text_started.add(item_id) + yield TextMessageStartEvent(message_id=item_id, role="assistant") + if delta: + yield TextMessageContentEvent(message_id=item_id, delta=delta) + + elif etype == "response.output_item.done": + item = event.get("item", {}) + item_type = item.get("type") + + if item_type == "message" and item.get("id") in text_started: + yield TextMessageEndEvent(message_id=item["id"]) + + elif item_type == "function_call": + call_id = item["call_id"] + yield ToolCallStartEvent(tool_call_id=call_id, tool_call_name=item["name"]) + yield ToolCallArgsEvent(tool_call_id=call_id, delta=item.get("arguments", "") or "{}") + yield ToolCallEndEvent(tool_call_id=call_id) + + elif item_type == "function_call_output": + yield ToolCallResultEvent( + message_id=str(uuid.uuid4()), + tool_call_id=item["call_id"], + content=str(item.get("output", "")), + role="tool", + ) + + elif item_type == "mcp_approval_request": + # The gated tool paused. Surface it as the synthetic + # `confirm_changes` tool call the frontend's + # useHumanInTheLoop hook renders and resolves. + function_name = item["name"] + function_arguments = item.get("arguments", "{}") + tool_call_id = str(uuid.uuid4()) + args_payload = json.dumps( + { + "function_name": function_name, + "function_arguments": function_arguments, + "steps": [ + { + "description": ( + f"Call {function_name} with arguments {function_arguments}. " + "This is a consequential action and needs your explicit approval." + ) + } + ], + } + ) + yield ToolCallStartEvent(tool_call_id=tool_call_id, tool_call_name=CONFIRM_CHANGES_TOOL) + yield ToolCallArgsEvent(tool_call_id=tool_call_id, delta=args_payload) + yield ToolCallEndEvent(tool_call_id=tool_call_id) + + state["pending_approval"] = { + "tool_call_id": tool_call_id, + "approval_request_id": item["id"], + # Filled in once `response.completed` arrives below — an + # mcp_approval_response must chain off the response.id + # that CONTAINS the approval request. + "response_id": None, + } + + elif etype == "response.completed": + response = event.get("response", {}) + response_id = response.get("response_id") or response.get("id") + state["last_response_id"] = response_id + if state.get("pending_approval") is not None and state["pending_approval"].get("response_id") is None: + state["pending_approval"]["response_id"] = response_id + + elif etype == "error": + raise RuntimeError(event.get("message") or "hosted agent returned an error event") + +# -------------------------------------------------------------------------- +# FastAPI app +# -------------------------------------------------------------------------- app = FastAPI(title="agent bridge") app.add_middleware( @@ -58,7 +346,7 @@ async def _sse_stream(run_input: RunAgentInput): async def _produce() -> None: try: - async for event in run_turn(run_input): + async for event in _run_turn(run_input): await queue.put(event) finally: await queue.put(DONE) @@ -88,10 +376,8 @@ async def health() -> dict: @app.post("/") @app.post("/agent") async def run_agent(request: Request) -> StreamingResponse: - if _API_KEY: - provided = request.headers.get("x-api-key") - if provided != _API_KEY: - return StreamingResponse(iter([]), status_code=401) + if _API_KEY and request.headers.get("x-api-key") != _API_KEY: + return StreamingResponse(iter([]), status_code=401) body = await request.json() try: diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py deleted file mode 100644 index 205c20fe0..000000000 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_client.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""Streaming Responses driver for the Foundry hosted agent. - -Two modes, selected by which env vars are set: - - DIRECT (local dev): HOSTED_AGENT_DIRECT_URL points at the agent started by - `azd ai agent run` (e.g. http://localhost:8088). Talks straight to the - local ResponsesHostServer, no auth. - - PLATFORM (deployed): FOUNDRY_PROJECT_ENDPOINT + HOSTED_AGENT_NAME reach the - published Foundry hosted agent's Responses endpoint keyless (Entra / - DefaultAzureCredential, audience https://ai.azure.com/.default). Verify - this path against a real deployed agent before relying on it — the - endpoint URL shape below was correct when last verified but this stack - moves fast. - -Both modes speak the same OpenAI Responses streaming wire format (SSE: -`event: ` / `data: `), so the parsing logic below is shared. -""" - -from __future__ import annotations - -import json -import os -from collections.abc import AsyncIterator -from typing import Any - -import httpx - -_DIRECT_URL = os.environ.get("HOSTED_AGENT_DIRECT_URL", "http://localhost:8088") -_FOUNDRY_PROJECT_ENDPOINT = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") -_HOSTED_AGENT_NAME = os.environ.get("HOSTED_AGENT_NAME") -_MODEL_DEPLOYMENT = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini") - -_AAD_SCOPE = "https://ai.azure.com/.default" - - -def is_platform_mode() -> bool: - """True once the app is pointed at a deployed hosted agent.""" - return bool(_FOUNDRY_PROJECT_ENDPOINT and _HOSTED_AGENT_NAME) - - -async def _get_bearer_token() -> str: - """Keyless Entra token for the deployed (platform) hosted agent. - - Requesting the `https://ai.azure.com/.default` audience is load-bearing — - the default Cognitive Services audience 401s ("audience is incorrect"). - """ - from azure.identity.aio import DefaultAzureCredential - - async with DefaultAzureCredential() as cred: - token = await cred.get_token(_AAD_SCOPE) - return token.token - - -def _target_url() -> tuple[str, dict[str, str]]: - """Resolve the Responses endpoint + headers for the current mode.""" - if is_platform_mode(): - # Deployed hosted-agent endpoint shape — confirm this is still - # current for your azure.ai.agents extension version: - # POST {project_endpoint}/agents/{agent_name}/endpoint/protocols/openai/responses - base = _FOUNDRY_PROJECT_ENDPOINT.rstrip("/") # type: ignore[union-attr] - url = f"{base}/agents/{_HOSTED_AGENT_NAME}/endpoint/protocols/openai/responses" - return url, {} - # DIRECT mode: local `azd ai agent run` (ResponsesHostServer) instance. - return f"{_DIRECT_URL.rstrip('/')}/responses", {} - - -async def stream_responses( - *, - input_items: list[dict[str, Any]], - previous_response_id: str | None = None, -) -> AsyncIterator[dict[str, Any]]: - """POST to the hosted agent's Responses endpoint and yield parsed SSE events. - - Each yielded item is the parsed JSON payload of one `data:` line (the - OpenAI Responses streaming event envelope: {"type": ..., ...}). - """ - url, base_headers = _target_url() - headers = {"Content-Type": "application/json", "Accept": "text/event-stream", **base_headers} - - # NOTE: deployed agents use Entra isolation; sending a manual - # `x-ms-user-isolation-key` header causes a 400. Do not add one here. - if is_platform_mode(): - headers["Authorization"] = f"Bearer {await _get_bearer_token()}" - - body: dict[str, Any] = { - "model": _MODEL_DEPLOYMENT, - "input": input_items, - "stream": True, - } - if previous_response_id: - body["previous_response_id"] = previous_response_id - - async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client: - async with client.stream("POST", url, headers=headers, json=body) as resp: - resp.raise_for_status() - data_lines: list[str] = [] - async for raw_line in resp.aiter_lines(): - line = raw_line.rstrip("\n") - if line == "": - if data_lines: - payload = "\n".join(data_lines) - data_lines = [] - if payload.strip(): - try: - yield json.loads(payload) - except json.JSONDecodeError: - continue - continue - if line.startswith("data:"): - data_lines.append(line[len("data:") :].strip()) - # `event: ` lines are redundant with the `type` field in - # the JSON payload, so they're intentionally ignored here. diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py deleted file mode 100644 index 0d831524c..000000000 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/hosted_proxy.py +++ /dev/null @@ -1,228 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -"""HostedProxyAgent — forwards each AG-UI turn to the Foundry hosted agent and -translates the Responses stream into native AG-UI SSE events. - -This is one way to implement the bridge the skill calls for (see -architecture.md strategy 2 — hand-rolled AG-UI translation): the native -`add_agent_framework_fastapi_endpoint(FoundryAgent(...))` path resolves an -approval decision LOCALLY and never forwards it as an `mcp_approval_response`, -so an approved gated tool never re-executes server-side. This proxy exists -purely to close that one gap: it forwards the human's decision to the hosted -agent so the tool re-executes there, and otherwise just translates Responses -events to AG-UI 1:1. - -Per-thread state (in-memory; single replica only — see hosted-deploy.md): - - last_response_id: the hosted agent's previous `response_id`, so the next - turn chains history server-side via `previous_response_id`. - - pending_approval: the outstanding approval-gated tool call awaiting a - human decision (a thread can only have one open approval at a time in - this simple version — extend if you need concurrent approvals). -""" - -from __future__ import annotations - -import json -import logging -import uuid -from collections.abc import AsyncIterator -from typing import Any - -from ag_ui.core import ( - RunAgentInput, - RunErrorEvent, - RunFinishedEvent, - RunStartedEvent, - TextMessageContentEvent, - TextMessageEndEvent, - TextMessageStartEvent, - ToolCallArgsEvent, - ToolCallEndEvent, - ToolCallResultEvent, - ToolCallStartEvent, - ToolMessage, - UserMessage, -) - -from hosted_client import stream_responses - -logger = logging.getLogger(__name__) - -# The synthetic tool name the frontend's `useHumanInTheLoop` hook listens for. -# This name (and the resolved payload shape below) is a convention YOU define -# and must keep in sync with the frontend — CopilotKit doesn't enforce either. -CONFIRM_CHANGES_TOOL = "confirm_changes" - -# thread_id -> {"last_response_id": str | None, "pending_approval": dict | None} -_THREAD_STATE: dict[str, dict[str, Any]] = {} - - -def _thread_state(thread_id: str) -> dict[str, Any]: - return _THREAD_STATE.setdefault(thread_id, {"last_response_id": None, "pending_approval": None}) - - -def _latest_user_text(messages: list[Any]) -> str: - """The most recent user message's text (a fresh turn, not an approval).""" - for msg in reversed(messages): - if isinstance(msg, UserMessage): - content = msg.content - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [getattr(p, "text", "") for p in content if getattr(p, "type", None) == "text"] - return "\n".join(p for p in parts if p) - return "" - - -def _find_approval_decision(messages: list[Any], pending: dict[str, Any] | None) -> bool | None: - """Detect a `confirm_changes` resolution from the frontend. - - `useHumanInTheLoop.respond({accepted, steps})` round-trips as a ToolMessage - whose `tool_call_id` matches the pending confirm_changes call and whose - content is `{"accepted": bool, "steps": [...]}` — this exact shape is a - convention this snippet defines; pick your own and keep both sides - consistent if you change it. - """ - if not pending: - return None - for msg in reversed(messages): - if isinstance(msg, ToolMessage) and msg.tool_call_id == pending.get("tool_call_id"): - try: - parsed = json.loads(msg.content) if isinstance(msg.content, str) else msg.content - except (json.JSONDecodeError, TypeError): - continue - if isinstance(parsed, dict) and "accepted" in parsed: - return bool(parsed["accepted"]) - return None - - -async def run_turn(run_input: RunAgentInput) -> AsyncIterator[Any]: - """Run one AG-UI turn against the hosted agent; yields AG-UI events.""" - thread_id = run_input.thread_id - run_id = run_input.run_id - state = _thread_state(thread_id) - messages = run_input.messages - - yield RunStartedEvent(thread_id=thread_id, run_id=run_id) - - pending = state.get("pending_approval") - decision = _find_approval_decision(messages, pending) - - if decision is not None and pending is not None: - input_items: list[dict[str, Any]] = [ - { - "type": "mcp_approval_response", - "approval_request_id": pending["approval_request_id"], - "approve": decision, - } - ] - previous_response_id = pending["response_id"] - state["pending_approval"] = None - else: - text = _latest_user_text(messages) - input_items = [{"role": "user", "content": [{"type": "input_text", "text": text}]}] - previous_response_id = state.get("last_response_id") - - try: - async for event in _translate_stream( - state=state, - input_items=input_items, - previous_response_id=previous_response_id, - ): - yield event - except Exception as exc: # noqa: BLE001 - surface any hosted-agent error to the UI - logger.exception("Hosted agent call failed") - yield RunErrorEvent(message=str(exc)) - return - - yield RunFinishedEvent(thread_id=thread_id, run_id=run_id) - - -async def _translate_stream( - *, - state: dict[str, Any], - input_items: list[dict[str, Any]], - previous_response_id: str | None, -) -> AsyncIterator[Any]: - """Translate one Responses stream into AG-UI events; updates thread state.""" - text_started: set[str] = set() - - async for event in stream_responses(input_items=input_items, previous_response_id=previous_response_id): - etype = event.get("type") - - if etype == "response.output_text.delta": - item_id = event["item_id"] - delta = event.get("delta", "") - if item_id not in text_started: - text_started.add(item_id) - yield TextMessageStartEvent(message_id=item_id, role="assistant") - if delta: - yield TextMessageContentEvent(message_id=item_id, delta=delta) - - elif etype == "response.output_item.done": - item = event.get("item", {}) - item_type = item.get("type") - - if item_type == "message" and item.get("id") in text_started: - yield TextMessageEndEvent(message_id=item["id"]) - - elif item_type == "function_call": - call_id = item["call_id"] - name = item["name"] - yield ToolCallStartEvent(tool_call_id=call_id, tool_call_name=name) - yield ToolCallArgsEvent(tool_call_id=call_id, delta=item.get("arguments", "") or "{}") - yield ToolCallEndEvent(tool_call_id=call_id) - - elif item_type == "function_call_output": - call_id = item["call_id"] - yield ToolCallResultEvent( - message_id=str(uuid.uuid4()), - tool_call_id=call_id, - content=str(item.get("output", "")), - role="tool", - ) - - elif item_type == "mcp_approval_request": - # The gated tool paused. Surface it as the synthetic - # `confirm_changes` tool call the frontend's - # useHumanInTheLoop hook renders and resolves. - function_name = item["name"] - function_arguments = item.get("arguments", "{}") - tool_call_id = str(uuid.uuid4()) - args_payload = json.dumps( - { - "function_name": function_name, - "function_arguments": function_arguments, - "steps": [ - { - "description": ( - f"Call {function_name} with arguments {function_arguments}. " - "This is a consequential action and needs your explicit approval." - ) - } - ], - } - ) - yield ToolCallStartEvent(tool_call_id=tool_call_id, tool_call_name=CONFIRM_CHANGES_TOOL) - yield ToolCallArgsEvent(tool_call_id=tool_call_id, delta=args_payload) - yield ToolCallEndEvent(tool_call_id=tool_call_id) - - state["pending_approval"] = { - "tool_call_id": tool_call_id, - "approval_request_id": item["id"], - "function_name": function_name, - "function_arguments": function_arguments, - # Filled in once `response.completed` arrives below — an - # mcp_approval_response must chain off the response.id - # that CONTAINS the approval request. - "response_id": None, - } - - elif etype == "response.completed": - response = event.get("response", {}) - response_id = response.get("response_id") or response.get("id") - state["last_response_id"] = response_id - if state.get("pending_approval") is not None and state["pending_approval"].get("response_id") is None: - state["pending_approval"]["response_id"] = response_id - - elif etype == "error": - raise RuntimeError(event.get("message") or "hosted agent returned an error event") diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx index c453ed637..f75b6848a 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ApprovalHitl.tsx @@ -12,7 +12,7 @@ import { AGENT_NAME } from "../lib/agent"; * Resolves with `{ accepted, steps }` — this exact shape is a convention * THIS SNIPPET defines, not something CopilotKit enforces (`respond(result)` * accepts any value). Pick your own shape if you like, but keep the frontend - * `respond(...)` call and your bridge's parser in sync — see hosted_proxy.py. + * `respond(...)` call and your bridge's parser in sync — see backend/bridge_app.py. */ const confirmChangesArgs = z.object({ function_name: z.string(), diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx index 0c5b9f13f..4be3e292f 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/components/ToolCards.tsx @@ -11,7 +11,7 @@ import { AGENT_NAME } from "../lib/agent"; * `useRenderTool` gives each named tool its own progress/result card. * * Rename `list_pending_records`/`approve_record` (and the parameter schemas) - * to match whatever tools you defined in src/agent.py. + * to match whatever tools you defined in hosted/responses/agent.py. */ export function ToolCards() { useRenderTool({ diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts index a5fd6d6b0..e268a4bc8 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/lib/agent.ts @@ -6,7 +6,7 @@ // having to thread a custom id through every hook call site. Confirm this // default is still current for your installed version. // -// The Foundry hosted agent's OWN identity (src/agent.py AGENT_NAME, +// The Foundry hosted agent's OWN identity (hosted/responses/agent.py AGENT_NAME, // hosted/responses/agent.yaml, backend/bridge_app.py) is a SEPARATE constant // (e.g. "my-hosted-agent") — the two identifiers do not have to match. export const AGENT_NAME = "default"; diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/agent.py similarity index 92% rename from skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py rename to skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/agent.py index e5e0d4892..e6d0d66e9 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/src/agent.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/agent.py @@ -4,6 +4,11 @@ build_hosted_agent() returns an agent_framework.Agent backed by FoundryChatClient (Responses protocol). This is the SAME code that runs locally via `azd ai agent run` and, once deployed, as the Azure AI Foundry hosted agent. +It lives next to main.py (not in a separate top-level src/) because nothing +else in this app imports it directly — the bridge talks to the running +hosted agent over HTTP, not in-process. If you have a reason to reuse this +module from somewhere else (a test script, another entry point), moving it +to a shared location is a fine adaptation — just update main.py's import. This example domain is a generic "records" assistant: - list_pending_records(owner) -> READ tool, no side effects. diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py index db9b9459c..f6a28dcd3 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/hosted/responses/main.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. """Foundry hosted-agent entry point. -Wraps the shared build_hosted_agent() (src/agent.py, the single brain) in a -ResponsesHostServer so it can run: +Wraps build_hosted_agent() (agent.py, right next to this file — the single +brain) in a ResponsesHostServer so it can run: - locally, via `azd ai agent run` (this file executed directly), and - deployed, as the published Azure AI Foundry hosted agent (same image/code). @@ -13,21 +13,9 @@ may move again; trust what the generated scaffold's own `main.py` imports. """ -import os -import sys - -# Make the project-root `src/` package importable regardless of the current -# working directory this script is launched from (local `azd ai agent run` -# runs it with cwd=hosted/responses; a container build also typically -# preserves this relative layout). -_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -if _ROOT not in sys.path: - sys.path.insert(0, _ROOT) - -from agent_framework_foundry_hosting import ResponsesHostServer # noqa: E402 -from dotenv import load_dotenv # noqa: E402 - -from src.agent import build_hosted_agent # noqa: E402 +from agent import build_hosted_agent +from agent_framework_foundry_hosting import ResponsesHostServer +from dotenv import load_dotenv load_dotenv() diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js index 2f9cecf61..60de79c34 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/browser_e2e.js @@ -2,7 +2,7 @@ // stack: Next.js frontend (3000) -> bridge (8080) -> hosted Foundry agent // running locally via `azd ai agent run` (8088). // -// Scenarios (for the example "records" domain in src/agent.py — rename the +// Scenarios (for the example "records" domain in hosted/responses/agent.py — rename the // tool prompts / record ids below if you changed the domain): // 1. Read: "list pending records for alice" -> assistant text + tool-render // card, no approval UI. diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py index 0c423fef8..b337722b2 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py @@ -13,7 +13,7 @@ Env overrides: BRIDGE_URL (default http://localhost:8080/agent). Exercises, through the bridge (not the raw hosted agent), for the example -"records" domain in src/agent.py — rename the tool names / regex below if you +"records" domain in hosted/responses/agent.py — rename the tool names / regex below if you changed the domain: - read tool (list_pending_records) runs and returns a result - the consequential tool (approve_record) PAUSES with a confirm_changes diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh index 7efd19907..eb4a0eb34 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/verify.sh @@ -16,81 +16,77 @@ check_file() { } echo "== Layout ==" -check_file "src/agent.py" -check_file "backend/bridge_app.py" -check_file "backend/hosted_proxy.py" -check_file "backend/hosted_client.py" -check_file "backend/requirements.txt" -check_file "backend/Dockerfile" -check_file "hosted/azure.yaml" +check_file "hosted/responses/agent.py" check_file "hosted/responses/main.py" check_file "hosted/responses/agent.yaml" check_file "hosted/responses/Dockerfile" +check_file "hosted/azure.yaml" +check_file "backend/bridge_app.py" +check_file "backend/requirements.txt" +check_file "backend/Dockerfile" check_file "frontend/package.json" check_file "frontend/app/providers.tsx" check_file "frontend/app/api/copilotkit/[[...slug]]/route.ts" check_file "frontend/components/ApprovalHitl.tsx" -echo "== src/agent.py: FoundryChatClient + HITL tool ==" -if grep -q "FoundryChatClient" "$ROOT/src/agent.py"; then +AGENT_PY="$ROOT/hosted/responses/agent.py" +BRIDGE_PY="$ROOT/backend/bridge_app.py" + +echo "== hosted/responses/agent.py: FoundryChatClient + HITL tool ==" +if grep -q "FoundryChatClient" "$AGENT_PY"; then pass "uses FoundryChatClient (Responses) — required for hosted mcp_approval_response resume" else fail "does NOT use FoundryChatClient — Chat Completions 500s on hosted approve-resume" fi -if grep -q 'approval_mode="always_require"' "$ROOT/src/agent.py"; then +if grep -q 'approval_mode="always_require"' "$AGENT_PY"; then pass "at least one tool has approval_mode=\"always_require\"" else fail "no consequential tool is gated with approval_mode=\"always_require\"" fi -if grep -q 'approval_mode="never_require"' "$ROOT/src/agent.py"; then +if grep -q 'approval_mode="never_require"' "$AGENT_PY"; then pass "has at least one read (no side-effect) tool" else fail "no read-only tool found" fi -echo "== backend/bridge_app.py + hosted_proxy.py: bridge forwards HITL ==" -if grep -q "run_turn" "$ROOT/backend/bridge_app.py" && grep -q "hosted_proxy" "$ROOT/backend/bridge_app.py"; then - pass "bridge_app.py mounts the hosted-proxy turn logic (hosted_proxy.run_turn)" -else - fail "bridge_app.py does not appear to mount the hosted proxy" -fi -if grep -q "mcp_approval_response" "$ROOT/backend/hosted_proxy.py"; then - pass "hosted_proxy.py forwards mcp_approval_response on approve/reject (re-executes server-side)" +echo "== backend/bridge_app.py: forwards HITL, translates the Responses stream ==" +if grep -q "mcp_approval_response" "$BRIDGE_PY"; then + pass "forwards mcp_approval_response on approve/reject (re-executes server-side)" else - fail "hosted_proxy.py never sends mcp_approval_response — HITL approve would not re-execute" + fail "never sends mcp_approval_response — HITL approve would not re-execute" fi -if grep -q "confirm_changes" "$ROOT/backend/hosted_proxy.py"; then - pass "hosted_proxy.py surfaces the gated tool as an approval-request card" +if grep -q "confirm_changes" "$BRIDGE_PY"; then + pass "surfaces the gated tool as an approval-request card" else - fail "hosted_proxy.py does not surface an approval-request card" + fail "does not surface an approval-request card" fi -if grep -qE 'headers\[.x-ms-user-isolation-key.\]|"x-ms-user-isolation-key":' "$ROOT/backend/hosted_client.py"; then - fail "hosted_client.py sets x-ms-user-isolation-key — deployed agents use Entra isolation (400)" +if grep -qE 'headers\[.x-ms-user-isolation-key.\]|"x-ms-user-isolation-key":' "$BRIDGE_PY"; then + fail "sets x-ms-user-isolation-key — deployed agents use Entra isolation (400)" else - pass "hosted_client.py does not send x-ms-user-isolation-key" + pass "does not send x-ms-user-isolation-key" fi -if grep -q "ping" "$ROOT/backend/bridge_app.py"; then - pass "bridge_app.py has an SSE keep-alive" +if grep -q "ping" "$BRIDGE_PY"; then + pass "has an SSE keep-alive" else - fail "bridge_app.py has no SSE keep-alive" + fail "has no SSE keep-alive" fi echo "== HITL contract consistency (your chosen shape, both sides) ==" -if grep -q '"accepted"' "$ROOT/backend/hosted_proxy.py" && grep -q "accepted" "$ROOT/frontend/components/ApprovalHitl.tsx"; then +if grep -q '"accepted"' "$BRIDGE_PY" && grep -q "accepted" "$ROOT/frontend/components/ApprovalHitl.tsx"; then pass "backend detects \"accepted\" in the resolved payload; frontend responds with the same shape" else fail "HITL contract mismatch — the frontend respond(...) shape and the bridge's parser disagree" fi -echo "== Agent name consistency (src/agent.py <-> hosted/*/agent.yaml) ==" -AGENT_PY_NAME=$(grep -oE 'AGENT_NAME = "[^"]+"' "$ROOT/src/agent.py" | head -1 | sed -E 's/AGENT_NAME = "([^"]+)"/\1/') +echo "== Agent name consistency (agent.py <-> agent.yaml) ==" +AGENT_PY_NAME=$(grep -oE 'AGENT_NAME = "[^"]+"' "$AGENT_PY" | head -1 | sed -E 's/AGENT_NAME = "([^"]+)"/\1/') YAML_NAME=$(grep -oE '^name: [a-zA-Z0-9_.-]+' "$ROOT/hosted/responses/agent.yaml" | head -1 | sed -E 's/name: //') -echo " src/agent.py AGENT_NAME = $AGENT_PY_NAME" -echo " hosted/responses/agent.yaml = $YAML_NAME" +echo " hosted/responses/agent.py AGENT_NAME = $AGENT_PY_NAME" +echo " hosted/responses/agent.yaml name = $YAML_NAME" if [ "$AGENT_PY_NAME" = "$YAML_NAME" ] && [ -n "$AGENT_PY_NAME" ]; then - pass "agent name is consistent between src/agent.py and agent.yaml" + pass "agent name is consistent between agent.py and agent.yaml" else - fail "agent name DRIFTS between src/agent.py and agent.yaml — see values printed above" + fail "agent name DRIFTS between agent.py and agent.yaml — see values printed above" fi echo "== Frontend: CopilotKit provider / route wiring ==" diff --git a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md index d480441e0..271caa089 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md +++ b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md @@ -57,7 +57,7 @@ what you actually have installed before trusting it. | --- | --- | --- | | `az acr build` fails `toomanyrequests` | Docker Hub base image | use `mcr.microsoft.com/devcontainers/...` base images. | | azd deploys the helloworld placeholder | ran `azd provision` only | run `azd up` (provision + deploy). | -| hosted image missing `src/agent.py` (or other shared code) | build context too narrow | make sure `hosted/azure.yaml`'s build context includes wherever your shared agent code actually lives. | +| hosted image missing `agent.py` (or other agent code) | build context too narrow — this shouldn't happen if `agent.py` lives inside `hosted/responses/` (the recommended layout); only occurs if you've moved shared agent code outside `hosted/` | keep `agent.py` next to `main.py` inside `hosted/responses/` so the default build context already includes it; if you deliberately keep shared code elsewhere, widen `hosted/azure.yaml`'s build context to reach it. | | container crashes at boot with `ModuleNotFoundError: No module named 'mcp'`, surfacing as HTTP 424 `session_not_ready` on invoke | the hosting package imports `mcp` but it isn't declared/pulled in transitively on a remote build | pin `mcp` explicitly in `requirements.txt`. | ## Bridge (whatever you name it) @@ -66,7 +66,7 @@ what you actually have installed before trusting it. | --- | --- | --- | | Approval card vanishes when a turn makes several tool calls | multi-tool AG-UI snapshot translation issue (see AG-UI rendering above) | verify live against your actual CopilotKit version rather than assuming a fixed rule. | | HITL approve does nothing / state doesn't change | the bridge resolved/dropped the approval locally instead of forwarding it | the single most load-bearing bridge behavior — verify with a live test: reject must leave state unchanged, approve must change it. | -| `useAgent().state` stays empty | `state_schema`/`predict_state_config` not passed to the agent, or no tool writes the state key, or your bridge doesn't relay state deltas (this pattern is roadmap through a pure proxy — see patterns-7.md) | set `AGENT_STATE_SCHEMA`/`AGENT_PREDICT_STATE`-equivalent config in `src/agent.py` and write the key from a tool; confirm your bridge actually forwards state events before assuming this pattern works out of the box. | +| `useAgent().state` stays empty | `state_schema`/`predict_state_config` not passed to the agent, or no tool writes the state key, or your bridge doesn't relay state deltas (this pattern is roadmap through a pure proxy — see patterns-7.md) | set `AGENT_STATE_SCHEMA`/`AGENT_PREDICT_STATE`-equivalent config in `hosted/responses/agent.py` and write the key from a tool; confirm your bridge actually forwards state events before assuming this pattern works out of the box. | | Deployed bridge can't reach the agent | required settings (e.g. the Foundry project endpoint, the hosted agent's name) unset | set whatever your bridge's platform-mode code needs; it should be able to reach the deployed agent keyless via Entra. | | Python `@tool` didn't run "in Foundry" | the native `FoundryAgent` client (not the hosted-agent-first `build_hosted_agent` path this skill uses) runs Python `@tool` callables CLIENT-SIDE; only Foundry-native tools run server-side | expected for that client — this skill's `build_hosted_agent`/`FoundryChatClient` path runs `@tool`s server-side in the hosted runtime instead. | | UI 500 mid-run on a long silent tool | a gateway dropped the idle SSE connection | add an SSE keep-alive (a periodic `: ping` comment, e.g. every ~10s) to your bridge's streaming response. | From 235f06287b70e6b9f80a1a4e228a15497f307369 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 13:45:35 +0800 Subject: [PATCH 08/12] Address review comments: agent name, smoke.py false-positive, 401 body - Restore human-readable agent name per AGENTS.md's checklist (name field should read e.g. 'Address Comments', not the file-name slug); an earlier commit had wrongly changed it to match the filename based on a one-time, apparently-since-superseded validator comment. Regenerated docs/README. - smoke.py C2 checked TOOL_CALL_RESULT with toolCallName=approve_record, but ToolCallResultEvent never carries a toolCallName field at all, so the assertion trivially passed regardless of whether the tool executed. Verified live against a real hosted agent that a function_call item (the model's intent) DOES appear for a gated tool before its mcp_approval_request, so checking for the absence of TOOL_CALL_START was also wrong (it's expected). Fixed to match by tool_call_id: find the TOOL_CALL_START for approve_record (if any), then assert no TOOL_CALL_RESULT exists for that same id - the real signal that the gated tool never executed. - bridge_app.py returned an empty StreamingResponse on auth failure, which looks like a silently-empty successful SSE run when debugging. Now returns a plain 401 with a body. Re-verified all three fixes live: 401 returns 'Unauthorized' as text/plain, and smoke.py passes 12/12 with the corrected (and now actually discriminating) C2 assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agents/foundry-hosted-agent-copilotkit.agent.md | 2 +- docs/README.agents.md | 2 +- .../references/snippets/backend/bridge_app.py | 11 +++++++---- .../references/snippets/scripts/smoke.py | 14 ++++++++++++-- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/agents/foundry-hosted-agent-copilotkit.agent.md b/agents/foundry-hosted-agent-copilotkit.agent.md index cd107f9ca..ba0a1dd47 100644 --- a/agents/foundry-hosted-agent-copilotkit.agent.md +++ b/agents/foundry-hosted-agent-copilotkit.agent.md @@ -2,7 +2,7 @@ description: 'Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid).' model: 'gpt-5' tools: ['codebase', 'terminalCommand'] -name: 'foundry-hosted-agent-copilotkit' +name: 'Foundry Hosted Agent + CopilotKit Builder' --- You are an expert builder of agentic web apps on the **Azure AI Foundry diff --git a/docs/README.agents.md b/docs/README.agents.md index d29a83bad..f86a24c0c 100644 --- a/docs/README.agents.md +++ b/docs/README.agents.md @@ -99,7 +99,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-agents) for guidelines on how to | [Expert React Frontend Engineer](../agents/expert-react-frontend-engineer.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fexpert-react-frontend-engineer.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fexpert-react-frontend-engineer.agent.md) | Expert React 19.2 frontend engineer specializing in modern hooks, Server Components, Actions, TypeScript, and performance optimization | | | [Expert Vue.js Frontend Engineer](../agents/vuejs-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fvuejs-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fvuejs-expert.agent.md) | Expert Vue.js frontend engineer specializing in Vue 3 Composition API, reactivity, state management, testing, and performance with TypeScript | | | [Fedora Linux Expert](../agents/fedora-linux-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffedora-linux-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffedora-linux-expert.agent.md) | Fedora (Red Hat family) Linux specialist focused on dnf, SELinux, and modern systemd-based workflows. | | -| [Foundry Hosted Agent Copilotkit](../agents/foundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md) | Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). | | +| [Foundry Hosted Agent + CopilotKit Builder](../agents/foundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffoundry-hosted-agent-copilotkit.agent.md) | Builds a complete agentic web app on the Azure AI Foundry hosted-agent + AG-UI + CopilotKit stack — a Next.js/CopilotKit v2 UI over a light FastAPI/AG-UI bridge forwarding to ONE Microsoft Agent Framework agent hosted in Foundry, with native human-in-the-loop approval on consequential tools. Requires an Azure AI Foundry project (paid). | | | [Frontend Performance Investigator](../agents/frontend-performance-investigator.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffrontend-performance-investigator.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Ffrontend-performance-investigator.agent.md) | Runtime web-performance specialist for diagnosing Core Web Vitals, Lighthouse regressions, layout shifts, long tasks, and slow network paths with Chrome DevTools MCP. | | | [Gem Browser Tester](../agents/gem-browser-tester.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-browser-tester.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-browser-tester.agent.md) | E2E browser testing, UI/UX validation, visual regression. | | | [Gem Code Simplifier](../agents/gem-code-simplifier.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-code-simplifier.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fgem-code-simplifier.agent.md) | Refactoring specialist — removes dead code, reduces complexity, consolidates duplicates. | | diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py index 0d5c68379..f3ccf9435 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py @@ -51,9 +51,9 @@ ToolMessage, UserMessage, ) -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse +from fastapi.responses import PlainTextResponse, StreamingResponse from pydantic import ValidationError logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) @@ -375,9 +375,12 @@ async def health() -> dict: @app.post("/") @app.post("/agent") -async def run_agent(request: Request) -> StreamingResponse: +async def run_agent(request: Request) -> Response: if _API_KEY and request.headers.get("x-api-key") != _API_KEY: - return StreamingResponse(iter([]), status_code=401) + # A plain 401 (not an empty event-stream) — the caller isn't expecting + # SSE at this point, and a real body makes the failure obvious when + # debugging instead of looking like a silently-empty successful run. + return PlainTextResponse("Unauthorized", status_code=401) body = await request.json() try: diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py index b337722b2..6fa59ad12 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/scripts/smoke.py @@ -115,9 +115,19 @@ def main() -> int: events2 = run_turn(t2, "r1", [{"id": "m1", "role": "user", "content": f"Approve {record_id}."}]) confirm_start = find(events2, type="TOOL_CALL_START", toolCallName="confirm_changes") check("C2 confirm_changes tool call appears (HITL pause)", confirm_start is not None) + # The hosted agent's Responses stream emits a `function_call` (the model's + # intent to call the gated tool) immediately followed by an + # `mcp_approval_request` for the SAME call — so a TOOL_CALL_START named + # "approve_record" legitimately appears even though it's paused. What must + # NOT appear is a TOOL_CALL_RESULT for that same tool_call_id (there is no + # `function_call_output` for a gated call until it's approved). Match by + # id, not name — ToolCallResultEvent carries no toolCallName field at all, + # so checking TOOL_CALL_RESULT + toolCallName here would trivially "pass" + # regardless of whether the tool actually ran. + approve_start = find(events2, type="TOOL_CALL_START", toolCallName="approve_record") check( - "C2 the underlying tool has NO TOOL_CALL_RESULT yet (paused, not executed)", - find(events2, type="TOOL_CALL_RESULT", toolCallName="approve_record") is None, + "C2 approve_record has NO TOOL_CALL_RESULT yet (paused, not executed)", + approve_start is None or find(events2, type="TOOL_CALL_RESULT", toolCallId=approve_start["toolCallId"]) is None, ) if not confirm_start: print("Cannot continue: no confirm_changes call to approve/reject.") From 17ca853f1495087c8792d9895df88b1d941923a6 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 13:54:00 +0800 Subject: [PATCH 09/12] Fix orphan-files: vally doesn't URL-decode link targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause found by installing @microsoft/vally-cli locally and reading its source: extractFileReferences' regex handles literal brackets in a link URL fine (it only excludes parens/whitespace), but orphan-files.js resolves ref.normalized directly against the filesystem with no decodeURIComponent step — so my earlier percent-encoded route.ts link (%5B%5D) never matched the real path. Fixed by using the raw literal path (brackets and all) with a bracket-free link label instead. Verified locally: vally lint now reports 3/3 checks passed, orphan-files 18/18 reachable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/snippets/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md index 5cf75b935..a111b3a16 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md @@ -31,7 +31,7 @@ adaptation — just don't start there. | [`hosted/responses/main.py`](hosted/responses/main.py) | `hosted/responses/main.py` | Entry point wrapping `build_hosted_agent()` in `ResponsesHostServer`. Prefer generating this (and `agent.yaml`/`azure.yaml`/`Dockerfile`/`infra/`) with `azd ai agent init` instead — this file is here mainly to show the import shape. | | [`backend/bridge_app.py`](backend/bridge_app.py) | `backend/bridge_app.py` | The ENTIRE bridge in one file: FastAPI AG-UI endpoint, the streaming Responses HTTP client (DIRECT local / platform deployed), the Responses→AG-UI translation, HITL forwarding, and an SSE keep-alive. | | [`backend/requirements.txt`](backend/requirements.txt) | `backend/requirements.txt` | Bridge-only deps (no agent-framework/foundry packages — the bridge runs no model). | -| [`frontend/app/api/copilotkit/[[...slug]]/route.ts`]() | same path | CopilotKit runtime handler pointed at the bridge. | +| [CopilotKit catch-all route (`route.ts`)](frontend/app/api/copilotkit/[[...slug]]/route.ts) | `frontend/app/api/copilotkit/[[...slug]]/route.ts` | CopilotKit runtime handler pointed at the bridge. | | [`frontend/app/providers.tsx`](frontend/app/providers.tsx) | `frontend/app/providers.tsx` | `` provider + HITL/tool-card component registration. | | [`frontend/components/ApprovalHitl.tsx`](frontend/components/ApprovalHitl.tsx) | `frontend/components/` | `useHumanInTheLoop` example for the gated tool. | | [`frontend/components/ToolCards.tsx`](frontend/components/ToolCards.tsx) | `frontend/components/` | `useRenderTool` examples for both tools. | From cbf1f4aeb86ed4abef02e4a5d36cd21d879a6087 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 14:40:59 +0800 Subject: [PATCH 10/12] Add the missing chat widget + CSS; add 3 gaps found by a second E2E test Ran a second independent end-to-end build test (fresh Copilot CLI session, different domain: a "PTO approver" instead of the first test's "expense approver") to verify the skill's snippets/simplification/bug-fixes actually help. Result: 12/12 smoke checks, all 7 browser E2E scenarios passed with screenshots, and verify.sh all green - and per the build's own report, the bridge snippet worked "completely unmodified except for the AGENT_NAME constant", azd ai agent init worked exactly as documented, and verify.sh/smoke.py/browser_e2e.js needed only mechanical domain renames. Total wall clock was roughly half the first test run's ~1 hour. That run also surfaced one real, valuable gap: the skill's frontend snippets only shipped providers.tsx (the wrapper + hook registrations) but never the actual chat WIDGET every consumer needs. The builder had to discover CopilotChat by reading the installed package's .d.ts files. Added: - frontend/app/page.tsx - the CopilotChat widget, generalized from this run's real, working, screenshot-verified code. - frontend/app/layout.tsx - minimal root layout wiring globals.css + Providers. - frontend/app/globals.css - plain CSS (no Tailwind dependency) for the .hitl-card/.tool-card class names, so the approval card/tool cards render legibly instead of just being present in the DOM unstyled. Verified these three files by swapping them into the actual test project and confirming `npm run build` succeeds and the page renders correctly in a headless browser (screenshot taken, matches expected UI). Also added 3 troubleshooting.md entries and 1 HITL-section entry for gaps this run's own REVIEW_NOTES.md flagged: in-memory hosted-agent state is process-lifetime (restart between verification passes or approvals leak across runs), a stale process can leave the hosted-agent port bound (Address already in use on the next `azd ai agent run`), an occasional transient DeploymentNotFound 404 on the very first request after startup (retry/restart resolves it), and keeping a renamed gated-tool parameter name in sync between the Python tool and the frontend's parsed-args cast. Verified locally with `npx @microsoft/vally-cli lint`: 3/3 checks pass, 21/21 reference files reachable from SKILL.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/snippets/README.md | 3 + .../snippets/frontend/app/globals.css | 75 +++++++++++++++++++ .../snippets/frontend/app/layout.tsx | 18 +++++ .../references/snippets/frontend/app/page.tsx | 25 +++++++ .../references/troubleshooting.md | 4 + 5 files changed, 125 insertions(+) create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/globals.css create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/layout.tsx create mode 100644 skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/page.tsx diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md index a111b3a16..5bae4db17 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/README.md @@ -32,6 +32,9 @@ adaptation — just don't start there. | [`backend/bridge_app.py`](backend/bridge_app.py) | `backend/bridge_app.py` | The ENTIRE bridge in one file: FastAPI AG-UI endpoint, the streaming Responses HTTP client (DIRECT local / platform deployed), the Responses→AG-UI translation, HITL forwarding, and an SSE keep-alive. | | [`backend/requirements.txt`](backend/requirements.txt) | `backend/requirements.txt` | Bridge-only deps (no agent-framework/foundry packages — the bridge runs no model). | | [CopilotKit catch-all route (`route.ts`)](frontend/app/api/copilotkit/[[...slug]]/route.ts) | `frontend/app/api/copilotkit/[[...slug]]/route.ts` | CopilotKit runtime handler pointed at the bridge. | +| [`frontend/app/layout.tsx`](frontend/app/layout.tsx) | `frontend/app/layout.tsx` | Root layout: imports `globals.css`, wraps children in `Providers`. | +| [`frontend/app/page.tsx`](frontend/app/page.tsx) | `frontend/app/page.tsx` | **The actual chat widget** (`CopilotChat`) — the one piece of UI every consumer needs; easy to miss since `providers.tsx` only wraps it. | +| [`frontend/app/globals.css`](frontend/app/globals.css) | `frontend/app/globals.css` | Plain CSS for the `.hitl-card`/`.tool-card` class names `ApprovalHitl.tsx`/`ToolCards.tsx` use — without this they're functionally correct but visually unstyled. | | [`frontend/app/providers.tsx`](frontend/app/providers.tsx) | `frontend/app/providers.tsx` | `` provider + HITL/tool-card component registration. | | [`frontend/components/ApprovalHitl.tsx`](frontend/components/ApprovalHitl.tsx) | `frontend/components/` | `useHumanInTheLoop` example for the gated tool. | | [`frontend/components/ToolCards.tsx`](frontend/components/ToolCards.tsx) | `frontend/components/` | `useRenderTool` examples for both tools. | diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/globals.css b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/globals.css new file mode 100644 index 000000000..edde7eecf --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/globals.css @@ -0,0 +1,75 @@ +/* Minimal, framework-agnostic CSS for the .hitl-card / .tool-card class + names used by ApprovalHitl.tsx and ToolCards.tsx. No Tailwind or other + build-time CSS dependency required — plain CSS only. Import this from + your root layout (e.g. `import "./globals.css"` in app/layout.tsx). */ + +body { + background: #ffffff; + color: #171717; + font-family: Arial, Helvetica, sans-serif; + margin: 0; +} + +.hitl-card { + border: 2px solid #d97706; + background: #fffbeb; + border-radius: 8px; + padding: 12px 16px; + margin: 8px 0; +} + +.hitl-card--done { + border-color: #16a34a; + background: #f0fdf4; +} + +.hitl-card__title { + font-weight: 700; + color: #92400e; + margin-bottom: 6px; +} + +.hitl-card__actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.hitl-card__actions button { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid #999; + cursor: pointer; + font-weight: 600; +} + +.hitl-card__actions button[data-testid="hitl-approve-button"] { + background: #16a34a; + color: white; + border-color: #16a34a; +} + +.hitl-card__actions button[data-testid="hitl-reject-button"] { + background: #dc2626; + color: white; + border-color: #dc2626; +} + +.tool-card { + border: 1px solid #ccc; + background: #f8fafc; + border-radius: 8px; + padding: 10px 14px; + margin: 8px 0; +} + +.tool-card__title { + font-weight: 600; + color: #334155; +} + +.tool-card__result { + white-space: pre-wrap; + font-size: 13px; + margin-top: 6px; +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/layout.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/layout.tsx new file mode 100644 index 000000000..97596ee80 --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Providers } from "./providers"; + +export const metadata: Metadata = { + title: "My Assistant", + description: "Internal chat assistant with human-in-the-loop approval", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/page.tsx b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/page.tsx new file mode 100644 index 000000000..024bd1e6a --- /dev/null +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/frontend/app/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CopilotChat } from "@copilotkit/react-core/v2"; +import { AGENT_NAME } from "../lib/agent"; + +// The actual chat WIDGET — the one piece of UI every consumer needs that +// isn't just plumbing. `providers.tsx` only wraps and registers +// the HITL/tool-card hooks; this page renders the chat window itself. +// `CopilotChat` takes the same `agentId` convention as the hooks (see +// lib/agent.ts for why this is a separate identifier from the Foundry +// hosted agent's own name). +export default function Home() { + return ( +
+

My Assistant

+

+ Ask about pending records, or ask to approve a specific record id — approvals require your + explicit confirmation. +

+
+ +
+
+ ); +} diff --git a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md index 271caa089..2621227b8 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md +++ b/skills/foundry-hosted-agent-copilotkit/references/troubleshooting.md @@ -11,6 +11,9 @@ what you actually have installed before trusting it. | Symptom | Cause | Fix | | --- | --- | --- | | `python3 -m venv` fails (`ensurepip is not available`) or `pip`/`apt-get install python3-venv` needs sudo you don't have | sandboxed/managed dev environment without system Python tooling | check for `uv` (`which uv`) and use `uv venv` / `uv pip install` instead — it doesn't need `ensurepip` or root. | +| `smoke.py`/`browser_e2e.js` fail (or pass for the wrong reason) when run back-to-back, or after an interrupted earlier attempt | the hosted agent's example in-memory store (`_RECORDS`/whatever dict you're using) is **process-lifetime** state — every approve/reject call, from any script, mutates the SAME shared data | restart the hosted agent process (`azd ai agent run` / `python main.py`) between independent verification passes to get back to the seeded starting state. Don't assume a fresh run starts clean if a previous script (or an interrupted tool call) already approved/rejected the same record ids. | +| A new `azd ai agent run` fails with `Address already in use` (often with a confusing hypercorn traceback) | a previous local hosted-agent process from an earlier/interrupted session is still holding the port | find and stop the old process (`ss -ltnp \| grep 8088` or similar) before starting a new one. | +| The very first request to a freshly-started local hosted agent 404s with `DeploymentNotFound`, even though the deployment demonstrably exists (`az cognitiveservices account deployment list` shows it) | occasional local-dev warm-up flake in the hosted runtime/SDK, not a real config problem | retry once, or restart the hosted agent process with the same env vars — this has been observed to resolve itself immediately on retry. | ## HITL / approval @@ -21,6 +24,7 @@ what you actually have installed before trusting it. | Clicking Approve does nothing / tool never runs | assumed a specific resolve payload shape is framework-enforced | it isn't (see the CopilotKit bridge section below) — make sure your bridge's parser actually matches what the frontend's `respond(...)` sends. | | Approve works once, next message 400s with an orphaned tool-call id | stale/replayed approval payload re-sent to the hosted agent | don't replay raw history to the hosted agent; derive the next turn's input from the latest user text or the pending `mcp_approval_response`, chained via `previous_response_id`. | | Consequential tool runs WITHOUT asking | Tool missing `approval_mode="always_require"` | Decorate the consequential tool; check for at least one in your structural check. | +| Approval card renders but shows the wrong/missing id or field when you renamed your gated tool's parameter (e.g. `record_id` → your own name) | the frontend's `ApprovalHitl.tsx` parses `function_arguments` and casts to a specific field name (`{ record_id?: string }` in the snippet) — this must match whatever parameter name your actual Python tool takes, since the bridge itself just forwards the model's raw arguments JSON verbatim | when you rename a gated tool's parameter, update the corresponding field name in the frontend's parsed-args cast to match — this is a presentation-only detail, not a bridge change. | ## AG-UI rendering (bridge-level) From 46d962e71437d0233a18203fc9a733bbd3b7056b Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 15:06:31 +0800 Subject: [PATCH 11/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/foundry-hosted-agent-copilotkit/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/foundry-hosted-agent-copilotkit/SKILL.md b/skills/foundry-hosted-agent-copilotkit/SKILL.md index ddb1c67bb..d3b2d7034 100644 --- a/skills/foundry-hosted-agent-copilotkit/SKILL.md +++ b/skills/foundry-hosted-agent-copilotkit/SKILL.md @@ -237,7 +237,7 @@ deployed hosted agent, since all logic is server-side. - **`build_hosted_agent` → `FoundryChatClient` (Responses)** — the single brain, the SAME code locally (`azd ai agent run`) and deployed. Required so the hosted `mcp_approval_request`/`mcp_approval_response` re-executes the gated tool - (verified live on a real local agent: pending→reimbursed on approve, no + (verified live on a real local agent: pending→approved on approve, no change on reject). No mock client. The Responses `OpenAIChatClient` / Chat Completions path 500s on hosted approve-resume — do not use it here. From 2c7abdc38ad679d69163ba887f55ce9001e8f451 Mon Sep 17 00:00:00 2001 From: Sunil Sattiraju Date: Wed, 1 Jul 2026 22:14:20 +0800 Subject: [PATCH 12/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../references/snippets/backend/bridge_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py index f3ccf9435..c26e88634 100644 --- a/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py +++ b/skills/foundry-hosted-agent-copilotkit/references/snippets/backend/bridge_app.py @@ -387,6 +387,6 @@ async def run_agent(request: Request) -> Response: run_input = RunAgentInput.model_validate(body) except ValidationError as exc: logger.error("Invalid RunAgentInput: %s", exc) - raise + return PlainTextResponse("Invalid RunAgentInput", status_code=422) return StreamingResponse(_sse_stream(run_input), media_type="text/event-stream")