Skip to content

Commit f5fbc35

Browse files
committed
docs(showcase): gen-ui-interrupt + interrupt-headless cross-framework parity
Closes the interrupt architectural-divergence gap for ms-agent-python and ms-agent-dotnet. Pairs with PDX-68 — same gating mechanism as the a2ui parity commit. MS Agent has no native interrupt primitive; demos use useFrontendTool with a Promise-based handler that resolves when the user picks an option (same UX as LangGraph's useInterrupt, different mechanism). New region names describe the promise-based shape rather than overloading the canonical names: ms-agent-python + ms-agent-dotnet: gen-ui-interrupt: frontend-promise-handler — useFrontendTool with promise resolver backend-tool-call — agent-side trigger that fires the tool interrupt-headless: headless-promise-primitives — headless equivalent of the same flow (also picks up backend-tool-call from the shared agent file) MDX restructure (3 docs pages): - /human-in-the-loop/useInterrupt.mdx - /human-in-the-loop/headless.mdx - /programmatic-control.mdx Each now has parallel <WhenFrameworkHas interrupt_pattern=...> blocks: native → existing langgraph regions (backend-interrupt-tool, frontend-useinterrupt-render, headless-useinterrupt- primitives) with the existing prose promise-based → the new regions above with prose explaining the Promise-based shim ('same UX, different mechanism') Frameworks where interrupt cells are unshipped (no interrupt_pattern in their manifest) see neither block — that's the correct behavior; engineering fills in the field once the demo ships.
1 parent b2e3ac5 commit f5fbc35

9 files changed

Lines changed: 115 additions & 10 deletions

File tree

showcase/integrations/ms-agent-dotnet/agent/InterruptAgent.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public InterruptAgentFactory(IConfiguration configuration, ILoggerFactory logger
6565
});
6666
}
6767

68+
// @region[backend-tool-call]
6869
public AIAgent CreateInterruptAgent()
6970
{
7071
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
@@ -75,8 +76,9 @@ public AIAgent CreateInterruptAgent()
7576
// and returns a generic "unscheduled" message. When the frontend
7677
// has the tool registered (the intended demo path), AG-UI forwards
7778
// the frontend tool definition and the client handles the call —
78-
// this backend tool acts only as a schema hint for the model when
79-
// the frontend tool isn't yet available.
79+
// the frontend handler returns a Promise that only resolves once
80+
// the user picks a slot, which is the MS Agent shim for the
81+
// LangGraph `interrupt()` pause/resume primitive.
8082
//
8183
// The parameters intentionally mirror the LangGraph reference's
8284
// `schedule_meeting(topic, attendee)` signature so the model's
@@ -97,6 +99,7 @@ describing the purpose and `attendee` describing who the meeting is with. After
9799

98100
return new SharedStateAgent(chatClientAgent, _jsonSerializerOptions, _loggerFactory.CreateLogger<SharedStateAgent>());
99101
}
102+
// @endregion[backend-tool-call]
100103

101104
// =================
102105
// Tools

showcase/integrations/ms-agent-dotnet/src/app/demos/gen-ui-interrupt/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ function Chat() {
8181
[],
8282
);
8383

84-
// @region[frontend-tool-schedule-meeting]
84+
// @region[frontend-promise-handler]
8585
useFrontendTool({
8686
name: "schedule_meeting",
8787
description:
@@ -150,7 +150,7 @@ function Chat() {
150150
);
151151
},
152152
});
153-
// @endregion[frontend-tool-schedule-meeting]
153+
// @endregion[frontend-promise-handler]
154154

155155
return (
156156
<CopilotChat agentId="gen-ui-interrupt" className="h-full rounded-2xl" />

showcase/integrations/ms-agent-dotnet/src/app/demos/interrupt-headless/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function Layout() {
100100
available: "always",
101101
});
102102

