chore: publish from main

This commit is contained in:
github-actions[bot]
2026-10-07 06:00:54 +00:00
parent 6d30d743dd
commit aac18a9828
18 changed files with 1220 additions and 417 deletions
@@ -0,0 +1,24 @@
# Foundry Hosted Agent + CopilotKit Plugin
Build and evolve agentic web apps where a CopilotKit React frontend talks over the AG-UI protocol to a Microsoft Agent Framework agent running as a Microsoft Foundry hosted agent.
## What It Includes
### Skills
| Skill | Description |
| --- | --- |
| `/foundry-hosted-agent-copilotkit` | Choose the wiring, use Foundry-native primitives (conversations, durable approvals, background runs, Toolbox, memory, session files), add tools and human-in-the-loop approvals, deploy with `azd`, and debug the AG-UI event stream. |
### MCP Servers
| Server | Description |
| --- | --- |
| `microsoft-learn` | Microsoft Learn MCP server (`https://learn.microsoft.com/api/mcp`) for current Agent Framework and Foundry documentation and code samples. |
| `foundry` | Foundry MCP server (`https://mcp.ai.azure.com`) for inspecting Foundry projects, model deployments, and agents. Signs in with your Microsoft Entra ID account. |
## Requirements
- An Azure subscription with a Microsoft Foundry project (hosted agents are a paid preview service).
- Azure Developer CLI (`azd`) with the `azure.ai.agents` extension, and Azure CLI.
- Node.js and Python for the frontend, CopilotKit runtime, and AG-UI gateway.
@@ -0,0 +1,13 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"microsoft-learn": {
"type": "streamable-http",
"url": "https://learn.microsoft.com/api/mcp"
},
"foundry": {
"type": "streamable-http",
"url": "https://mcp.ai.azure.com"
}
}
}
@@ -0,0 +1,20 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "foundry-hosted-agent-copilotkit",
"description": "Build CopilotKit + AG-UI web apps on Microsoft Foundry hosted agents (Microsoft Agent Framework): wiring, native Foundry primitives, human-in-the-loop approvals, azd deploy, and debugging — with the Microsoft Learn and Foundry MCP servers for live grounding.",
"version": "1.0.0",
"keywords": [
"copilotkit",
"ag-ui",
"microsoft-foundry",
"hosted-agents",
"agent-framework",
"human-in-the-loop",
"azure"
],
"author": {
"name": "Sunil Sattiraju"
},
"repository": "https://github.com/github/awesome-copilot",
"license": "MIT"
}
@@ -0,0 +1,136 @@
---
name: foundry-hosted-agent-copilotkit
description: 'Build and evolve agentic web apps that pair a CopilotKit React frontend with a Microsoft Agent Framework agent running as a Microsoft Foundry hosted agent, connected over the AG-UI protocol. Covers choosing the wiring, using Foundry-native primitives instead of hand-rolled plumbing, agent tools, human-in-the-loop approvals, generative UI and shared state, local run and azd deploy, debugging the event stream, and safe upgrades.'
---
# CopilotKit + AG-UI + Microsoft Foundry hosted agents
Use this skill when a React/Next.js app uses CopilotKit and talks over AG-UI to a Microsoft Agent Framework (MAF) agent hosted in Microsoft Foundry. This covers new builds and changes to existing apps. Foundry hosted agents are a paid Azure service.
Scaffold the agent with `azd ai agent init` and the frontend with your usual React/Next.js tooling. Then follow this skill for everything that connects them.
## Mental model
```text
React (CopilotKit v2) CopilotKitProvider / CopilotChat threadId=<conv_ id> / useInterrupt
│ HTTP
CopilotKit runtime CopilotRuntime({ agents: { default: new HttpAgent({ url }) } })
│ AG-UI (SSE)
AG-UI gateway (FastAPI) add_agent_framework_fastapi_endpoint(app, AgentFrameworkAgent(FoundryAgent(...),
use_service_session=True, service_session_id_from_thread_id=True)) ← keeps no thread state
│ OpenAI Responses + Entra token (https://ai.azure.com/.default)
Foundry hosted agent ResponsesHostServer(Agent(tools=[...])) ← deployed with azd
```
**A deployed hosted agent does not speak AG-UI.** It exposes `.../agents/<name>/endpoint/protocols/openai/responses` (Responses protocol) or `.../protocols/invocations`. Translate with MAF's own adapter (`FoundryAgent` + `agent-framework-ag-ui`) instead of writing an SSE translator by hand. Browsers cannot hold the Entra token, so the gateway is always server-side.
## Use the platform first
Before you write any plumbing, check this table. Each row is something older codebases built by hand.
| Need | Native primitive (don't hand-roll) |
| --- | --- |
| Conversation history across turns and restarts | Foundry **conversations**: create one per chat, use its `conv_…` ID as the AG-UI thread ID, and the gateway passes it through (no snapshot store) |
| Durable tool approvals | `ResponsesHostServer` persists approval requests by default (`FoundryFunctionApprovalStore`). The AG-UI adapter turns them into AG-UI interrupts and forwards decisions back to the hosted agent |
| Runs that survive browser disconnects | Responses `background: true` + `store: true`, then poll or reconnect (see [wiring](references/wiring.md#background-runs-gateway)) |
| Durable multi-step workflows with gates | Invocations hosted agent + `@multi_turn_task` + `FoundryStateStore` |
| Per-user files for the agent | Session Files API; the agent reads `$HOME` with its own tools. Never inline large documents into the prompt |
| Long-term user memory | Foundry Memory Store (user scope) or `FoundryMemoryProvider` |
| Tool catalogs, web search, code interpreter | Foundry **Toolbox** (`FoundryToolbox`) with Tool Search, instead of listing every tool up front |
| Live docs while coding | The Microsoft Learn and Foundry MCP servers (bundled with this skill's plugin) |
## Choose the wiring
| If the app needs… | Use | Gateway size |
| --- | --- | --- |
| Hosted agent + chat + tools + approvals (**default**) | **A. AG-UI gateway over a Responses hosted agent** | ~50 lines of Python |
| Every state pattern, local prototype, no Foundry compute | **B. In-process AG-UI** (agent runs inside the FastAPI app) | ~15 lines |
| Background runs that keep going after disconnect, reconnect, per-user conversation lists | **C. Background-runs gateway** (custom AG-UI agent over Foundry conversations) | ~150 lines of TypeScript |
| Multi-step plan → approve → execute, irreversible steps that must not run twice | **D. Invocations hosted agent** alongside A or C | REST proxy |
Code for each is in [references/wiring.md](references/wiring.md). In an existing codebase, identify the wiring before you change anything:
- `FoundryAgent(...)` wrapped by `add_agent_framework_fastapi_endpoint` means A.
- `Agent(client=...)` wrapped directly means B.
- A custom `AbstractAgent` calling `/responses` with `background` means C.
- `protocol: invocations` in `azure.yaml` means D.
## Workflow
1. **Pick or identify the wiring** (table above).
2. **Use the latest release of every package** (`@copilotkit/*`, `@ag-ui/*`, `agent-framework-*`, `azd` and its `azure.ai.agents` extension). Then ground on live sources:
- the Microsoft Learn MCP server for MAF and Foundry
- the Foundry MCP server for project state
- docs.copilotkit.ai and the `.d.ts` files bundled in the installed `@copilotkit/*` packages
- docs.ag-ui.com
APIs move between minor versions, so don't trust memorized names.
3. **Build or change it** with a playbook below.
4. **Verify adversarially** against the completion criteria. A compiling build, a started server, or one good chat reply is not proof.
## Playbooks
### Build a new app end to end (wiring A)
1. In a **fresh directory**, run `azd ai agent init --no-prompt ...` against the project. Confirm `azd env get-values` shows `FOUNDRY_PROJECT_ENDPOINT`, then write tools in `main.py` ([wiring §1](references/wiring.md#1-hosted-agent)).
2. Run it locally with `azd ai agent run` (port 8088) and smoke-test it with `azd ai agent invoke --local "hi"`.
3. `azd deploy` (each deploy creates a new agent version), then `azd ai agent invoke "hi"`.
4. Add the AG-UI gateway exactly as in [wiring §2](references/wiring.md#2-ag-ui-gateway-wiring-a), including the thread-ID-is-conversation-ID flags and the interop shim. Create a conversation, then test chat **and** an approve resume with `curl -N` using its `conv_…` ID as `threadId` before touching the UI.
5. Add the conversation route, the CopilotKit runtime route and the React provider ([wiring §3](references/wiring.md#3-copilotkit-runtime-and-react)).
6. Add `useInterrupt` approval UI and a tool renderer ([hitl.md](references/hitl.md#frontend-copilotkit-v2)).
7. Verify every completion criterion, first locally and then against the deployed agent.
### Add or change an agent tool
1. Define it with `@tool` (Python) or `AIFunctionFactory.Create` (.NET), using typed and described parameters.
2. Keep parameter descriptions free of concrete example values for data the model must derive. Models copy literal examples.
3. Return compact values. Rich formatting belongs in the UI renderer.
4. Side-effecting tools get `approval_mode="always_require"` ([hitl.md](references/hitl.md)).
5. Redeploy the hosted agent. Tools live in the hosted agent, not the gateway.
6. Verify through the UI that `TOOL_CALL_*` events stream, and that any component parsing the arguments still works.
### Generative UI and shared state (CopilotKit v2 hooks)
| Pattern | Agent side | Frontend hook |
| --- | --- | --- |
| Frontend tool (agent calls the UI) | none; arrives in `RunAgentInput.tools` | `useFrontendTool` |
| Render a backend tool call | normal `@tool` | `useRenderTool` / `useRenderToolCall` / `useDefaultRenderTool` |
| Human approval of a tool call | `approval_mode="always_require"` | `useInterrupt` → `resolve({approved})` |
| Agent asks the user for input (frontend-resolved tool) | none | `useHumanInTheLoop` → `respond(...)` |
| Shared or predictive state | `state_schema` / `predict_state_config` on `AgentFrameworkAgent` | `useAgent` (read `agent.state`) |
State patterns emit `STATE_SNAPSHOT`/`STATE_DELTA` only when the adapter can see the agent's state:
- **Wiring B** gets them natively.
- **Wiring A** needs them declared on the gateway's `AgentFrameworkAgent`. Test this before you promise the feature.
- **Wiring C** has no state events unless you synthesize them.
### Debug a broken flow
Work from the lowest layer upward:
1. Call the hosted agent with `azd ai agent invoke` or `curl` against `/responses`.
2. Call the gateway with a real conversation ID. A made-up thread ID returns HTTP 500 with this wiring:
```bash
CONV=$(curl -s -X POST "$FOUNDRY_PROJECT_ENDPOINT/agents/$FOUNDRY_AGENT_NAME/endpoint/protocols/openai/conversations?api-version=v1" \
-H "Authorization: Bearer $(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)" \
-H 'content-type: application/json' -d '{}' | jq -r .id)
curl -N -X POST <gateway>/ -H 'content-type: application/json' -d '{"threadId":"'$CONV'","runId":"r1","messages":[{"id":"m1","role":"user","content":"hi"}],"tools":[],"context":[],"state":{},"forwardedProps":{}}'
```
3. Test the CopilotKit route.
4. Test the browser.
The first layer that fails owns the bug. Then match the symptom in [troubleshooting.md](references/troubleshooting.md). Use `azd ai agent monitor` for hosted-agent logs.
### Upgrade dependencies
Follow the version rules in [troubleshooting.md](references/troubleshooting.md#upgrades). Upgrade all packages together, then re-run the completion criteria live.
## Completion criteria
A change is done only when all of these hold:
1. The chat path works through the real UI, not only through curl.
2. Every approval-gated tool was tested both ways:
- Approve: the tool runs once on the server and the side effect is observable.
- Reject: the tool does not run, and the agent acknowledges it.
3. After an approval, a follow-up turn in the same thread does **not** re-run the gated tool.
4. Tool and approval cards still render after `RUN_FINISHED`, not only while streaming.
5. For deployed changes, checks 1–4 passed against the deployed agent and gateway. A successful deployment proves nothing about behavior.
@@ -0,0 +1,111 @@
# Human-in-the-loop approvals
HITL gates actions with side effects, and its failures are silent: a tool runs without approval, runs twice, or never runs after approval. Every HITL change must be verified three ways:
- Approve runs the tool **once**.
- Reject runs it **zero** times.
- Later turns run it **zero** more times.
## Pick the mechanism
| Situation | Mechanism | Durable across restarts? |
| --- | --- | --- |
| Chat with a gated tool on a hosted agent (wiring A) | `approval_mode="always_require"` on the hosted agent's tool. The AG-UI adapter surfaces it as an AG-UI **interrupt** and forwards the decision to the hosted agent | Foundry side: yes (`FoundryFunctionApprovalStore`). The gateway's in-flight registry is not: a pending approval interrupted by a gateway restart is rejected **without running the tool**, and the user asks again |
| In-process agent (wiring B) | Same tool flag; the adapter resolves the approval locally | **No.** A restart while an approval is pending leaves it unresolvable |
| Multi-step plan, irreversible steps, approvals that may wait days | Invocations workflow agent with server-issued gate tokens ([wiring §6](wiring.md#6-invocations-workflow-agent-wiring-d)) | Yes (`FoundryStateStore` + `@multi_turn_task`) |
## Server
```python
@tool(approval_mode="always_require")
def transfer(from_account: str, to_account: str, amount: float) -> str: ...
```
- On wiring A, the flag lives on the **hosted agent's** tool. The gateway must run as in [wiring §2](wiring.md#2-ag-ui-gateway-wiring-a): `use_service_session=True` with `service_session_id_from_thread_id=True` (thread ID = Foundry `conv_…` ID), plus the interop shim. Without conversation continuity, every approval turns into a fresh approval request.
- `approval_mode` controls approval only. `never_require` does not make a tool read-only.
- **.NET:** wrap the tool in `ApprovalRequiredAIFunction`. Bridge `ToolApprovalRequestContent`/`ToolApprovalResponseContent` to a client approval tool call in a `DelegatingAIAgent`, following the official AGUI `Step04_HumanInLoop` sample. Keep the call and its result **paired** in history; removing one makes Azure OpenAI return 400 "tool_calls must be followed by tool messages".
## Frontend (CopilotKit v2)
The gateway finishes the run with `RUN_FINISHED.outcome = {type: "interrupt", interrupts: [...]}`. Each interrupt has:
- `id` (the hosted `mcpr_...` approval id)
- `message` (for example, "Approve running transfer?")
- `metadata.agent_framework.function_call` (`name`, `arguments`)
Handle it with `useInterrupt`. CopilotKit then resumes the run with a spec `resume` array.
```tsx
"use client";
import { useState } from "react";
import { useInterrupt } from "@copilotkit/react-core/v2";
type Call = { name?: string; arguments?: Record<string, unknown> };
type Pending = { id: string; message?: string; metadata?: unknown };
type Resolve = (payload?: unknown, interruptId?: string) => Promise<unknown>;
function ApprovalCard({ interrupt, resolve }: { interrupt: Pending; resolve: Resolve }) {
const [decision, setDecision] = useState<boolean>();
const call = (interrupt.metadata as { agent_framework?: { function_call?: Call } })
?.agent_framework?.function_call;
const decide = (approved: boolean) => { setDecision(approved); void resolve({ approved }, interrupt.id); };
return (
<div data-testid="approval-card">
<p>{interrupt.message ?? "Approve this action?"}</p>
<pre>{call?.name} {JSON.stringify(call?.arguments)}</pre>
{decision === undefined ? (
<>
<button onClick={() => decide(true)}>Approve</button>
<button onClick={() => decide(false)}>Reject</button>
</>
) : <p>{decision ? "Approved" : "Rejected"}</p>}
</div>
);
}
export function ApprovalUI() {
useInterrupt({
// One card per open interrupt, each resolved by its own id. CopilotKit resumes the run
// only after every open interrupt has a decision; resolve() without an id targets the first.
render: ({ interrupts, resolve }) => (
<>{interrupts.map((i) => <ApprovalCard key={i.id} interrupt={i as Pending} resolve={resolve} />)}</>
),
});
return null;
}
```
- Mount `ApprovalUI` inside `CopilotKitProvider`. The card renders inside `CopilotChat` by default (`renderInChat`).
- Parallel gated calls: with agent-framework-ag-ui 1.4.0, a hosted agent's parallel gated calls surface **one interrupt per run**. Each decision resumes the run, which then asks for the next. Nothing executes until the last decision; then approved calls run once each and rejected ones return "rejected by user". Rendering all `interrupts` keeps the UI correct if a run ever carries several.
- The payload is a contract: `{approved: boolean}`, with `accepted` accepted as a legacy alias. Anything else does nothing, with no error. Reject with `resolve({approved: false})`, not `cancel()`. Cancelling discards the decision instead of telling the agent the call was rejected.
- The adapter also emits a `confirm_changes` tool call for older frontends. With `useInterrupt`, don't register `useHumanInTheLoop("confirm_changes")`; hide it in your tool renderer instead (see [wiring §3](wiring.md#3-copilotkit-runtime-and-react)).
- Render tool results with `useDefaultRenderTool` or `useRenderTool`. After approval, the hosted tool's result arrives as `TOOL_CALL_RESULT`, and nothing shows it otherwise.
## Verify approvals by their effect
On wiring A, the approved tool runs **on the hosted agent**, out of the UI's sight. A correct-looking transcript proves nothing. Check the side effect itself: query the record, or have the tool write an observable marker. Keep this regression test permanently:
- After one approval, send several unrelated follow-up turns in the same thread.
- Assert the side effect happened exactly once.
## Older versions
Before mid-2026 releases, the AG-UI adapter resolved approvals locally and never forwarded them to a hosted agent (microsoft/agent-framework#6652). Chaining `previous_response_id` through an approval-resolving response could also re-run the tool on a later turn (#6851). Both were fixed upstream: #7271, #7345, #7480, #7594.
If a codebase still contains a hand-written bridge that converts `mcp_approval_request`/`mcp_approval_response` or skips storing response IDs after approval turns, follow these steps:
1. Upgrade to the latest `agent-framework-*` packages.
2. Run the regression test without the workaround.
3. Delete the workaround only if the test passes.
The two current interop fixes in the wiring §2 shim follow the same rule.
## Debugging decision tree
Work top-down:
1. **400/500 "No tool output found for function call"** on approve: the hosted agent uses a Chat Completions client. Switch to `FoundryChatClient`.
2. **No approval card, browser shows `INCOMPLETE_STREAM` / "Cannot send 'TOOL_CALL_END' event: No active tool call found"**: the gateway is missing the shim from wiring §2.
3. **No approval card, no error**: `useInterrupt` isn't mounted inside the provider, or the tool lacks `approval_mode` and ran immediately. Check `RUN_FINISHED.outcome` with curl, and the hosted agent logs with `azd ai agent monitor`.
4. **Approve produces another approval request instead of running the tool**: the gateway isn't using `use_service_session=True`.
5. **Click does nothing, no error**: payload mismatch (see the contract above). A gateway reply of `APPROVAL_RESUME_REQUIRED` means the client sent a tool message without a `resume` entry.
6. **Approve runs the tool, but every later turn returns an empty run** (`RUN_STARTED` → `RUN_FINISHED` only): the duplicate interrupt tool message reached the adapter. Apply the shim from wiring §2.
7. **Approve shows a reply, but the side effect never happened**: the decision didn't reach the hosted agent. Upgrade packages, then `curl` the gateway with the resume to see whether a `TOOL_CALL_RESULT` comes back.
8. **Works once, then a later turn repeats the side effect**: an outdated workaround or an old package (see "Older versions").
9. **Card visible during the run but gone after `RUN_FINISHED`**: the final `MESSAGES_SNAPSHOT` differs from the live events. Check the DOM after the run, and upgrade CopilotKit.
10. **`RUN_ERROR` "No pending approval interrupt found for resume interruptId 'mcpr_…'"**: the gateway restarted, or the resume reached a different replica than the one that issued the approval. The tool did not run and the conversation is still usable, so ask again. With more than one replica, use session affinity. For approvals that must survive restarts, use an Invocations workflow.
@@ -0,0 +1,60 @@
# Troubleshooting and upgrades
Reproduce at the lowest failing layer first: hosted agent → gateway (`curl -N`) → CopilotKit route → browser. Match the exact error text below. HITL failures are covered in [hitl.md](hitl.md#debugging-decision-tree).
## Hosted agent and azd
| Symptom | Cause | Fix |
| --- | --- | --- |
| HTTP 424 `session_not_ready` | `main()` raised before serving `/readiness` (often missing env vars) | Check `azd ai agent monitor`; fail soft by serving a stub agent with an actionable message |
| `RuntimeError: ... running on protocol 1.0.0, but the agent requires protocol 2.0.0` | Hosting package and the `protocols[].version` in `azure.yaml` disagree | Bump the package and `azure.yaml` in one commit |
| `azd deploy`: `Foundry dependencies are not ready: ... FOUNDRY_PROJECT_ENDPOINT is not set` | `init` ran in a directory with an existing `azure.yaml` or env, so the project binding is incomplete (often with the wrong `AZURE_LOCATION` too) | Re-run `azd ai agent init --no-prompt -e <new-env> -p ... -d ...` in a fresh directory and check `azd env get-values`; don't `azd provision` an existing project |
| `azd ai agent ...` reports the extension "Incompatible" | azd core is older than the extension needs | `azd update && azd extension upgrade --all` |
| Remote build fails but local works | Meta-package `agent-framework` pulls optional extras, or unpinned pre-releases resolve differently | Depend on specific `agent-framework-*` packages; pin versions |
| Deployed agent still runs old code | Traffic pinned to an older version | `azd ai agent show`; move traffic to the latest version |
| 400 "Hosted agents can only be called through the agent endpoint" | Called with `agent_reference` on the project Responses endpoint | Use `.../agents/<name>/endpoint/protocols/openai/responses`, or `FoundryAgent(agent_name=...)` |
| `ImportError` for `agent_framework.azure` / `agent-framework-azure-ai` | Superseded package | Migrate to `agent-framework-foundry` |
## Gateway / AG-UI
| Symptom | Cause | Fix |
| --- | --- | --- |
| 401 "audience is incorrect" | Token requested for the wrong scope | Use `https://ai.azure.com/.default` |
| 401/403 invoking the agent | Gateway identity lacks a role on the project, or the token is from the wrong tenant | Assign Azure AI User on the project; `az login --tenant <id>` |
| 400 mentioning `x-ms-user-isolation-key` | Header sent to a deployed agent | Remove it; identity comes from Entra |
| Browser run fails with `INCOMPLETE_STREAM`: "Cannot send 'TOOL_CALL_END' event: No active tool call found with ID 'mcpr_...'" | Adapter closes a hosted approval id it never started | Use the gateway shim in [wiring §2](wiring.md#2-ag-ui-gateway-wiring-a) |
| `RUN_ERROR` `APPROVAL_RESUME_REQUIRED`: "Pending interrupts require a resume entry for every interruptId" | Approval sent as a tool message only (v1-style `confirm_changes` respond) | Resolve with `useInterrupt` (it sends `resume`); in curl, add a `resume` array |
| Approve → a second approval request instead of execution | Each run hits a new Foundry conversation | `use_service_session=True` + `service_session_id_from_thread_id=True` with `conv_…` thread IDs |
| After an approval, every turn returns `RUN_STARTED` → `RUN_FINISHED` with nothing | Duplicate interrupt tool message reached the adapter | Gateway shim in wiring §2 |
| `ValueError: use_service_session=True requires snapshot persistence` | Service sessions without a way to map threads to conversations | Add `service_session_id_from_thread_id=True` and use `conv_…` IDs as thread IDs ([wiring §2](wiring.md#2-ag-ui-gateway-wiring-a)) |
| HTTP 500 `server_error` from Foundry on the first turn | Thread ID is not a Foundry conversation ID (for example, a UUID generated by CopilotKit) while `service_session_id_from_thread_id=True` | Create the conversation first (`POST .../endpoint/protocols/openai/conversations`) and pass its `conv_…` ID as `threadId` |
| `RUN_ERROR` "No pending approval interrupt found for resume interruptId" | Gateway restarted, or a different replica received the resume | Ask again (the tool didn't run); use session affinity across replicas |
| Tools run but the stream has no `TOOL_CALL_RESULT` events; tool rows render empty | Gateway runs on an older `agent-framework-foundry` (seen with 1.11.0; 1.13.1 works), often a global or user-site Python instead of the project venv | Run the gateway from its own venv with the latest packages; confirm with `python -m pip list` *inside* that venv |
| Async credential errors about `aiohttp` | `azure.identity.aio` needs aiohttp | `pip install aiohttp` |
| 400 about orphaned / unanswered tool calls | Whole AG-UI transcript replayed into `/responses` (custom gateways) | Send only the latest user message plus the conversation / service session |
| `useAgent().agent.state` always empty | No `state_schema` on `AgentFrameworkAgent`, no tool writes state, or a custom gateway that never emits `STATE_*` | Declare the schema on the gateway wrapper; verify `STATE_SNAPSHOT` with curl |
## CopilotKit / frontend
| Symptom | Cause | Fix |
| --- | --- | --- |
| "Agent `<name>` not found" | Runtime `agents` key, `agentId`, and agent name disagree | One shared constant |
| 404 on `/api/copilotkit/info` or `/agent/.../run` | v2 runtime is multi-route but the Next.js route isn't a catch-all, or `basePath` is wrong | Use `app/api/copilotkit/[[...slug]]/route.ts` with a matching `basePath` |
| TypeScript error: `HttpAgent` is missing a property | App's `@ag-ui/*` version differs from the one the runtime pins | Match `@ag-ui/client`/`@ag-ui/core` to `npm view @copilotkit/runtime@<v> dependencies` |
| "Failed to execute 'fetch' on 'Window': Illegal invocation" | A library calls a captured `fetch` without its `this` | Upgrade; or pass `fetch.bind(window)` where a fetch option exists |
| CORS errors from the browser | Browser calling the gateway directly | Browser → CopilotKit route (same origin) → gateway server-side; otherwise use an explicit CORS allowlist |
| Hook not exported / renamed after upgrade | v1 names (`useCopilotAction`, `useCoAgent`, `CopilotKit` provider) used against `/v2` | Use v2 imports from `@copilotkit/react-core/v2`; check the bundled `.d.ts` |
| Dev warning "The agent called the tool ... and no renderer is registered"; tool results invisible | No tool renderer registered | `useDefaultRenderTool(...)` or `useRenderTool({ name, ... })` |
| Playwright: typed text stays in the input; no `/agent/.../run` request | Typed before hydration, or `fill()` didn't trigger the input handler | Wait for `networkidle`, `click()`, `pressSequentially(text)`, then `press("Enter")` and assert the box clears |
| Tool call renders while streaming, then disappears | Final `MESSAGES_SNAPSHOT` differs from live events | Upgrade; test the DOM after `RUN_FINISHED` |
## Upgrades
Always move to the **latest** releases, and move everything together:
1. **`@copilotkit/*` move in lockstep.** `react-core`, `runtime`, and any UI packages stay on one version, and `@ag-ui/*` matches the version the runtime pins.
2. **`agent-framework-*` stay on one release line.** Upgrade `-core`, `-foundry`, `-ag-ui`, and `-foundry-hosting` together.
3. **Protocol version and `azure.yaml` change together** (see the table above).
4. **Workarounds have exit criteria.** Keep a list mapping each local workaround to its upstream issue. Remove a workaround only when the fix has shipped **and** the regression test passes without it.
5. **Read what shipped.** CopilotKit release notes can be empty stubs, so diff the bundled `.d.ts` between versions.
6. **Re-verify live:** the chat path, frontend tools reaching `RunAgentInput.tools`, approve-once / reject-zero / follow-up-zero, cards after `RUN_FINISHED`, then redeploy and repeat against the deployed agent.
@@ -0,0 +1,304 @@
# Wiring reference
Package names below are current as of late 2026. Always install the **latest** releases and verify names against the installed packages: Python via `python -c "import agent_framework_ag_ui as m; print(m.__all__)"`, TypeScript via the `.d.ts` files.
## 1. Hosted agent
Scaffold the agent non-interactively against an existing Foundry project, in a **fresh directory** with a new environment:
```bash
azd ai agent init --no-prompt -e <env> -p <project-arm-id> -d <model-deployment> \
--agent-name my-agent --protocol responses --deploy-mode code \
--runtime python_3_13 --entry-point main.py --src src/my-agent
azd env get-values -e <env> # FOUNDRY_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME and
# AZURE_LOCATION (= the project's region) must all be set
```
- Running `init` in a directory that already has `azure.yaml` or another azd env can leave `FOUNDRY_PROJECT_ENDPOINT` empty, so `azd deploy` fails with `Foundry dependencies are not ready: ... FOUNDRY_PROJECT_ENDPOINT is not set`. Start over in a clean directory rather than patching the env.
- An existing project needs **no** `azd provision` and no `--infra`. `init` then `azd deploy` is the whole flow.
Everything is declared in `azure.yaml` (camelCase). The old separate `agent.yaml`/`agent.manifest.yaml` files are obsolete. The agent service looks like this:
```yaml
services:
my-agent:
project: src/my-agent
host: azure.ai.agent
language: python
uses: [ai-project]
env:
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
codeConfiguration: { runtime: python_3_13, entryPoint: main.py }
protocols:
- protocol: responses
version: 2.0.0
```
`main.py` must use a Responses-based chat client. `FoundryChatClient` is required for approval resume; a Chat Completions client fails with `No tool output found for function call`.
```python
import asyncio, os
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import DefaultAzureCredential
@tool
def get_balance(account: str) -> str:
"""Read-only lookup of an account balance."""
...
@tool(approval_mode="always_require")
def transfer(from_account: str, to_account: str, amount: float) -> str:
"""Move money. Side-effecting, so a human must approve it first."""
...
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], # injected when hosted
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)
agent = Agent(client=client, instructions="...", tools=[get_balance, transfer],
default_options={"store": False}) # the hosting layer owns history
await ResponsesHostServer(agent).run_async()
if __name__ == "__main__":
asyncio.run(main())
```
Dependencies (`requirements.txt`): `agent-framework-foundry`, `agent-framework-foundry-hosting`, and `azure-identity`. Pin each one to the latest release with `==`. The hosting package exists only as pre-releases, and remote builds resolve dependencies on their own. Depend on these specific packages, not on the `agent-framework` meta-package.
`ResponsesHostServer` persists conversation state and pending approvals in Foundry by default (`FoundryAgentSessionStore`, `FoundryFunctionApprovalStore`). Don't build your own stores for these.
**Fail soft at startup.** If `main()` raises before serving, `/readiness` never answers and Foundry reports only HTTP 424 `session_not_ready`. Catch configuration errors and serve a stub agent that explains what is missing.
## 2. AG-UI gateway (wiring A)
The gateway is a small FastAPI app. MAF does the Responses↔AG-UI translation and the approval forwarding.
**The AG-UI thread ID is a Foundry conversation ID.** The UI creates the conversation first (see §3), then the gateway passes the thread ID straight through as the Foundry conversation (`use_service_session=True, service_session_id_from_thread_id=True`). Foundry owns the history, so the gateway keeps no thread state and needs no snapshot store.
Conversation continuity is **required for hosted approvals**. Without it, every run reaches the hosted agent as a new conversation, the stored approval request can't be matched, and approving just produces a second approval request.
```python
# gateway/app.py — run: uvicorn app:app --port 8000
import os
from ag_ui.core import EventType
from agent_framework.foundry import FoundryAgent
from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint
from azure.identity.aio import DefaultAzureCredential
from fastapi import FastAPI
class HostedApprovalSafeAgent(AgentFrameworkAgent):
"""Interop fixes verified with agent-framework-ag-ui 1.4.0 + CopilotKit 1.76 / @ag-ui/client 1.0.1.
1. CopilotKit sends an interrupt decision twice: as `resume` AND as a tool message keyed by
the interrupt id. The duplicate makes the adapter skip the model's reply and leaves the
Foundry conversation returning empty runs. Keep only `resume`.
2. The adapter emits TOOL_CALL_END for the hosted approval id (`mcpr_...`) without a
TOOL_CALL_START; @ag-ui/client aborts the run with INCOMPLETE_STREAM. Drop it.
Delete this class when a release fixes both and the HITL regression test passes without it.
"""
async def run(self, input_data, **kwargs):
# The endpoint passes snake_case keys (interrupt_id, tool_call_id) by the time run() is called.
resumed = {r.get("interrupt_id") or r.get("interruptId") or r.get("id")
for r in (input_data.get("resume") or []) if isinstance(r, dict)}
if resumed:
input_data = {**input_data, "messages": [
m for m in input_data.get("messages", [])
if not (m.get("role") == "tool" and (m.get("tool_call_id") or m.get("toolCallId")) in resumed)]}
started: set[str] = set()
async for event in super().run(input_data, **kwargs):
if event.type == EventType.TOOL_CALL_START:
started.add(event.tool_call_id)
elif event.type == EventType.TOOL_CALL_END and event.tool_call_id not in started:
continue
yield event
hosted = FoundryAgent(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
agent_name=os.environ["FOUNDRY_AGENT_NAME"], # same name as in azure.yaml
credential=DefaultAzureCredential(),
allow_preview=True, # hosted-agent session APIs are preview
)
app = FastAPI()
add_agent_framework_fastapi_endpoint(
app,
HostedApprovalSafeAgent(agent=hosted, use_service_session=True,
service_session_id_from_thread_id=True), # threadId == Foundry conv_ id
"/",
)
```
Dependencies: `agent-framework-ag-ui`, `agent-framework-foundry`, `azure-identity`, `aiohttp` (needed by the async credential), `uvicorn`.
- **Thread IDs must be real `conv_…` IDs.** An arbitrary UUID makes Foundry return HTTP 500. Without `service_session_id_from_thread_id=True`, `use_service_session=True` instead requires a `snapshot_store` + `snapshot_scope_resolver` that maps thread IDs to conversations, which puts state back in the gateway.
- **What the gateway still holds: in-flight approvals only.** `agent-framework-ag-ui` keeps pending approvals in a process-local `InMemoryAGUIApprovalStateStore`, which can't be replaced. If the gateway restarts while an approval is pending:
- The resume fails with `RUN_ERROR` "No pending approval interrupt found for resume interruptId 'mcpr_…'".
- The tool does **not** run.
- The same conversation keeps working, so the user simply asks again.
Everything else survives restarts. For more than one replica, use session affinity so a resume reaches the replica that issued the approval.
- **Authorization:** the gateway trusts the thread ID it receives. In a multi-user app, record which user created each conversation (in the §3 route) and reject other users' thread IDs. This mapping is written once per conversation, not on every turn.
- Resume shape, for curl tests: `"resume": [{"interruptId": "<mcpr_ id from RUN_FINISHED.outcome.interrupts>", "status": "resolved", "payload": {"approved": true}}]`. Send it with the previous `MESSAGES_SNAPSHOT` as `messages`.
## 3. CopilotKit runtime and React
The runtime runs server-side. In Next.js, use an App Router route at `app/api/copilotkit/[[...slug]]/route.ts`. The v2 runtime is multi-route (`/info`, `/agent/:id/run`, …), so the catch-all segment is required.
```ts
import { HttpAgent } from "@ag-ui/client";
import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
const runtime = new CopilotRuntime({
agents: { default: new HttpAgent({ url: process.env.AGUI_URL! }) }, // gateway URL
});
const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" });
export const GET = handler;
export const POST = handler;
```
For Hono or Express servers, use `createCopilotHonoHandler` / `createCopilotExpressHandler` from `@copilotkit/runtime/v2`.
Create the Foundry conversation server-side. Its ID becomes the AG-UI thread ID. Put this in `app/api/thread/route.ts` (needs `@azure/identity`, plus `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_AGENT_NAME` in the Next.js server env):
```ts
import { DefaultAzureCredential } from "@azure/identity";
const credential = new DefaultAzureCredential();
export async function POST() {
const { token } = await credential.getToken("https://ai.azure.com/.default");
const url = `${process.env.FOUNDRY_PROJECT_ENDPOINT}/agents/${process.env.FOUNDRY_AGENT_NAME}` +
`/endpoint/protocols/openai/conversations?api-version=v1`;
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok) return Response.json({ error: `Foundry ${res.status}` }, { status: 502 });
const { id } = (await res.json()) as { id: string }; // conv_...
return Response.json({ threadId: id }); // multi-user: record (user, id) here
}
```
The React side, in `app/page.tsx`:
```tsx
"use client";
import { useEffect, useState } from "react";
import { CopilotKitProvider, CopilotChat, useDefaultRenderTool } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
import { ApprovalUI } from "./approval"; // useInterrupt, see hitl.md
function ToolRows() {
// Hosted tools run remotely; without a renderer their results are invisible in the chat.
useDefaultRenderTool({
render: ({ name, result }) => name === "confirm_changes" ? null :
<div>{name}: {typeof result === "string" ? result : JSON.stringify(result ?? "")}</div>,
});
return null;
}
export default function Page() {
const [threadId, setThreadId] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
fetch("/api/thread", { method: "POST" })
.then(async (r) => {
const d = await r.json().catch(() => ({}));
if (!r.ok || typeof d.threadId !== "string" || !d.threadId.startsWith("conv_")) {
throw new Error(d.error ?? `Could not start a conversation (HTTP ${r.status})`);
}
setThreadId(d.threadId);
})
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
}, []);
if (error) return <p role="alert">{error}</p>;
if (!threadId) return <p>Starting conversation…</p>;
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit">
<ToolRows />
<ApprovalUI />
<CopilotChat agentId="default" threadId={threadId} />
</CopilotKitProvider>
);
}
```
Passing a stored `conv_…` ID back as `threadId` continues that conversation on the agent side, because Foundry holds the history. Re-displaying the earlier transcript in the chat UI is a separate concern, and this setup doesn't cover it.
Version rules:
- Install `next`, `@copilotkit/react-core`, and `@copilotkit/runtime` at `@latest`.
- `@copilotkit/runtime` pins an exact `@ag-ui/client` version. Read it from the installed runtime with `npm view @copilotkit/runtime@<installed version> dependencies`, then install that exact version: `npm i @ag-ui/client@<pinned version>`.
- Keep all `@copilotkit/*` packages on one version.
Names must agree in three places: the runtime `agents` key, `agentId` on `CopilotChat`/`useAgent`, and the hosted agent name the gateway is given. Use one constant.
## 4. In-process AG-UI (wiring B)
Same as §2, but wrap a local `Agent(client=FoundryChatClient(...), tools=[...])` in a plain `AgentFrameworkAgent` instead of `FoundryAgent`; `use_service_session` and the §2 shim aren't needed. All state patterns work natively. Approval state is process-local, so a restart while an approval is pending leaves that approval unresolvable. Use this wiring for prototypes, or put durable approvals behind a hosted agent instead.
## 5. Background-runs gateway (wiring C) {#background-runs-gateway}
Use this when runs must continue after the browser disconnects and users must be able to reopen conversations. It is a custom `AbstractAgent` (from `@ag-ui/client`) in a Node gateway that drives Foundry's REST API directly. Derive the URLs from the agent's Responses endpoint `E = {project}/agents/{name}/endpoint/protocols/openai/responses`:
- `openai = E` minus `/responses`
- `agentEndpoint = openai` minus `/protocols/openai`
| Step | Call |
| --- | --- |
| New thread | `POST {openai}/conversations` → `conversation.id`; `POST {agentEndpoint}/sessions` → `agent_session_id`; store `{threadId → conversationId, sessionId, owner}` |
| Start a turn | `POST {openai}/responses` `{input, conversation, agent_session_id, background: true, store: true, stream: false}` → save `response.id` |
| Stream to the UI | Poll `GET {openai}/responses/{id}` about once a second while `status ∈ {queued, in_progress}`. Emit `MESSAGES_SNAPSHOT` built from `GET {openai}/conversations/{id}/items` (paginate with `after` until `has_more` is false) merged with `response.output`. Finish with `RUN_FINISHED`, or `RUN_ERROR` carrying `response.error` |
| Reconnect | Same poll loop starting from the saved `response.id`. Disconnecting must **not** cancel the run |
| Cancel | `POST {openai}/responses/{id}/cancel` |
Every call uses `?api-version=v1` and a bearer token for `https://ai.azure.com/.default`.
- Register a custom `AgentRunner` with `CopilotSseRuntime({ agents, runner })` so Foundry is the only replay store. The default `InMemoryAgentRunner` keeps a process-global thread cache that disagrees with Foundry after a restart.
- Derive each turn's input from the latest user message only. Replaying the whole AG-UI transcript into `/responses` fails with 400 errors about orphaned tool calls.
- Return 409 if a thread already has an active response.
## 6. Invocations workflow agent (wiring D)
Use this for plan → approve → execute flows where an irreversible step must not run twice, even across container restarts:
- Declare `protocol: invocations` (version `2.0.0`) in `azure.yaml`.
- In the agent, use `@multi_turn_task` from `azure.ai.agentserver.core.tasks`, with application checkpoints in `FoundryStateStore` (`azure.ai.agentserver.core.storage`).
- Each POST to `/invocations?agent_session_id=<id>` returns 202 with an `invocation_id`. Poll `GET /invocations/{id}` for `status` and `output`.
- Gate each irreversible step with a server-issued token (invocation ID + step index + a random value, compared with `hmac.compare_digest`).
- A watermark recorded before the side effect gives **at-most-once**: a crash between the watermark and the action skips the action. For **exactly-once** effects, send a stable per-step idempotency key that the destination enforces, or commit the effect and the completion checkpoint in one transaction.
- On recovery (`ctx.entry_mode == "recovered"`), the same turn is re-invoked with the same input. Reconcile any step that was started but not checkpointed using that idempotency key (ask the destination whether it happened) instead of treating the watermark as proof of completion.
The browser reaches this agent through a server-side REST proxy that checks session ownership. It does not go through AG-UI.
## 7. Auth and identity
- Token audience is `https://ai.azure.com/.default`. The `cognitiveservices` scope returns 401 "audience is incorrect".
- Never send `x-ms-user-isolation-key` to a deployed agent. Isolation comes from the Entra identity, and the header causes a 400.
- The gateway's identity, and the Next.js server identity that creates conversations, need the **Azure AI User** role on the Foundry project. That's a managed identity in Azure, or your `az login` locally.
- The gateway is the authorization boundary:
- Validate the user's Entra token (issuer, audience, tenant, scope).
- Keep a user → thread/session ownership index.
- Check ownership before every Foundry call.
- The Foundry Memory Store calls models as the **project's** managed identity. Grant that identity the Foundry User role on the parent account, or memory writes fail with 401.
- Each hosted agent gets its own Entra identity. Model and session-storage access are granted in-project, but any other Azure resource needs an explicit RBAC assignment.
## 8. Local loop and deploy
```bash
azd ai agent run # the real agent on http://localhost:8088, using your az login
azd ai agent invoke --local "hi" # smoke-test the local agent
azd deploy # code deploy; each deploy creates a new agent version
azd ai agent invoke "hi" # call the deployed agent
azd ai agent show / monitor # status and logs
```
- The §2 gateway targets a deployed agent by name, so deploy before you test the gateway. Iterate on agent logic locally with `azd ai agent run`.
- Update azd and the `azure.ai.agents` extension first (`azd update`, `azd extension upgrade --all`). An "Incompatible" extension status blocks every `azd ai agent` command.
- If the agent's traffic routing has been pinned explicitly (FixedRatio on a version), `azd deploy` does not move traffic to the new version. Check with `azd ai agent show`.
- Remote builds resolve dependencies independently. A local success does not prove the deployed image builds, so pin versions.
- Restart the local runtime between verification passes if tools keep in-memory state.