103-
// @region[headless-frontend-tool-schedule-meeting]
103+
// @region[headless-promise-primitives]
104104
useFrontendTool({
105105
name: "schedule_meeting",
106106
description:
@@ -140,7 +140,7 @@ function Layout() {
140140
// reads the `pending` state set inside the handler.
141141
render: () => null,
142142
});
143-
// @endregion[headless-frontend-tool-schedule-meeting]
143+
// @endregion[headless-promise-primitives]
144144

145145
return (
146146
<div className="grid h-screen grid-cols-[1fr_420px] bg-[#FAFAFC]">

showcase/integrations/ms-agent-python/src/agents/interrupt_agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from agent_framework_ag_ui import AgentFrameworkAgent
2525

2626

27+
# @region[backend-tool-call]
2728
SYSTEM_PROMPT = dedent(
2829
"""
2930
You are a scheduling assistant. Whenever the user asks you to book a call
@@ -52,6 +53,10 @@ def create_interrupt_agent(chat_client: BaseChatClient) -> AgentFrameworkAgent:
5253
instructions=SYSTEM_PROMPT,
5354
# No backend tools. `schedule_meeting` is registered on the frontend
5455
# via `useFrontendTool` and dispatched through the CopilotKit runtime.
56+
# When the agent calls `schedule_meeting`, the request is routed to
57+
# the frontend handler, which returns a Promise that only resolves
58+
# once the user picks a slot — equivalent to `interrupt()` in the
59+
# LangGraph reference.
5560
tools=[],
5661
)
5762

@@ -64,3 +69,4 @@ def create_interrupt_agent(chat_client: BaseChatClient) -> AgentFrameworkAgent:
6469
),
6570
require_confirmation=False,
6671
)
72+
# @endregion[backend-tool-call]

showcase/integrations/ms-agent-python/src/app/demos/gen-ui-interrupt/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function Chat() {
6565
available: "always",
6666
});
6767

68-
// @region[ms-agent-frontend-tool-interrupt]
68+
// @region[frontend-promise-handler]
6969
useFrontendTool({
7070
name: "schedule_meeting",
7171
description:
@@ -114,7 +114,7 @@ function Chat() {
114114
);
115115
},
116116
});
117-
// @endregion[ms-agent-frontend-tool-interrupt]
117+
// @endregion[frontend-promise-handler]
118118

119119
return (
120120
<CopilotChat agentId="gen-ui-interrupt" className="h-full rounded-2xl" />

showcase/integrations/ms-agent-python/src/app/demos/interrupt-headless/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function Layout() {
7474
available: "always",
7575
});
7676

77-
// @region[ms-agent-frontend-tool-headless]
77+
// @region[headless-promise-primitives]
7878
useFrontendTool({
7979
name: "schedule_meeting",
8080
description:
@@ -117,7 +117,7 @@ function Layout() {
117117
// Render nothing inside the chat — the UI lives in the app surface.
118118
render: () => null,
119119
});
120-
// @endregion[ms-agent-frontend-tool-headless]
120+
// @endregion[headless-promise-primitives]
121121

122122
const resolve = (result: PickerResult) => {
123123
const fn = resolverRef.current;

showcase/shell-docs/src/content/docs/human-in-the-loop/headless.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ mounting a chat at all.
3737
If you just want "a picker in chat", just use
3838
[`useInterrupt`](./useInterrupt).
3939

40+
<WhenFrameworkHas flag="interrupt_pattern" equals="native">
41+
4042
## The primitives
4143

4244
Under the hood, `useInterrupt` composes two public APIs:
@@ -66,6 +68,38 @@ A few things this hook is careful about:
6668
- `onRunFailed` drops the staged event so a transport hiccup doesn't
6769
leave the UI stuck showing a picker for a run that never paused.
6870

71+
</WhenFrameworkHas>
72+
73+
<WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">
74+
75+
## The primitives (Promise-based shim)
76+
77+
On Microsoft Agent Framework there's no native interrupt primitive —
78+
the runtime can't pause a run and surface a custom event for the
79+
client to subscribe to. Instead, the headless variant uses
80+
`useFrontendTool` with an async handler that returns a Promise: the
81+
handler exposes its pending payload via React state (so a separate
82+
"app surface" can render the picker outside the chat) and resolves
83+
the Promise once the user interacts. Same UX, different mechanism.
84+
85+
The render callback intentionally returns `null` — the picker UI lives
86+
in the app surface, not in the chat transcript. The handler's pending
87+
state drives whether the picker is shown:
88+
89+
<Snippet region="headless-promise-primitives" title="frontend/src/app/page.tsx — useFrontendTool (headless Promise-based)" />
90+
91+
A few things this pattern is careful about:
92+
93+
- The handler stages its `resolve` callback in a ref keyed by tool-call
94+
id, so concurrent tool calls don't trample each other's resolvers.
95+
- `setPending` is called from inside the handler so the app surface
96+
re-renders the picker as soon as the agent calls the tool, and again
97+
with `null` after the user interacts so the picker disappears.
98+
- `render: () => null` keeps the chat transcript clean — the headless
99+
variant deliberately bypasses inline rendering.
100+
101+
</WhenFrameworkHas>
102+
69103
## Driving it from plain UI
70104

71105
Once `useHeadlessInterrupt` returns `{ pending, resolve }`, the rest is

showcase/shell-docs/src/content/docs/human-in-the-loop/useInterrupt.mdx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ not an LLM-initiated tool call. Typical cases:
3434
For LLM-initiated pauses where the model decides on the fly to ask
3535
the user, prefer [`useHumanInTheLoop`](../human-in-the-loop).
3636

37+
<WhenFrameworkHas flag="interrupt_pattern" equals="native">
38+
3739
## The backend: `interrupt()` inside a tool
3840

3941
The example agent exposes a `schedule_meeting` tool. When the model
@@ -64,6 +66,41 @@ and `resolve(...)` is how you resume the run:
6466
Whatever you pass to `resolve` is round-tripped back to the agent as
6567
the return value of the matching `interrupt(...)` call.
6668

69+
</WhenFrameworkHas>
70+
71+
<WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">
72+
73+
## The Promise-based shim
74+
75+
On Microsoft Agent Framework there's no native interrupt primitive —
76+
the runtime can't pause a run mid-tool and hand control to the client
77+
the way LangGraph's `interrupt()` does. Instead, the demo uses
78+
`useFrontendTool` with an async handler that returns a Promise. The
79+
agent calls `schedule_meeting` like any other tool, the client-side
80+
handler renders the picker, and the Promise only resolves once the
81+
user picks a slot or cancels. Same UX, different mechanism.
82+
83+
### The frontend: `useFrontendTool` with a Promise-resolving handler
84+
85+
The handler stores its `resolve` callback in a ref, returns a Promise
86+
that the user's pick eventually resolves, and renders the picker
87+
inline in the chat. This is the MS Agent equivalent of
88+
`useInterrupt`'s `event` / `resolve` pair:
89+
90+
<Snippet region="frontend-promise-handler" title="frontend/src/app/page.tsx — useFrontendTool (Promise-based)" />
91+
92+
### The backend: agent instructed to call the frontend tool
93+
94+
The agent has no local `schedule_meeting` implementation — the tool is
95+
registered entirely on the frontend. The backend's only job is to
96+
instruct the model to call `schedule_meeting` whenever the user wants
97+
to book a meeting. AG-UI routes the tool call to the client, where
98+
the Promise-returning handler takes over:
99+
100+
<Snippet region="backend-tool-call" title="backend — agent instructed to call schedule_meeting" />
101+
102+
</WhenFrameworkHas>
103+
67104
### Key props
68105

69106
- **`agentId`** — must match a runtime-registered agent. If omitted, the

showcase/shell-docs/src/content/docs/programmatic-control.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ object accepts every AG-UI lifecycle callback: `onCustomEvent`,
5858
deltas. Use it to drive custom progress UI, forward events to
5959
analytics, or catch LangGraph `interrupt(...)` events and resume with a payload (the pattern below).
6060

61+
<WhenFrameworkHas flag="interrupt_pattern" equals="native">
62+
6163
## Resolving a LangGraph interrupt from a button
6264

6365
The `interrupt-headless` cell demonstrates the full pattern without
@@ -74,6 +76,29 @@ The resulting `{ pending, resolve }` tuple is pure data; any UI can
7476
drive it. The cell itself renders a simple button grid, but the same
7577
hook would power a modal, a toast, a sidebar form, or a voice UI.
7678

79+
</WhenFrameworkHas>
80+
81+
<WhenFrameworkHas flag="interrupt_pattern" equals="promise-based">
82+
83+
## Resolving an MS Agent tool call from a button
84+
85+
On Microsoft Agent Framework there's no native interrupt primitive —
86+
the demo uses `useFrontendTool` with a Promise-based handler instead.
87+
The handler stages its `resolve` callback and pending payload via
88+
React state, the app surface renders the picker outside the chat, and
89+
the user's pick resolves the Promise that the agent's tool call is
90+
awaiting. Same UX, different mechanism — the agent never knows it's
91+
talking to a button grid instead of a chat picker:
92+
93+
<Snippet region="headless-promise-primitives" cell="interrupt-headless" title="frontend/src/app/page.tsx — useFrontendTool + Promise resolver" />
94+
95+
The resulting `{ pending, resolveActive }` pair is pure data; any UI
96+
can drive it. The cell itself renders a simple button grid, but the
97+
same pattern would power a modal, a toast, a sidebar form, or a voice
98+
UI.
99+
100+
</WhenFrameworkHas>
101+
77102
## See also
78103

79104
- [Headless UI](./headless) — the full `useRenderedMessages` composition

0 commit comments

Comments
 (0)