diff --git a/apps/dojo/e2e/a2ui-crewai-fixtures.ts b/apps/dojo/e2e/a2ui-crewai-fixtures.ts index 21746925fe..8b05ce3da1 100644 --- a/apps/dojo/e2e/a2ui-crewai-fixtures.ts +++ b/apps/dojo/e2e/a2ui-crewai-fixtures.ts @@ -1,13 +1,14 @@ /** * aimock fixtures for the CrewAI A2UI demos. * - * CrewAI runs gpt-4o via litellm, so these are structured-arg fixtures (like the - * LangGraph ones), not the Gemini JSON-string shape. Every predicate is scoped + * The CrewAI flows run openai/gpt-5.4 via litellm, so these are structured-arg + * fixtures (like the LangGraph ones), not the Gemini JSON-string shape. Every + * predicate is scoped * to a phrase unique to the CrewAI e2e prompts ("boutique hotels" for dynamic, * "search for flights" / "search for hotels" for fixed) so they never intercept - * the LangGraph / ADK demos (which use "comparison of 3 hotels" and "Find - * flights" / "Find hotels"). The recovery demo reuses the shared - * a2ui-recovery-fixtures.ts ("luxury" / "broken"). + * the LangGraph / ADK / Strands / Mastra demos (which use "comparison of 3 + * hotels" and "Find flights" / "Find hotels"). The recovery demo reuses the + * shared a2ui-recovery-fixtures.ts ("luxury" / "broken"). * * Register via `registerA2UICrewAIFixtures(mockServer)` from aimock-setup.ts. */ @@ -27,8 +28,146 @@ const allText = (messages: ChatMessage[] = []): string => messages.map((m) => textOf(m.content)).join("\n"); const userText = (messages: ChatMessage[] = []): string => textOf(messages.filter((m) => m.role === "user").pop()?.content); +const lastMessage = (messages: ChatMessage[] = []): ChatMessage | undefined => + messages[messages.length - 1]; -const isDynamic = (text: string) => /boutique hotels/i.test(text); +// --------------------------------------------------------------------------- +// Framework scope +// +// The three prompts the CrewAI A2UI e2e specs type. Every predicate below is +// gated on one of them, so nothing in this file can answer another +// integration's A2UI demo (they prompt with "Find flights" / "Find hotels" / +// "a comparison of 3 hotels") even though they register the same tool names. +// The last user message survives a surface-action run unchanged (the action is +// forwarded as tool messages, not as a new user turn), so the same gate scopes +// the action turns too. +// --------------------------------------------------------------------------- +const isFixedFlightPrompt = (text: string) => /search for flights/i.test(text); +const isFixedHotelPrompt = (text: string) => /search for hotels/i.test(text); +const isDynamicPrompt = (text: string) => /boutique hotels/i.test(text); +const isFixedRun = (req: { messages?: ChatMessage[] }) => { + const text = userText(req.messages); + return isFixedFlightPrompt(text) || isFixedHotelPrompt(text); +}; +const isDynamicRun = (req: { messages?: ChatMessage[] }) => + isDynamicPrompt(userText(req.messages)); +const isCrewAIA2UIRun = (req: { messages?: ChatMessage[] }) => + isFixedRun(req) || isDynamicRun(req); + +// --------------------------------------------------------------------------- +// Surface actions +// --------------------------------------------------------------------------- + +/** The tool-result line the A2UI middleware synthesizes for a surface action. */ +const ACTION_REPORT = + /^User performed action "([^"]*)" on surface "([^"]*)"(?: \(component: ([^)]*)\))?\. Context: ([\s\S]*)$/; + +interface SurfaceAction { + name: string; + surfaceId: string; + context: Record; +} + +const parseActionReport = (text: string): SurfaceAction | null => { + const parts = ACTION_REPORT.exec(text.trim()); + if (!parts) return null; + let context: Record = {}; + try { + const parsed = JSON.parse(parts[4]); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + context = parsed as Record; + } else { + console.warn( + `[a2ui-crewai-fixtures] action "${parts[1]}" forwarded a non-object context; ` + + `answering generically: ${parts[4]}`, + ); + } + } catch { + // Still an action, so answer it without the detail rather than falling + // through to another fixture. Logged because a context that stops parsing + // is a real forwarding regression, and the only other symptom is a vaguer + // reply that the spec's assertion would blame on the wrong thing. + console.warn( + `[a2ui-crewai-fixtures] action "${parts[1]}" forwarded an unparseable context; ` + + `answering generically: ${parts[4]}`, + ); + } + return { name: parts[1], surfaceId: parts[2], context }; +}; + +/** + * The action a request is being asked to answer, read off the LAST message. + * + * Anchoring on the last message rather than the first report in the history is + * what keeps a second click answering the SECOND choice: the history of a + * repeat-click run carries every earlier report too. + */ +const pendingAction = (req: { + messages?: ChatMessage[]; +}): SurfaceAction | null => { + const last = lastMessage(req.messages); + if (!last || last.role !== "tool") return null; + return parseActionReport(textOf(last.content)); +}; + +const asText = (value: unknown): string | undefined => + typeof value === "string" || typeof value === "number" + ? String(value) + : undefined; + +/** + * The reply to a surface action, derived from the forwarded action context. + * + * Derived, never canned: a hard-coded item name would let the spec pass even if + * the click of a different card were forwarded, or the wrong tool answered. + */ +const actionReply = (action: SurfaceAction | null): string => { + const context = action?.context ?? {}; + const price = asText(context.price) ?? asText(context.pricePerNight); + if (action?.name === "book_flight") { + const flight = asText(context.flightNumber) ?? "your flight"; + const origin = asText(context.origin); + const destination = asText(context.destination); + const route = origin && destination ? ` from ${origin} to ${destination}` : ""; + return `You are booked on ${flight}${route}${ + price ? ` for ${price}` : "" + }. Your itinerary is on its way.`; + } + const hotel = asText(context.hotelName) ?? asText(context.name); + if (!hotel) return "You are booked. Your confirmation is on its way."; + return `You are booked at ${hotel}${ + price ? ` for ${price} a night` : "" + }. Your confirmation is on its way.`; +}; + +/** A CrewAI A2UI surface-action turn: the click report is the pending message. */ +const isActionTurn = (req: { messages?: ChatMessage[] }) => + isCrewAIA2UIRun(req) && pendingAction(req) !== null; + +/** + * A CrewAI A2UI render follow-up turn: the flow looped the model over the + * `a2ui_operations` envelope its own search / generation returned. + */ +const isRenderFollowUpTurn = (req: { messages?: ChatMessage[] }) => { + const last = lastMessage(req.messages); + return ( + isCrewAIA2UIRun(req) && + last?.role === "tool" && + /a2ui_operations/.test(textOf(last.content)) + ); +}; + +/** + * Whether a fixture in THIS file answers the request, for the generic + * tool-result catch-all in aimock-setup.ts to step aside. + * + * Scoped to the CrewAI A2UI prompts on purpose: the replacements live here and + * nowhere else, so an A2UI turn in any other integration must keep the generic + * acknowledgment instead of dropping to the universal catch-all. + */ +export const crewAIA2UIAnswersToolResultTurn = (req: { + messages?: ChatMessage[]; +}): boolean => isActionTurn(req) || isRenderFollowUpTurn(req); const ROOT = { id: "root", @@ -98,11 +237,50 @@ export function registerA2UICrewAIFixtures(mockServer: LLMock): void { const hasTool = (req: any, name: string) => req.tools?.some((t: any) => t.function.name === name); + // Surface action, any demo: reply about the item that was actually clicked. + // The reply comes from a response FACTORY reading the forwarded action + // context, so the flight cards' "Select" is answered about that flight and a + // second click is answered about the second choice. + // + // `endpoint: "chat"` is load-bearing for a FUNCTION response: it skips + // aimock's per-endpoint response-shape gate, so without it this fixture + // becomes eligible for image/speech/transcription requests. + mockServer.addFixture({ + match: { endpoint: "chat", predicate: isActionTurn }, + response: (req: any) => ({ content: actionReply(pendingAction(req)) }), + }); + + // fixed_schema render follow-up: the closing reply the flow gets only by + // looping the model over its own search result. Registered before the search + // fixtures below so a request already carrying the envelope cannot re-search. + mockServer.addFixture({ + match: { + predicate: (req: any) => isRenderFollowUpTurn(req) && isFixedRun(req), + }, + response: { content: "Here are your results." }, + }); + + // dynamic_schema render follow-up: same closing turn, over the generate_a2ui + // envelope. Must precede the generate_a2ui fixture for the same reason. + mockServer.addFixture({ + match: { + predicate: (req: any) => isRenderFollowUpTurn(req) && isDynamicRun(req), + }, + response: { content: "Here is the comparison you asked for." }, + }); + // fixed_schema - backend search_flights tool ("search for flights"). + // + // `hasToolResult: false` keeps the searches to the FIRST turn of a run: a + // follow-up or action turn is answered by the fixtures above, never by + // re-running the search (which would repaint the surface the user just + // clicked). Deliberately no `content`, so the run's only assistant text is + // the closing reply the loop produces. mockServer.addFixture({ match: { + hasToolResult: false, predicate: (req: any) => - hasTool(req, "search_flights") && /search for flights/i.test(userText(req.messages)), + hasTool(req, "search_flights") && isFixedFlightPrompt(userText(req.messages)), }, response: { toolCalls: [{ name: "search_flights", arguments: JSON.stringify({ flights: FLIGHTS }) }], @@ -112,8 +290,9 @@ export function registerA2UICrewAIFixtures(mockServer: LLMock): void { // fixed_schema - backend search_hotels tool ("search for hotels"). mockServer.addFixture({ match: { + hasToolResult: false, predicate: (req: any) => - hasTool(req, "search_hotels") && /search for hotels/i.test(userText(req.messages)), + hasTool(req, "search_hotels") && isFixedHotelPrompt(userText(req.messages)), }, response: { toolCalls: [{ name: "search_hotels", arguments: JSON.stringify({ hotels: HOTELS_FIXED }) }], @@ -123,7 +302,8 @@ export function registerA2UICrewAIFixtures(mockServer: LLMock): void { // dynamic_schema - main agent calls the generate_a2ui sub-agent tool. mockServer.addFixture({ match: { - predicate: (req: any) => hasTool(req, "generate_a2ui") && isDynamic(userText(req.messages)), + hasToolResult: false, + predicate: (req: any) => hasTool(req, "generate_a2ui") && isDynamicRun(req), }, response: { toolCalls: [{ name: "generate_a2ui", arguments: JSON.stringify({ intent: "create" }) }], @@ -133,7 +313,8 @@ export function registerA2UICrewAIFixtures(mockServer: LLMock): void { // dynamic_schema - sub-agent render_a2ui → valid hotel-comparison surface. mockServer.addFixture({ match: { - predicate: (req: any) => hasTool(req, "render_a2ui") && isDynamic(allText(req.messages)), + predicate: (req: any) => + hasTool(req, "render_a2ui") && isDynamicPrompt(allText(req.messages)), }, response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgs }] }, }); diff --git a/apps/dojo/e2e/aimock-setup.ts b/apps/dojo/e2e/aimock-setup.ts index ccee04de4b..7370727e96 100644 --- a/apps/dojo/e2e/aimock-setup.ts +++ b/apps/dojo/e2e/aimock-setup.ts @@ -2,7 +2,10 @@ import { LLMock, type ChatMessage } from "@copilotkit/aimock"; import * as path from "node:path"; import { registerA2UIRecoveryFixtures } from "./a2ui-recovery-fixtures"; import { registerA2UIADKFixtures } from "./a2ui-adk-fixtures"; -import { registerA2UICrewAIFixtures } from "./a2ui-crewai-fixtures"; +import { + crewAIA2UIAnswersToolResultTurn, + registerA2UICrewAIFixtures, +} from "./a2ui-crewai-fixtures"; import { registerInterruptCrewAIFixtures } from "./interrupt-crewai-fixtures"; // Configurable so parallel worktrees / runs don't collide on one aimock port. @@ -34,7 +37,7 @@ export async function setupLLMock(): Promise { // the generic loadFixtureFile below). registerA2UIRecoveryFixtures(mockServer); - // CrewAI A2UI fixtures (gpt-4o, scoped to CrewAI-unique prompts so + // CrewAI A2UI fixtures (openai/gpt-5.4, scoped to CrewAI-unique prompts so // they never intercept the LangGraph/ADK demos). Predicate fixtures, before // the generic loader. registerA2UICrewAIFixtures(mockServer); @@ -612,6 +615,74 @@ export async function setupLLMock(): Promise { }, }); + // Backend tool rendering (backend_tool_rendering flow): a "Weather Assistant" + // crew agent calls the backend get_weather tool, then produces a final text + // summary. Both the tool-call turn and the final-answer turn hit aimock. + // Matched on the unique "Weather Assistant" role so they beat the crew_chat + // "Your personal goal is" catch-all below (first registered wins). The + // final-answer turn is also excluded from the generic tool-result catch-all + // further down so this dedicated summary wins over it. + // Require the CrewAI agent's backstory phrase alongside the role. sysIncludes is + // case-insensitive and other frameworks (e.g. Mastra) also ship a "weather + // assistant" backend-tool demo, so matching the role alone would hijack their + // requests; this phrase is unique to the CrewAI agent's backstory. + const isWeatherAgentCall = (req: { messages: ChatMessage[] }) => + sysIncludes(req.messages, "Weather Assistant") && + sysIncludes(req.messages, "look up the weather before you answer"); + const isWeatherAgentToolResultTurn = (req: { messages: ChatMessage[] }) => + isWeatherAgentCall(req) && hasToolResult(req); + const weatherToolCall = (location: string, id: string) => ({ + toolCalls: [ + { name: "get_weather", arguments: JSON.stringify({ location }), id }, + ], + }); + + // Tool-call turn, San Francisco. + mockServer.addFixture({ + match: { + predicate: (req) => { + const lastUser = req.messages.filter((m) => m.role === "user").pop(); + return ( + isWeatherAgentCall(req) && + !hasToolResult(req) && + textOf(lastUser?.content).includes("San Francisco") + ); + }, + }, + response: weatherToolCall("San Francisco", "call_get_weather_sf"), + }); + + // Tool-call turn, New York. + mockServer.addFixture({ + match: { + predicate: (req) => { + const lastUser = req.messages.filter((m) => m.role === "user").pop(); + return ( + isWeatherAgentCall(req) && + !hasToolResult(req) && + textOf(lastUser?.content).includes("New York") + ); + }, + }, + response: weatherToolCall("New York", "call_get_weather_ny"), + }); + + // Final-answer turn: after get_weather returns, the crew agent completes with a + // short weather summary. One fixture serves both cities (the card data rides + // the tool result); the city is echoed from the user request for a natural reply. + mockServer.addFixture({ + match: { predicate: (req) => isWeatherAgentToolResultTurn(req) }, + response: (req) => { + const lastUser = req.messages.filter((m) => m.role === "user").pop(); + const city = textOf(lastUser?.content).includes("New York") + ? "New York" + : "San Francisco"; + return { + content: `${city}: sunny and 20°C, 50% humidity, wind around 10, feels like 25°C.`, + }; + }, + }); + // Crew-internal kickoff: crew.kickoff() runs the "General Assistant" agent, // whose single LLM call routes here. crewai's no-tools agent requires the // EXACT "Thought:/Final Answer:" format or it retries — returning it means @@ -1440,6 +1511,15 @@ export async function setupLLMock(): Promise { // dedicated fixture keyed on the crew output string. if (hasCrewRunTool(req) && textOf(last.content) === CREW_RUN_OUTPUT) return false; + // Don't match the backend weather tool-result turn; a dedicated + // Weather-Assistant summary fixture answers it. + if (isWeatherAgentToolResultTurn(req)) return false; + // Don't match a CrewAI A2UI turn that a2ui-crewai-fixtures.ts answers + // itself (a surface-action click, or the closing turn over a render + // result): a generic acknowledgment would mask the reply under test. + // The predicate is scoped to that file's own prompts, so every other + // integration's A2UI demo keeps this fallback. + if (crewAIA2UIAnswersToolResultTurn(req)) return false; return true; }, }, diff --git a/apps/dojo/e2e/featurePages/A2UIPage.ts b/apps/dojo/e2e/featurePages/A2UIPage.ts index e7ca42448d..1391b62f7c 100644 --- a/apps/dojo/e2e/featurePages/A2UIPage.ts +++ b/apps/dojo/e2e/featurePages/A2UIPage.ts @@ -1,6 +1,9 @@ import { Page, Locator, expect } from "@playwright/test"; import { CopilotSelectors } from "../utils/copilot-selectors"; -import { sendChatMessage, awaitLLMResponseDone } from "../utils/copilot-actions"; +import { + sendAndAwaitResponse, + awaitResponseAfterAction, +} from "../utils/copilot-actions"; /** * Page object for A2UI feature tests (fixed schema, dynamic schema, advanced). @@ -29,9 +32,17 @@ export class A2UIPage { } } + /** + * Send a message and wait for the run it starts to finish. + * + * `awaitLLMResponseDone` alone is not enough: its run-start window is short, + * so the previous turn's `data-copilot-running="false"` can end the wait + * before the new run has started, and the caller's first assertion then races + * the response. `sendAndAwaitResponse` anchors on a NEW assistant message + * first, the same way `clickSurfaceAction` does for the action path. + */ async sendMessage(message: string) { - await sendChatMessage(this.page, message); - await awaitLLMResponseDone(this.page); + await sendAndAwaitResponse(this.page, message); } async assertUserMessageVisible(text: string | RegExp) { @@ -63,6 +74,42 @@ export class A2UIPage { return this.page.locator("[data-surface-id]"); } + /** + * A single surface node to assert on, count inside, or click inside. + * + * `surface()` and `anySurface()` stay multi-element on purpose so callers can + * count SURFACES with them, but an `expect()` or a click against a + * multi-element locator throws a strict mode violation as soon as the same + * surface id is painted twice, which the action flow can do, and a count taken + * through them doubles for the same reason. Preferring a visible node also + * keeps a stale hidden duplicate earlier in the DOM from failing the + * assertion. + * + * Use this (not `surface()`) whenever the assertion is about what is INSIDE + * one surface. + */ + visibleSurface(surfaceId?: string): Locator { + const surfaces = surfaceId ? this.surface(surfaceId) : this.anySurface(); + return surfaces.filter({ visible: true }).first(); + } + + /** + * The action buttons labelled `label` inside a single visible surface. + * + * The counting-safe way to assert how many cards are in a given state ("one + * Booked"): scoped to one surface node, so a duplicate paint of the same + * surface id cannot double the count. A string label matches exactly, keeping + * "Book" off an already-clicked "Booked". + */ + surfaceActions(label: string | RegExp, surfaceId?: string): Locator { + return this.visibleSurface(surfaceId).getByRole("button", { + name: label, + // `exact` applies to string names only; Playwright ignores it for a + // RegExp, which matches on its own terms. + ...(typeof label === "string" ? { exact: true } : {}), + }); + } + /** Assert that at least one A2UI surface is rendered on the page */ async assertSurfaceVisible(timeout = 30_000) { await expect(this.anySurface().first()).toBeVisible({ timeout }); @@ -70,7 +117,7 @@ export class A2UIPage { /** Assert a surface with a specific ID is rendered */ async assertSurfaceWithIdVisible(surfaceId: string, timeout = 30_000) { - await expect(this.surface(surfaceId)).toBeVisible({ timeout }); + await expect(this.visibleSurface(surfaceId)).toBeVisible({ timeout }); } /** Assert the rendered surface contains the given text */ @@ -95,4 +142,35 @@ export class A2UIPage { async getSurfaceCount(): Promise { return this.anySurface().count(); } + + /** + * Click an action button on a rendered surface and wait for the run it starts. + * + * The A2UI middleware forwards the click, appends a synthetic log_a2ui_event + * assistant call plus its result to the history, and re-runs the agent, so the + * agent's reply about the choice arrives as a new turn. + * + * `nth` is a 0-based index into the buttons that CURRENTLY match `label` + * inside the surface, in DOM order. It is not a card index: a clicked button + * relabels (Book -> Booked), so it leaves the match set and the remaining + * buttons re-index. On a three-card surface, clicking `nth: 1` hits the second + * card, and a following `nth: 0` hits the first. + * + * A string label matches exactly, keeping "Book" off an already-clicked + * "Booked"; a RegExp matches on its own terms (Playwright applies no + * exactness to it), so anchor it if it must not also match "Booked". + * + * The wait is anchored on the new assistant turn appearing, so callers can + * assert on the reply straight after this resolves. + */ + async clickSurfaceAction( + label: string | RegExp, + surfaceId?: string, + options: { nth?: number } = {}, + ) { + const scope = this.visibleSurface(surfaceId); + await expect(scope).toBeVisible({ timeout: 30_000 }); + const action = this.surfaceActions(label, surfaceId).nth(options.nth ?? 0); + await awaitResponseAfterAction(this.page, () => action.click()); + } } diff --git a/apps/dojo/e2e/pages/crewAIPages/SubgraphsPage.ts b/apps/dojo/e2e/pages/crewAIPages/SubgraphsPage.ts new file mode 100644 index 0000000000..ffe71ca5d9 --- /dev/null +++ b/apps/dojo/e2e/pages/crewAIPages/SubgraphsPage.ts @@ -0,0 +1,137 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { CopilotSelectors } from '../../utils/copilot-selectors'; +import { sendChatMessage, awaitLLMResponseDone } from '../../utils/copilot-actions'; +import { DEFAULT_WELCOME_MESSAGE } from '../../lib/constants'; + +// The travel-planner frontend is shared across integrations, so the selectors +// mirror the LangGraph page object; only the backend (a CrewAI flow with two +// @human_feedback suspends) differs. +export class SubgraphsPage { + readonly page: Page; + readonly chatInput: Locator; + readonly sendButton: Locator; + readonly agentGreeting: Locator; + readonly agentMessage: Locator; + readonly userMessage: Locator; + + readonly flightOptions: Locator; + readonly klmFlightOption: Locator; + readonly unitedFlightOption: Locator; + + readonly hotelOptions: Locator; + readonly hotelZephyrOption: Locator; + readonly ritzCarltonOption: Locator; + readonly hotelZoeOption: Locator; + + readonly selectedFlight: Locator; + readonly selectedHotel: Locator; + + readonly supervisorIndicator: Locator; + readonly flightsAgentIndicator: Locator; + readonly hotelsAgentIndicator: Locator; + readonly experiencesAgentIndicator: Locator; + + constructor(page: Page) { + this.page = page; + this.agentGreeting = page.getByText(DEFAULT_WELCOME_MESSAGE); + this.chatInput = CopilotSelectors.chatTextarea(page); + this.sendButton = CopilotSelectors.sendButton(page); + this.agentMessage = CopilotSelectors.assistantMessages(page); + this.userMessage = CopilotSelectors.userMessages(page); + + // Scope to the interrupt picker cards (the itinerary panel also lists these + // names, so match on the option-card button to stay unambiguous). + this.flightOptions = page.locator('.flight-option, [data-testid*="flight"]'); + this.klmFlightOption = page.locator('.flight-option', { hasText: 'KLM' }); + this.unitedFlightOption = page.locator('.flight-option', { hasText: 'United' }); + + this.hotelOptions = page.locator('.hotel-option, [data-testid*="hotel"]'); + this.hotelZephyrOption = page.locator('.hotel-option', { hasText: 'Hotel Zephyr' }); + this.ritzCarltonOption = page.locator('.hotel-option', { hasText: 'Ritz-Carlton' }); + this.hotelZoeOption = page.locator('.hotel-option', { hasText: 'Hotel Zoe' }); + + this.selectedFlight = page.locator('[data-testid*="selected-flight"], .selected-flight'); + this.selectedHotel = page.locator('[data-testid*="selected-hotel"], .selected-hotel'); + + this.supervisorIndicator = page.locator('[data-testid*="supervisor"], .supervisor-active'); + this.flightsAgentIndicator = page.locator('[data-testid*="flights-agent"], .flights-agent-active'); + this.hotelsAgentIndicator = page.locator('[data-testid*="hotels-agent"], .hotels-agent-active'); + this.experiencesAgentIndicator = page.locator('[data-testid*="experiences-agent"], .experiences-agent-active'); + } + + async openChat() { + await expect(this.agentGreeting).toBeVisible(); + } + + async sendMessage(message: string) { + await sendChatMessage(this.page, message); + await awaitLLMResponseDone(this.page); + } + + async selectFlight(airline: 'KLM' | 'United') { + const flightOption = airline === 'KLM' ? this.klmFlightOption : this.unitedFlightOption; + await expect(this.flightOptions.first()).toBeVisible(); + await flightOption.click(); + } + + async selectHotel(hotel: 'Zephyr' | 'Ritz-Carlton' | 'Zoe') { + let hotelOption: Locator; + switch (hotel) { + case 'Zephyr': + hotelOption = this.hotelZephyrOption; + break; + case 'Ritz-Carlton': + hotelOption = this.ritzCarltonOption; + break; + case 'Zoe': + hotelOption = this.hotelZoeOption; + break; + } + await expect(this.hotelOptions.first()).toBeVisible(); + await hotelOption.click(); + } + + async waitForFlightsAgent() { + await expect( + this.page.getByText(/flight.*options|Amsterdam.*San Francisco|KLM|United/i).first() + ).toBeVisible(); + } + + async waitForHotelsAgent() { + await expect( + this.page.getByText(/hotel.*options|accommodation|Zephyr|Ritz-Carlton|Hotel Zoe/i).first() + ).toBeVisible(); + } + + async waitForExperiencesAgent() { + await expect( + this.page.getByText(/experience|activities|restaurant|Pier 39|Golden Gate|Swan Oyster|Tartine/i).first() + ).toBeVisible(); + } + + async verifyStaticFlightData() { + await expect(this.page.getByText(/KLM.*\$650.*11h 30m/).first()).toBeVisible(); + await expect(this.page.getByText(/United.*\$720.*12h 15m/).first()).toBeVisible(); + } + + async verifyStaticHotelData() { + await expect(this.page.getByText(/Hotel Zephyr.*\$280/).first()).toBeVisible(); + await expect(this.page.getByText(/Ritz-Carlton.*\$550/).first()).toBeVisible(); + await expect(this.page.getByText(/Hotel Zoe.*\$320/).first()).toBeVisible(); + } + + async verifyStaticExperienceData() { + await expect(this.page.getByText('No experiences planned yet')).not.toBeVisible({ timeout: 30000 }); + await expect(this.page.locator('.activity-name').first()).toBeVisible(); + const experienceContent = this.page.locator('.activity-name').first().or( + this.page.getByText(/Pier 39|Golden Gate Bridge|Swan Oyster Depot|Tartine Bakery/i).first() + ); + await expect(experienceContent).toBeVisible(); + } + + async waitForSupervisorCoordination() { + await expect( + this.page.getByText(/supervisor|coordinate|specialist|routing|Amsterdam|San Francisco/i).first() + ).toBeVisible(); + } +} diff --git a/apps/dojo/e2e/tests/crewAIConversationalFlowsTests/featureParity.spec.ts b/apps/dojo/e2e/tests/crewAIConversationalFlowsTests/featureParity.spec.ts new file mode 100644 index 0000000000..c62db98f78 --- /dev/null +++ b/apps/dojo/e2e/tests/crewAIConversationalFlowsTests/featureParity.spec.ts @@ -0,0 +1,322 @@ +import { expect, type Locator } from "@playwright/test"; +import * as path from "path"; +import { test } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; +import { AgenticChatPage } from "../../featurePages/AgenticChatPage"; +import { SharedStatePage } from "../../featurePages/SharedStatePage"; +import { ToolBaseGenUIPage } from "../../featurePages/ToolBaseGenUIPage"; +import { V1AgenticChatPage } from "../../featurePages/V1AgenticChatPage"; +import { AgenticGenUIPage } from "../../pages/crewAIPages/AgenticUIGenPage"; +import { HumanInLoopPage } from "../../pages/crewAIPages/HumanInLoopPage"; +import { PredictiveStateUpdatesPage } from "../../pages/crewAIPages/PredictiveStateUpdatesPage"; +import { SubgraphsPage } from "../../pages/crewAIPages/SubgraphsPage"; +import { + awaitLLMResponseDone, + openChat, + sendChatMessage, +} from "../../utils/copilot-actions"; +import { CopilotSelectors } from "../../utils/copilot-selectors"; + +const integrationId = "crewai-conversational-flows"; +const testImage = path.join( + import.meta.dirname, + "../../fixtures/test-image.png", +); +const parityFeatures = [ + "agentic_chat", + "agentic_chat_reasoning", + "agentic_chat_multimodal", + "v1_agentic_chat", + "backend_tool_rendering", + "interrupt", + "human_in_the_loop", + "agentic_generative_ui", + "predictive_state_updates", + "shared_state", + "tool_based_generative_ui", + "subgraphs", + "a2ui_dynamic_schema", + "a2ui_recovery", + "a2ui_fixed_schema", +] as const; + +async function expectRenderedAfter( + earlier: Locator, + later: Locator, +): Promise { + await expect(earlier).toBeAttached(); + await expect(later).toBeAttached(); + + const laterHandle = await later.elementHandle(); + expect(laterHandle).not.toBeNull(); + const followsInDocument = await earlier.evaluate( + (earlierElement, laterElement) => + Boolean( + earlierElement.compareDocumentPosition(laterElement as Node) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + laterHandle, + ); + expect(followsInDocument).toBe(true); + + const [earlierBox, laterBox] = await Promise.all([ + earlier.boundingBox(), + later.boundingBox(), + ]); + if (earlierBox && laterBox) { + expect(earlierBox.y).toBeLessThan(laterBox.y); + } +} + +test.describe("CrewAI Conversational Flows feature parity", () => { + test.describe.configure({ mode: "serial" }); + + for (const feature of parityFeatures) { + test(`${feature} has a dedicated dojo cell`, async ({ page }) => { + const response = await page.goto(`/${integrationId}/feature/${feature}`); + + expect(response?.ok()).toBe(true); + await expect(page.locator("body")).not.toContainText( + "Integration not found", + ); + }); + } + + test("public turns retain conversation history", async ({ page }) => { + await page.goto(`/${integrationId}/feature/agentic_chat`); + const chat = new AgenticChatPage(page); + await chat.openChat(); + + await chat.sendMessage("My favorite fruit is Mango"); + await chat.assertAgentReplyVisible(/Mango/i); + await chat.sendMessage("Can you remind me what my favorite fruit is?"); + + await chat.assertAgentReplyVisible(/Mango/i); + await expectRenderedAfter( + CopilotSelectors.userMessages(page).last(), + CopilotSelectors.assistantMessages(page).last(), + ); + }); + + test("reasoning renders between the user prompt and assistant answer", async ({ + page, + }) => { + await page.goto(`/${integrationId}/feature/agentic_chat_reasoning`); + await openChat(page); + + await sendChatMessage(page, "What is the best car to buy?"); + await awaitLLMResponseDone(page); + + const userMessage = CopilotSelectors.userMessages(page).last(); + const reasoningIndicator = page.getByText(/Thought for/i).last(); + const answer = CopilotSelectors.assistantMessages(page) + .last() + .getByText(/Based on my analysis/i); + await expect(reasoningIndicator).toBeVisible({ timeout: 10000 }); + await expect(answer).toBeVisible({ timeout: 10000 }); + + const [userBox, reasoningBox, answerBox] = await Promise.all([ + userMessage.boundingBox(), + reasoningIndicator.boundingBox(), + answer.boundingBox(), + ]); + expect(userBox).not.toBeNull(); + expect(reasoningBox).not.toBeNull(); + expect(answerBox).not.toBeNull(); + expect(userBox!.y).toBeLessThan(reasoningBox!.y); + expect(reasoningBox!.y).toBeLessThan(answerBox!.y); + }); + + test("multimodal turns preserve the uploaded image", async ({ page }) => { + await page.goto(`/${integrationId}/feature/agentic_chat_multimodal`); + await openChat(page); + await page.locator('input[type="file"]').setInputFiles(testImage); + + await sendChatMessage(page, "Tell me what do you see in this image"); + await awaitLLMResponseDone(page); + + await expect(CopilotSelectors.assistantMessages(page).last()).toContainText( + /image|visual|content|see|picture/i, + ); + await expectRenderedAfter( + CopilotSelectors.userMessages(page).last(), + CopilotSelectors.assistantMessages(page).last(), + ); + }); + + test("v1 chat renders the assistant after its user turn", async ({ page }) => { + await page.goto(`/${integrationId}/feature/v1_agentic_chat`); + const chat = new V1AgenticChatPage(page); + + await chat.sendMessage("Hi"); + await chat.assertAgentReplyVisible(/Hello|Hi|hey|help|assist/i); + + await expectRenderedAfter(chat.userMessages.last(), chat.assistantMessages.last()); + }); + + test("backend tool cards render after the triggering user turn", async ({ + page, + }) => { + await page.goto(`/${integrationId}/feature/backend_tool_rendering`); + await page + .getByRole("button", { name: "Weather in San Francisco" }) + .click(); + + const weatherCard = page.getByTestId("weather-card").first(); + await expect(weatherCard).toBeVisible({ timeout: 30_000 }); + await expectRenderedAfter( + CopilotSelectors.userMessages(page).last(), + weatherCard, + ); + }); + + test("native interrupt UI renders after the triggering user turn", async ({ + page, + }) => { + await page.goto(`/${integrationId}/feature/interrupt`); + await openChat(page); + await sendChatMessage( + page, + "Book an intro call with the sales team to discuss pricing.", + ); + + const picker = page.getByTestId("interrupt-picker"); + await expect(picker).toBeVisible({ timeout: 30_000 }); + await expectRenderedAfter( + CopilotSelectors.userMessages(page).last(), + picker, + ); + }); + + test("frontend HITL confirmation continues the public turn", async ({ + page, + }) => { + await page.goto(`/${integrationId}/feature/human_in_the_loop`); + const hitl = new HumanInLoopPage(page); + await hitl.openChat(); + await hitl.sendMessage( + "Give me a plan to make brownies, there should be only one step with eggs and one step with oven, this is a strict requirement so adhere", + ); + await expectRenderedAfter(hitl.userMessage.last(), hitl.plan); + await hitl.uncheckItem("eggs"); + await hitl.performStepsAndAwait(); + + await hitl.assertAgentReplyVisible(/Done|completed/i); + }); + + test("agentic generative UI renders its task after the user turn", async ({ + page, + }) => { + await page.goto(`/${integrationId}/feature/agentic_generative_ui`); + const generativeUI = new AgenticGenUIPage(page); + await generativeUI.openChat(); + + await generativeUI.sendMessage("Go to Mars"); + await expect(generativeUI.agentPlannerContainer).toBeVisible({ + timeout: 30_000, + }); + await expectRenderedAfter( + generativeUI.userMessage.last(), + generativeUI.agentPlannerContainer, + ); + }); + + test("predictive state accepts a document change", async ({ page }) => { + test.slow(); + await page.goto(`/${integrationId}/feature/predictive_state_updates`); + const predictive = new PredictiveStateUpdatesPage(page); + await predictive.openChat(); + await predictive.sendMessage( + "Give me a story for a dragon called Atlantis in document", + ); + await predictive.getPredictiveResponse(); + await predictive.getUserApproval(); + + expect(await predictive.verifyAgentResponse("Atlantis")).not.toBeNull(); + await expectRenderedAfter( + predictive.userMessage.last(), + predictive.confirmedChangesResponse, + ); + }); + + test("shared-state replies render after the user turn", async ({ page }) => { + await page.goto(`/${integrationId}/feature/shared_state`); + const sharedState = new SharedStatePage(page); + await sharedState.openChat(); + + await sharedState.sendMessage("Give me all the ingredients"); + await expect(sharedState.agentMessage.last()).toBeVisible(); + await expectRenderedAfter( + sharedState.userMessage.last(), + sharedState.agentMessage.last(), + ); + }); + + test("tool-based generative UI renders its card after the user turn", async ({ + page, + }) => { + await page.goto(`/${integrationId}/feature/tool_based_generative_ui`); + const generativeUI = new ToolBaseGenUIPage(page); + + await generativeUI.generateHaiku('Generate Haiku for "I will always win"'); + await generativeUI.checkGeneratedHaiku(); + await expectRenderedAfter( + CopilotSelectors.userMessages(page).last(), + generativeUI.haikuBlock.last(), + ); + }); + + test("subgraphs resumes flight and hotel selections", async ({ page }) => { + test.slow(); + await page.goto(`/${integrationId}/feature/subgraphs`); + const subgraphs = new SubgraphsPage(page); + await subgraphs.openChat(); + await subgraphs.sendMessage("Help me plan a trip to San Francisco"); + await subgraphs.waitForFlightsAgent(); + await expectRenderedAfter( + subgraphs.userMessage.last(), + page.locator(".flight-option").first(), + ); + await subgraphs.selectFlight("KLM"); + await subgraphs.waitForHotelsAgent(); + await subgraphs.selectHotel("Zoe"); + await subgraphs.waitForExperiencesAgent(); + await subgraphs.verifyStaticExperienceData(); + }); + + for (const { + feature, + prompt, + surfaceId, + } of [ + { + feature: "a2ui_fixed_schema", + prompt: "Search for hotels in downtown Manhattan for next weekend.", + surfaceId: "hotel-search-results", + }, + { + feature: "a2ui_dynamic_schema", + prompt: + "Compare three boutique hotels - The Ritz, Holiday Inn, and Boutique Loft - with location, nightly price, and rating.", + surfaceId: "hotel-comparison", + }, + { + feature: "a2ui_recovery", + prompt: "Compare 3 luxury hotels with ratings and prices.", + surfaceId: "hotel-comparison", + }, + ] as const) { + test(`${feature} renders its surface after the user turn`, async ({ page }) => { + await page.goto(`/${integrationId}/feature/${feature}`); + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + + await a2ui.sendMessage(prompt); + await a2ui.assertSurfaceWithIdVisible(surfaceId); + await expectRenderedAfter( + a2ui.userMessages.last(), + a2ui.visibleSurface(surfaceId), + ); + }); + } +}); diff --git a/apps/dojo/e2e/tests/crewAITests/a2uiDynamicSchema.spec.ts b/apps/dojo/e2e/tests/crewAITests/a2uiDynamicSchema.spec.ts index 8737b3c6ab..93f1d14de0 100644 --- a/apps/dojo/e2e/tests/crewAITests/a2uiDynamicSchema.spec.ts +++ b/apps/dojo/e2e/tests/crewAITests/a2uiDynamicSchema.spec.ts @@ -28,6 +28,31 @@ test("[CrewAI] A2UI Dynamic Schema renders hotel comparison surface", async ({ ]); // HotelCard renders the numeric rating value. - const surface = a2ui.surface("hotel-comparison"); + const surface = a2ui.visibleSurface("hotel-comparison"); await expect(surface.getByText("4.8").first()).toBeVisible(); }); + +test("[CrewAI] A2UI Dynamic Schema answers an action click about that choice", async ({ + page, +}) => { + await page.goto("/crewai/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Compare three boutique hotels - The Ritz, Holiday Inn, and Boutique Loft - with location, nightly price, and rating.", + ); + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + + // The generation turn ends on the generate_a2ui call, so this closing reply + // exists only because the flow loops the model over its own tool result. + await a2ui.assertAgentReplyVisible(/here is the comparison you asked for/i); + + // Book the SECOND card; the reply is built from the forwarded action context. + await a2ui.clickSurfaceAction("Book", "hotel-comparison", { nth: 1 }); + + // Counted inside ONE surface node: a repaint of the same surface id would + // otherwise double the count. + await expect(a2ui.surfaceActions("Booked", "hotel-comparison")).toHaveCount(1); + await a2ui.assertAgentReplyVisible(/booked at Holiday Inn/i); +}); diff --git a/apps/dojo/e2e/tests/crewAITests/a2uiFixedSchema.spec.ts b/apps/dojo/e2e/tests/crewAITests/a2uiFixedSchema.spec.ts index b0d711fb4a..c89a891f34 100644 --- a/apps/dojo/e2e/tests/crewAITests/a2uiFixedSchema.spec.ts +++ b/apps/dojo/e2e/tests/crewAITests/a2uiFixedSchema.spec.ts @@ -32,6 +32,57 @@ test("[CrewAI] A2UI Fixed Schema renders hotel search results", async ({ await a2ui.assertSurfaceContainsAll(["The Manhattan Grand", "Downtown Boutique Hotel"]); // HotelCard renders the numeric rating value via StarRating. - const surface = a2ui.surface("hotel-search-results"); + const surface = a2ui.visibleSurface("hotel-search-results"); await expect(surface.getByText("4.5").first()).toBeVisible(); }); + +test("[CrewAI] A2UI Fixed Schema answers an action click about that choice", async ({ + page, +}) => { + await page.goto("/crewai/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Search for hotels in downtown Manhattan for next weekend."); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + + // The search turn ends on the tool call, so this closing reply exists only + // because the flow loops the model over its own tool result. A single-shot + // flow renders the surface and says nothing, failing here. + await a2ui.assertAgentReplyVisible(/here are your results/i); + + // Counted through the page object's single-surface helper: an action run can + // repaint the same surface id, and a count across every node with that id + // would then double and report the wrong number of clicks. + const booked = a2ui.surfaceActions("Booked", "hotel-search-results"); + + // Book the SECOND card. The reply is built from the forwarded action context, + // so naming the first hotel (or a hotel at all on a flight surface) fails. + await a2ui.clickSurfaceAction("Book", "hotel-search-results", { nth: 1 }); + await expect(booked).toHaveCount(1); + await a2ui.assertAgentReplyVisible(/booked at Downtown Boutique Hotel for \$280/i); + + // A second click is answered about the second choice; the earlier report is + // still in the history, so replaying the first reply here is a failure. + await a2ui.clickSurfaceAction("Book", "hotel-search-results", { nth: 0 }); + await expect(booked).toHaveCount(2); + await a2ui.assertAgentReplyVisible(/booked at The Manhattan Grand for \$350/i); +}); + +test("[CrewAI] A2UI Fixed Schema answers a flight selection about that flight", async ({ + page, +}) => { + await page.goto("/crewai/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Search for flights from SFO to JFK for next Tuesday."); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + await a2ui.assertAgentReplyVisible(/here are your results/i); + + await a2ui.clickSurfaceAction("Select", "flight-search-results", { nth: 1 }); + await expect( + a2ui.surfaceActions("Selected", "flight-search-results"), + ).toHaveCount(1); + await a2ui.assertAgentReplyVisible(/booked on DL 456 from SFO to JFK for \$315/i); +}); diff --git a/apps/dojo/e2e/tests/crewAITests/a2uiSurfaceCounting.spec.ts b/apps/dojo/e2e/tests/crewAITests/a2uiSurfaceCounting.spec.ts new file mode 100644 index 0000000000..7b3d97975b --- /dev/null +++ b/apps/dojo/e2e/tests/crewAITests/a2uiSurfaceCounting.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A DOM-only test of the A2UIPage surface helpers: it drives static markup via +// page.setContent, so it needs no dojo server, no agent and no mock LLM. +// +// It exists because the A2UI action specs assert button COUNTS ("exactly one +// Booked"), and a surface can legitimately be painted twice for the same surface +// id: an action run repaints it, and a stale hidden node can linger in the DOM. +// A count taken across every node with that id then doubles, so the assertion +// reports the duplicate paint as a wrong number of clicks. The helpers under test +// scope the count to a single visible surface. + +const SURFACE_ID = "hotel-search-results"; + +/** A surface node with three hotel cards, the second one already booked. */ +const surfaceMarkup = (hidden = false) => ` +
+
The Manhattan Grand
+
Downtown Boutique Hotel
+
Midtown Suites
+
+`; + +test("[CrewAI] A2UIPage counts actions on ONE surface when the same id is painted twice", async ({ + page, +}) => { + await page.setContent(surfaceMarkup() + surfaceMarkup()); + + const a2ui = new A2UIPage(page); + + // The hazard: `surface()` stays multi-element on purpose (callers count + // surfaces with it), so a count through it doubles on a repaint. + await expect( + a2ui.surface(SURFACE_ID).getByRole("button", { name: "Booked", exact: true }), + ).toHaveCount(2); + + // The helpers the specs use are unaffected by the duplicate node. + await expect(a2ui.surfaceActions("Booked", SURFACE_ID)).toHaveCount(1); + await expect(a2ui.surfaceActions("Book", SURFACE_ID)).toHaveCount(2); + await expect( + a2ui.visibleSurface(SURFACE_ID).getByRole("button", { name: "Booked", exact: true }), + ).toHaveCount(1); +}); + +test("[CrewAI] A2UIPage counts actions on the VISIBLE surface, not a stale hidden one", async ({ + page, +}) => { + // The stale node comes first in the DOM, so a `.first()` that ignored + // visibility would count inside the surface the user cannot see. + await page.setContent(surfaceMarkup(true) + surfaceMarkup()); + + const a2ui = new A2UIPage(page); + + await expect(a2ui.surfaceActions("Booked", SURFACE_ID)).toHaveCount(1); + await expect(a2ui.surfaceActions("Booked", SURFACE_ID)).toBeVisible(); +}); diff --git a/apps/dojo/e2e/tests/crewAITests/agenticChatMultimodalPage.spec.ts b/apps/dojo/e2e/tests/crewAITests/agenticChatMultimodalPage.spec.ts new file mode 100644 index 0000000000..65601688fd --- /dev/null +++ b/apps/dojo/e2e/tests/crewAITests/agenticChatMultimodalPage.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "../../test-isolation-helper"; +import * as path from "path"; +import { + sendChatMessage, + awaitLLMResponseDone, + openChat, +} from "../../utils/copilot-actions"; +import { CopilotSelectors } from "../../utils/copilot-selectors"; + +const TEST_IMAGE = path.join(import.meta.dirname, "../../fixtures/test-image.png"); + +// The attached image is converted to LiteLLM's image_url shape by the bridge +// before the CrewAI flow forwards it to a vision model. +test.describe("[Integration] CrewAI - Agentic Chat Multimodal", () => { + test("should upload an image and receive a description", async ({ page }) => { + await page.goto("/crewai/feature/agentic_chat_multimodal"); + await openChat(page); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(TEST_IMAGE); + + await sendChatMessage(page, "Tell me what do you see in this image"); + await awaitLLMResponseDone(page); + + const lastAssistant = CopilotSelectors.assistantMessages(page).last(); + await expect(lastAssistant).toContainText(/image|visual|content|see|picture/i, { + timeout: 10000, + }); + }); +}); diff --git a/apps/dojo/e2e/tests/crewAITests/agenticChatReasoningPage.spec.ts b/apps/dojo/e2e/tests/crewAITests/agenticChatReasoningPage.spec.ts new file mode 100644 index 0000000000..3e0c374588 --- /dev/null +++ b/apps/dojo/e2e/tests/crewAITests/agenticChatReasoningPage.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../test-isolation-helper"; +import { + sendChatMessage, + awaitLLMResponseDone, + openChat, +} from "../../utils/copilot-actions"; +import { CopilotSelectors } from "../../utils/copilot-selectors"; + +// The reasoning cell lets the user pick a provider (state.model). OpenAI's +// reasoning models expose their reasoning summaries only over the Responses API, +// so the bridge streams the OpenAI option there and maps the summaries onto +// REASONING_*; the default (OpenAI) therefore surfaces a real thinking trace. +// Anthropic / Gemini reason on the chat-completions delta and are covered by the +// bridge suite (integrations/crew-ai/python/tests/test_reasoning.py). +test.describe("[Integration] CrewAI - Agentic Chat Reasoning", () => { + test("should display the model selection dropdown", async ({ page }) => { + await page.goto("/crewai/feature/agentic_chat_reasoning"); + + const dropdown = page.getByRole("button", { + name: /OpenAI|Anthropic|Gemini/i, + }); + await expect(dropdown).toBeVisible({ timeout: 10000 }); + }); + + test("should show reasoning indicator and then the response", async ({ page }) => { + await page.goto("/crewai/feature/agentic_chat_reasoning"); + await openChat(page); + + await sendChatMessage(page, "What is the best car to buy?"); + await awaitLLMResponseDone(page); + + // The reasoning UI renders "Thought for Xs" after reasoning completes. + // + // Asserted hard rather than conditionally. The flow surfaces a trace only + // over the Responses channel and deliberately degrades to chat-completions + // (no trace) when the bridge probes that channel as unavailable, but that + // degrade is unreachable here: the probe is a capability check on the + // resolved litellm entrypoint, and the crew-ai server this suite runs + // against installs the locked litellm, which exposes it. Treating a missing + // trace as an acceptable outcome would instead leave the demo's whole point + // untested, since "answers with no thinking trace" is exactly the regression + // this test exists to catch and the browser cannot tell it apart from the + // degrade. The message names the degrade path so an out-of-floor litellm is + // diagnosed as such instead of being chased through the UI. + const reasoningIndicator = page.getByText(/Thought for/i); + await expect( + reasoningIndicator, + "no reasoning trace: either REASONING events stopped reaching the UI, or " + + "the crew-ai server degraded to chat-completions because its litellm " + + "exposes no Responses entrypoint (it logs that warning when it does)", + ).toBeVisible({ timeout: 10000 }); + + const lastAssistant = CopilotSelectors.assistantMessages(page).last(); + await expect(lastAssistant).toContainText( + /Toyota|Honda|Mazda|recommendations/i, + { timeout: 15000 }, + ); + }); +}); diff --git a/apps/dojo/e2e/tests/crewAITests/backendToolRenderingPage.spec.ts b/apps/dojo/e2e/tests/crewAITests/backendToolRenderingPage.spec.ts new file mode 100644 index 0000000000..a8beb281ff --- /dev/null +++ b/apps/dojo/e2e/tests/crewAITests/backendToolRenderingPage.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "../../test-isolation-helper"; +import { awaitLLMResponseDone } from "../../utils/copilot-actions"; + +// The weather agent runs a real crew: the model calls the backend get_weather +// tool, the crew executes it server-side, and the bridge surfaces the call + +// result so the client renders a weather card. The crew's own agent loop makes +// two LLM calls (tool-call turn, then final-answer turn); both are mocked by the +// Weather-Assistant fixtures in aimock-setup.ts. +test("[CrewAI] Backend Tool Rendering displays weather cards", async ({ + page, +}) => { + await page.goto("/crewai/feature/backend_tool_rendering"); + + // Verify suggestion buttons are visible + await expect( + page.getByRole("button", { name: "Weather in San Francisco" }), + ).toBeVisible({ + timeout: 5000, + }); + + // Click first suggestion and verify weather card appears + await page.getByRole("button", { name: "Weather in San Francisco" }).click(); + + // Wait for either test ID or fallback to "Current Weather" text + const weatherCard = page.getByTestId("weather-card"); + const currentWeatherText = page.getByText("Current Weather"); + + // Try test ID first, fallback to text + try { + await expect(weatherCard.first()).toBeVisible(); + } catch (e) { + // Fallback to checking for "Current Weather" text + await expect(currentWeatherText.first()).toBeVisible(); + } + + // Verify weather content is present (use flexible selectors) + const hasHumidity = await page + .getByText("Humidity") + .first() + .isVisible() + .catch(() => false); + const hasWind = await page + .getByText("Wind") + .first() + .isVisible() + .catch(() => false); + const hasCityName = await page + .locator("h3") + .filter({ hasText: /San Francisco/i }) + .isVisible() + .catch(() => false); + + // At least one of these should be true + expect(hasHumidity || hasWind || hasCityName).toBeTruthy(); + + // Click second suggestion + await page.getByRole("button", { name: "Weather in New York" }).click(); + await awaitLLMResponseDone(page); + + // Verify at least one weather-related element is still visible + const weatherElements = await page + .getByText(/Weather|Humidity|Wind|Temperature/i) + .count(); + expect(weatherElements).toBeGreaterThan(0); +}); diff --git a/apps/dojo/e2e/tests/crewAITests/errorFlowPage.spec.ts b/apps/dojo/e2e/tests/crewAITests/errorFlowPage.spec.ts deleted file mode 100644 index d4bbd43b03..0000000000 --- a/apps/dojo/e2e/tests/crewAITests/errorFlowPage.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { test, expect } from "../../test-isolation-helper"; -import { AgenticChatPage } from "../../featurePages/AgenticChatPage"; -import { sendChatMessage } from "../../utils/copilot-actions"; - -// RunErrorEvent does not transition data-copilot-running to false in the -// CopilotKit frontend. When the backend raises before any LLM call, the SSE -// stream emits RunErrorEvent and closes, but CopilotKit never sets -// data-copilot-running="false". This causes both sendMessage (via -// awaitLLMResponseDone) and manual waitForFunction to hang/timeout. -// This is a CopilotKit frontend bug — RunErrorEvent should terminate the -// running state so the UI doesn't show an infinite spinner. -test.fixme( - "[CrewAI] Error flow emits RunErrorEvent on backend exception", - async ({ page }) => { - await page.goto("/crewai/feature/error_flow"); - - const chat = new AgenticChatPage(page); - - await chat.openChat(); - await expect(chat.agentGreeting).toBeVisible(); - - await sendChatMessage(page, "trigger error"); - await chat.assertUserMessageVisible("trigger error"); - - // Wait for CopilotKit to process the error - await page.waitForFunction( - () => { - const el = document.querySelector("[data-copilot-running]"); - return ( - el === null || el.getAttribute("data-copilot-running") === "false" - ); - }, - null, - { timeout: 10_000 }, - ); - - // Verify no successful assistant response beyond the greeting - const messageCount = await chat.agentMessage.count(); - expect(messageCount).toBeLessThanOrEqual(1); - }, -); diff --git a/apps/dojo/e2e/tests/crewAITests/subgraphsPage.spec.ts b/apps/dojo/e2e/tests/crewAITests/subgraphsPage.spec.ts new file mode 100644 index 0000000000..d680f54c7c --- /dev/null +++ b/apps/dojo/e2e/tests/crewAITests/subgraphsPage.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "../../test-isolation-helper"; +import { SubgraphsPage } from "../../pages/crewAIPages/SubgraphsPage"; + +// Multi-agent travel planner on CrewAI. The supervisor hands off to the flights +// and hotels specialists, each of which suspends the flow (@human_feedback) to +// surface a selection picker, then the experiences specialist narrates. Exercises +// nested-agent attribution, sequential interrupts, and shared-state streaming +// together, at parity with the LangGraph subgraphs cell. +test.describe("Subgraphs Travel Agent Feature", () => { + test("[CrewAI] should complete full travel planning flow with feature validation", async ({ + page, + }) => { + const subgraphsPage = new SubgraphsPage(page); + + await page.goto("/crewai/feature/subgraphs"); + await subgraphsPage.openChat(); + + await subgraphsPage.sendMessage("Help me plan a trip to San Francisco"); + + await subgraphsPage.waitForFlightsAgent(); + await subgraphsPage.verifyStaticFlightData(); + + await subgraphsPage.selectFlight("KLM"); + await expect(subgraphsPage.selectedFlight) + .toContainText("KLM") + .catch(async () => { + await expect(page.getByText(/KLM/i).first()).toBeVisible({ timeout: 2000 }); + }); + + await subgraphsPage.waitForHotelsAgent(); + await subgraphsPage.verifyStaticHotelData(); + + await subgraphsPage.selectHotel("Zoe"); + await expect(subgraphsPage.selectedHotel) + .toContainText("Zoe") + .catch(async () => { + await expect(page.getByText(/Hotel Zoe|Zoe/i).first()).toBeVisible({ timeout: 2000 }); + }); + + await subgraphsPage.waitForExperiencesAgent(); + await subgraphsPage.verifyStaticExperienceData(); + }); + + test("[CrewAI] should handle a different flight and hotel selection", async ({ + page, + }) => { + const subgraphsPage = new SubgraphsPage(page); + + await page.goto("/crewai/feature/subgraphs"); + await subgraphsPage.openChat(); + + await subgraphsPage.sendMessage("I want to visit San Francisco from Amsterdam"); + + await subgraphsPage.waitForFlightsAgent(); + await subgraphsPage.verifyStaticFlightData(); + + await subgraphsPage.selectFlight("United"); + await expect(page.getByText(/United/i).first()).toBeVisible(); + + await subgraphsPage.waitForHotelsAgent(); + await subgraphsPage.verifyStaticHotelData(); + + await subgraphsPage.selectHotel("Ritz-Carlton"); + await expect(page.getByText(/Ritz-Carlton/i).first()).toBeVisible(); + + await subgraphsPage.waitForExperiencesAgent(); + await subgraphsPage.verifyStaticExperienceData(); + }); +}); diff --git a/apps/dojo/e2e/utils/copilot-actions.ts b/apps/dojo/e2e/utils/copilot-actions.ts index 8d3f6713e7..277d86044c 100644 --- a/apps/dojo/e2e/utils/copilot-actions.ts +++ b/apps/dojo/e2e/utils/copilot-actions.ts @@ -36,6 +36,25 @@ async function waitForCurrentCopilotRunToFinish( await waitForNoActiveCopilotRun(page, timeout); } +/** + * Wait until the assistant message count grows past `countBefore`, proving the + * run we just triggered has actually started and we are not observing a stale + * idle flag from the previous run. + */ +async function waitForNewAssistantMessage( + page: Page, + countBefore: number, + timeout = LLM_RESPONSE_TIMEOUT, +) { + await page.waitForFunction( + (before) => + document.querySelectorAll('[data-testid="copilot-assistant-message"]') + .length > before, + countBefore, + { timeout }, + ); +} + async function expectSubmittedUserMessage( page: Page, userMessageIndex: number, @@ -111,19 +130,36 @@ export async function sendAndAwaitResponse( // Wait for a NEW assistant message to appear, proving the agent // started responding to our message (not a stale previous response). - await page.waitForFunction( - (before) => - document.querySelectorAll('[data-testid="copilot-assistant-message"]') - .length > before, - countBefore, - { timeout }, - ); + await waitForNewAssistantMessage(page, countBefore, timeout); // Now wait for the current run to finish. This helper first gives the UI a // chance to report running=true, so a stale idle flag cannot end the wait. await waitForCurrentCopilotRunToFinish(page, timeout); } +/** + * Run an interaction that starts an agent run without going through the chat + * input (clicking a button rendered by the agent, for example) and wait for + * that run to finish. + * + * Anchors on a NEW assistant message the same way `sendAndAwaitResponse` does: + * `awaitLLMResponseDone` alone can return before the triggered run has started, + * because its run-start window is short and a stale idle flag then ends the + * wait immediately, leaving the caller's assertions racing the response. + */ +export async function awaitResponseAfterAction( + page: Page, + action: () => Promise, + timeout = LLM_RESPONSE_TIMEOUT, +) { + const countBefore = await CopilotSelectors.assistantMessages(page).count(); + + await action(); + + await waitForNewAssistantMessage(page, countBefore, timeout); + await waitForCurrentCopilotRunToFinish(page, timeout); +} + /** * Assert that the last assistant message contains the expected text. */ diff --git a/apps/dojo/package.json b/apps/dojo/package.json index e847971413..7d3d2bae42 100644 --- a/apps/dojo/package.json +++ b/apps/dojo/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "mastra:dev": "mastra dev", "generate-content-json": "npx tsx scripts/generate-content-json.ts", + "test:crewai-config": "tsx --test src/crewai.test.ts", "run-everything": "./scripts/prep-dojo-everything.js && ./scripts/run-dojo-everything.js", "local-install": "bash scripts/local-install.sh" }, diff --git a/apps/dojo/scripts/generate-content-json.ts b/apps/dojo/scripts/generate-content-json.ts index f0f2287af9..fe6264dc56 100644 --- a/apps/dojo/scripts/generate-content-json.ts +++ b/apps/dojo/scripts/generate-content-json.ts @@ -236,12 +236,8 @@ const agentFilesMapper: Record< tool_based_generative_ui: [ path.join(__dirname, "../src/mastra/agents/tool-based-generative-ui.ts"), ], - a2ui_dynamic_schema: [ - path.join(__dirname, "../src/mastra/agents/a2ui.ts"), - ], - a2ui_recovery: [ - path.join(__dirname, "../src/mastra/agents/a2ui.ts"), - ], + a2ui_dynamic_schema: [path.join(__dirname, "../src/mastra/agents/a2ui.ts")], + a2ui_recovery: [path.join(__dirname, "../src/mastra/agents/a2ui.ts")], a2ui_fixed_schema: [ path.join(__dirname, "../src/mastra/agents/a2ui-fixed.ts"), ], @@ -373,6 +369,32 @@ const agentFilesMapper: Record< {}, ); }, + "crewai-conversational-flows": (agentKeys: string[]) => { + return agentKeys.reduce( + (acc, agentId) => ({ + ...acc, + [agentId]: [ + path.join( + __dirname, + integrationsFolderPath, + "/crew-ai/python/ag_ui_crewai/examples/conversational.py", + ), + path.join( + __dirname, + integrationsFolderPath, + `/crew-ai/python/ag_ui_crewai/examples/${ + agentId === "v1_agentic_chat" + ? "agentic_chat" + : agentId === "interrupt" + ? "interrupt_flow" + : agentId + }.py`, + ), + ], + }), + {}, + ); + }, "adk-middleware": (agentKeys: string[]) => { return agentKeys.reduce( (acc, agentId) => ({ diff --git a/apps/dojo/src/agents.ts b/apps/dojo/src/agents.ts index ce225eddd3..e056066667 100644 --- a/apps/dojo/src/agents.ts +++ b/apps/dojo/src/agents.ts @@ -36,6 +36,10 @@ import { Ag2Agent } from "@ag-ui/ag2"; import { LangroidHttpAgent } from "@ag-ui/langroid"; import { WatsonxAgent } from "@ag-ui/watsonx"; import { A2UIMiddleware } from "@ag-ui/a2ui-middleware"; +import { + CREWAI_CONVERSATIONAL_AGENT_PATHS, + CREWAI_FLOW_AGENT_PATHS, +} from "./crewai"; const envVars = getEnvVars(); @@ -76,6 +80,24 @@ export const CREWAI_A2UI_INJECT_AGENTS: string[] = [ "a2ui_recovery", ]; +function createCrewAIIntegrationAgents>( + paths: T, +) { + const agents = mapAgents( + (path) => new CrewAIAgent({ url: `${envVars.crewAiUrl}/${path}` }), + paths, + ); + for (const id of CREWAI_A2UI_INJECT_AGENTS) { + (agents as Record)[id]?.use( + new A2UIMiddleware({ + injectA2UITool: true, + defaultCatalogId: A2UI_DOJO_CATALOG_ID, + }), + ); + } + return agents; +} + export const agentsIntegrations = { "middleware-starter": async () => ({ agentic_chat: new MiddlewareStarterAgent(), @@ -155,7 +177,9 @@ export const agentsIntegrations = { return MastraAgent.getRemoteAgents({ // Cast needed: pnpm may resolve separate @mastra/client-js installations // for dojo vs @ag-ui/mastra, causing nominal type mismatch on private fields - mastraClient: mastraClient as any, + mastraClient: mastraClient as unknown as Parameters< + typeof MastraAgent.getRemoteAgents + >[0]["mastraClient"], resourceId: "mastra-agent-remote", // Surface Observational Memory background work as AG-UI activity events // for the `observational_memory` demo only (default OFF for all others). @@ -183,7 +207,9 @@ export const agentsIntegrations = { const base = MastraAgent.getLocalAgents({ // Cast needed: pnpm may resolve separate @mastra/core installations // for dojo vs @ag-ui/mastra, causing nominal type mismatch on private fields - mastra: mastra as any, + mastra: mastra as unknown as Parameters< + typeof MastraAgent.getLocalAgents + >[0]["mastra"], resourceId: "mastra-agent-local", // Surface Observational Memory background work as AG-UI activity events // for the `observational_memory` demo only (default OFF for all others). @@ -195,7 +221,7 @@ export const agentsIntegrations = { // so the runtime's per-request `clone()` preserves it. const wrapA2UI = (agent: unknown): AbstractAgent => new MastraAgent({ - agent: agent as any, + agent: agent as ConstructorParameters[0]["agent"], resourceId: "mastra-agent-local", a2ui: a2uiInjectConfig, }) as unknown as AbstractAgent; @@ -203,7 +229,7 @@ export const agentsIntegrations = { // bridge never adds generate_a2ui alongside search_flights/search_hotels. const wrapA2UIFixed = (agent: unknown): AbstractAgent => new MastraAgent({ - agent: agent as any, + agent: agent as ConstructorParameters[0]["agent"], resourceId: "mastra-agent-local", a2ui: { injectA2UITool: false }, }) as unknown as AbstractAgent; @@ -408,38 +434,10 @@ export const agentsIntegrations = { }, ), - crewai: async () => { - const agents = mapAgents( - (path) => new CrewAIAgent({ url: `${envVars.crewAiUrl}/${path}` }), - { - agentic_chat: "agentic_chat", - backend_tool_rendering: "backend_tool_rendering", - interrupt: "interrupt", - human_in_the_loop: "human_in_the_loop", - tool_based_generative_ui: "tool_based_generative_ui", - agentic_generative_ui: "agentic_generative_ui", - shared_state: "shared_state", - predictive_state_updates: "predictive_state_updates", - crew_chat: "crew_chat", - error_flow: "error_flow", - a2ui_dynamic_schema: "a2ui_dynamic_schema", - a2ui_recovery: "a2ui_recovery", - a2ui_fixed_schema: "a2ui_fixed_schema", - }, - ); - // Auto-inject generate_a2ui for the subagent demos (dynamic + recovery); - // a2ui_fixed_schema wires its own backend tools and is deliberately left - // out. Excluded from the runtime a2ui config in route.ts (double-apply). - for (const id of CREWAI_A2UI_INJECT_AGENTS) { - (agents as Record)[id]?.use( - new A2UIMiddleware({ - injectA2UITool: true, - defaultCatalogId: A2UI_DOJO_CATALOG_ID, - }), - ); - } - return agents; - }, + crewai: async () => createCrewAIIntegrationAgents(CREWAI_FLOW_AGENT_PATHS), + + "crewai-conversational-flows": async () => + createCrewAIIntegrationAgents(CREWAI_CONVERSATIONAL_AGENT_PATHS), "agent-spec-langgraph": async () => mapAgents( diff --git a/apps/dojo/src/app/[integrationId]/feature/(v2)/error_flow/README.mdx b/apps/dojo/src/app/[integrationId]/feature/(v2)/error_flow/README.mdx deleted file mode 100644 index 55ab873b73..0000000000 --- a/apps/dojo/src/app/[integrationId]/feature/(v2)/error_flow/README.mdx +++ /dev/null @@ -1,20 +0,0 @@ -# ⚠️ Error Flow (RunErrorEvent Test) - -## What This Demo Shows - -This demo exercises the **error handling path** in the CrewAI endpoint. The -backend flow intentionally raises a `RuntimeError` on every request, which -triggers the `except Exception` handler in `endpoint.py` that emits a -`RunErrorEvent` via SSE. - -## How to Interact - -Send any message — the flow will raise immediately and no successful assistant -response will be generated. - -## Technical Details - -- `ErrorFlow` raises `RuntimeError` in its `@start()` method before any LLM call -- The `event_generator` exception handler catches it and emits `RunErrorEvent` -- This verifies that backend exceptions are properly surfaced to the client - rather than silently swallowed diff --git a/apps/dojo/src/app/[integrationId]/feature/(v2)/error_flow/page.tsx b/apps/dojo/src/app/[integrationId]/feature/(v2)/error_flow/page.tsx deleted file mode 100644 index e5125d72fb..0000000000 --- a/apps/dojo/src/app/[integrationId]/feature/(v2)/error_flow/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; -import React from "react"; -import "@copilotkit/react-core/v2/styles.css"; -import { CopilotChat } from "@copilotkit/react-core/v2"; -import { CopilotKit } from "@copilotkit/react-core"; - -interface ErrorFlowProps { - params: Promise<{ - integrationId: string; - }>; -} - -const ErrorFlowPage: React.FC = ({ params }) => { - const { integrationId } = React.use(params); - - return ( - -
-
- -
-
-
- ); -}; - -export default ErrorFlowPage; diff --git a/apps/dojo/src/app/[integrationId]/feature/(v2)/subgraphs/page.tsx b/apps/dojo/src/app/[integrationId]/feature/(v2)/subgraphs/page.tsx index 2f21651c66..7f2295b291 100644 --- a/apps/dojo/src/app/[integrationId]/feature/(v2)/subgraphs/page.tsx +++ b/apps/dojo/src/app/[integrationId]/feature/(v2)/subgraphs/page.tsx @@ -316,7 +316,13 @@ function TravelPlanner() { }, []); useLangGraphInterrupt({ - render: ({ event, resolve }) => , + render: ({ event, resolve }) => { + // CrewAI suspends the whole flow, so the specialist's payload arrives under + // metadata.crewai.output; LangGraph puts the raw value on event.value directly. + const raw = (event.value ?? {}) as any; + const value = raw?.metadata?.crewai?.output ?? raw; + return ; + }, }); // Current itinerary strip diff --git a/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts b/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts index abad6a3f92..463cd77ecd 100644 --- a/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts +++ b/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts @@ -70,7 +70,8 @@ async function getHandler(integrationId: string) { : integrationId === "aws-strands" || integrationId === "aws-strands-typescript" ? STRANDS_A2UI_INJECT_AGENTS - : integrationId === "crewai" + : integrationId === "crewai" || + integrationId === "crewai-conversational-flows" ? CREWAI_A2UI_INJECT_AGENTS : []; const a2uiAgents = allA2UIAgents.filter( @@ -107,7 +108,8 @@ export async function POST(request: NextRequest, context: RouteParams) { if (!handler) { return new Response("Integration not found", { status: 404 }); } - const distinctId = request.headers.get("x-posthog-distinct-id") || "anonymous"; + const distinctId = + request.headers.get("x-posthog-distinct-id") || "anonymous"; const posthog = getPostHogClient(); posthog?.capture({ distinctId, diff --git a/apps/dojo/src/config.ts b/apps/dojo/src/config.ts index fdb6f1d230..40cb7047e7 100644 --- a/apps/dojo/src/config.ts +++ b/apps/dojo/src/config.ts @@ -148,13 +148,6 @@ export const featureConfig: FeatureConfig[] = [ description: "Chat with a CrewAI crew wrapped in a dict-state chat flow", tags: ["Chat", "CrewAI", "Streaming"], }), - createFeatureConfig({ - id: "error_flow", - name: "Error Flow", - description: - "Backend flow that raises an error, surfaced to the client as a RunErrorEvent", - tags: ["CrewAI", "Error Handling"], - }), ]; export default featureConfig; diff --git a/apps/dojo/src/crewai.test.ts b/apps/dojo/src/crewai.test.ts new file mode 100644 index 0000000000..237d9f8960 --- /dev/null +++ b/apps/dojo/src/crewai.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CREWAI_CONVERSATIONAL_AGENT_PATHS, + CREWAI_CONVERSATIONAL_FEATURES, + CREWAI_FLOW_AGENT_PATHS, + CREWAI_FLOW_FEATURES, +} from "./crewai"; +import { menuIntegrations } from "./menu"; + +const parityFeatures = [ + "agentic_chat", + "agentic_chat_reasoning", + "agentic_chat_multimodal", + "v1_agentic_chat", + "backend_tool_rendering", + "interrupt", + "human_in_the_loop", + "agentic_generative_ui", + "predictive_state_updates", + "shared_state", + "tool_based_generative_ui", + "subgraphs", + "a2ui_dynamic_schema", + "a2ui_recovery", + "a2ui_fixed_schema", +] as const; + +test("conversational features match regular Flow parity without crew chat", () => { + assert.deepEqual(CREWAI_CONVERSATIONAL_FEATURES, parityFeatures); + assert.deepEqual(CREWAI_FLOW_FEATURES, [...parityFeatures, "crew_chat"]); +}); + +test("conversational agents use their dedicated backend route prefix", () => { + for (const [feature, path] of Object.entries( + CREWAI_CONVERSATIONAL_AGENT_PATHS, + )) { + assert.equal(path, `conversational_flows/${feature}`); + } + assert.equal(CREWAI_FLOW_AGENT_PATHS.crew_chat, "crew_chat"); + assert.equal("crew_chat" in CREWAI_CONVERSATIONAL_AGENT_PATHS, false); +}); + +test("dojo exposes separate stable framework identities", () => { + const regular = menuIntegrations.find(({ id }) => id === "crewai"); + const conversational = menuIntegrations.find( + ({ id }) => id === "crewai-conversational-flows", + ); + + assert.equal(regular?.name, "CrewAI Flows"); + assert.equal(conversational?.name, "CrewAI Conversational Flows"); + assert.deepEqual(conversational?.features, CREWAI_CONVERSATIONAL_FEATURES); +}); diff --git a/apps/dojo/src/crewai.ts b/apps/dojo/src/crewai.ts new file mode 100644 index 0000000000..b9a96c9624 --- /dev/null +++ b/apps/dojo/src/crewai.ts @@ -0,0 +1,51 @@ +export const CREWAI_CONVERSATIONAL_FEATURES = [ + "agentic_chat", + "agentic_chat_reasoning", + "agentic_chat_multimodal", + "v1_agentic_chat", + "backend_tool_rendering", + "interrupt", + "human_in_the_loop", + "agentic_generative_ui", + "predictive_state_updates", + "shared_state", + "tool_based_generative_ui", + "subgraphs", + "a2ui_dynamic_schema", + "a2ui_recovery", + "a2ui_fixed_schema", +] as const; + +export const CREWAI_FLOW_FEATURES = [ + ...CREWAI_CONVERSATIONAL_FEATURES, + "crew_chat", +] as const; + +export const CREWAI_FLOW_AGENT_PATHS = { + agentic_chat: "agentic_chat", + agentic_chat_reasoning: "agentic_chat_reasoning", + agentic_chat_multimodal: "agentic_chat_multimodal", + backend_tool_rendering: "backend_tool_rendering", + interrupt: "interrupt", + human_in_the_loop: "human_in_the_loop", + tool_based_generative_ui: "tool_based_generative_ui", + agentic_generative_ui: "agentic_generative_ui", + shared_state: "shared_state", + predictive_state_updates: "predictive_state_updates", + subgraphs: "subgraphs", + crew_chat: "crew_chat", + a2ui_dynamic_schema: "a2ui_dynamic_schema", + a2ui_recovery: "a2ui_recovery", + a2ui_fixed_schema: "a2ui_fixed_schema", +} as const; + +export const CREWAI_CONVERSATIONAL_AGENT_PATHS = Object.fromEntries( + Object.entries(CREWAI_FLOW_AGENT_PATHS) + .filter(([feature]) => feature !== "crew_chat") + .map(([feature, path]) => [feature, `conversational_flows/${path}`]), +) as { + [K in Exclude< + keyof typeof CREWAI_FLOW_AGENT_PATHS, + "crew_chat" + >]: `conversational_flows/${(typeof CREWAI_FLOW_AGENT_PATHS)[K]}`; +}; diff --git a/apps/dojo/src/files.json b/apps/dojo/src/files.json index a475b4208a..5768eaad67 100644 --- a/apps/dojo/src/files.json +++ b/apps/dojo/src/files.json @@ -498,7 +498,7 @@ "langgraph::subgraphs": [ { "name": "page.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => ,\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => {\n // CrewAI suspends the whole flow, so the specialist's payload arrives under\n // metadata.crewai.output; LangGraph puts the raw value on event.value directly.\n const raw = (event.value ?? {}) as any;\n const value = raw?.metadata?.crewai?.output ?? raw;\n return ;\n },\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", "language": "typescript", "type": "file" }, @@ -844,7 +844,7 @@ "langgraph-fastapi::subgraphs": [ { "name": "page.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => ,\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => {\n // CrewAI suspends the whole flow, so the specialist's payload arrives under\n // metadata.crewai.output; LangGraph puts the raw value on event.value directly.\n const raw = (event.value ?? {}) as any;\n const value = raw?.metadata?.crewai?.output ?? raw;\n return ;\n },\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", "language": "typescript", "type": "file" }, @@ -1194,7 +1194,7 @@ "langgraph-typescript::subgraphs": [ { "name": "page.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => ,\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => {\n // CrewAI suspends the whole flow, so the specialist's payload arrives under\n // metadata.crewai.output; LangGraph puts the raw value on event.value directly.\n const raw = (event.value ?? {}) as any;\n const value = raw?.metadata?.crewai?.output ?? raw;\n return ;\n },\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", "language": "typescript", "type": "file" }, @@ -2612,7 +2612,7 @@ "microsoft-agent-framework-dotnet::subgraphs": [ { "name": "page.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => ,\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => {\n // CrewAI suspends the whole flow, so the specialist's payload arrives under\n // metadata.crewai.output; LangGraph puts the raw value on event.value directly.\n const raw = (event.value ?? {}) as any;\n const value = raw?.metadata?.crewai?.output ?? raw;\n return ;\n },\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", "language": "typescript", "type": "file" }, @@ -3604,7 +3604,53 @@ }, { "name": "agentic_chat.py", - "content": "\"\"\"\nA simple agentic chat flow.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\nclass AgenticChatFlow(Flow[CopilotKitState]):\n\n @start()\n async def chat(self):\n system_prompt = \"You are a helpful assistant.\"\n\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-4o\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the available tools to the model\n tools=[\n *self.state.copilotkit.actions,\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n", + "content": "\"\"\"\nA simple agentic chat flow.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\nclass AgenticChatFlow(Flow[CopilotKitState]):\n\n @start()\n async def chat(self):\n system_prompt = \"You are a helpful assistant.\"\n\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the available tools to the model\n tools=[\n *self.state.copilotkit.actions,\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n", + "language": "python", + "type": "file" + } + ], + "crewai::agentic_chat_reasoning": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useFrontendTool,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { ChevronDown } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticChatProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticChat: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\ninterface AgentState {\n model: string;\n}\n\nconst Chat = () => {\n const [background, setBackground] = useState(\"--copilot-kit-background-color\");\n const { agent } = useAgent({\n agentId: \"agentic_chat_reasoning\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as AgentState | undefined;\n\n // Initialize model if not set\n const selectedModel = agentState?.model || \"OpenAI\";\n\n const handleModelChange = (model: string) => {\n agent.setState({ model });\n };\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Change background\",\n message: \"Change the background to something new.\",\n },\n {\n title: \"Generate sonnet\",\n message: \"Write a short sonnet about AI.\",\n },\n ],\n available: \"always\",\n });\n\n useFrontendTool({\n agentId: \"agentic_chat_reasoning\",\n name: \"change_background\",\n description:\n \"Change the background color of the chat. Can be anything that the CSS background attribute accepts. Regular colors, linear of radial gradients etc.\",\n parameters: z.object({\n background: z.string().describe(\"The background. Prefer gradients.\"),\n }) ,\n handler: async ({ background }: { background: string }) => {\n setBackground(background);\n },\n });\n\n return (\n
\n {/* Reasoning Model Dropdown */}\n
\n
\n
\n \n Reasoning Model:\n \n \n \n \n \n \n Select Model\n \n handleModelChange(\"OpenAI\")}>\n OpenAI\n \n handleModelChange(\"Anthropic\")}>\n Anthropic\n \n handleModelChange(\"Gemini\")}>\n Gemini\n \n \n \n
\n
\n
\n\n {/* Chat Container */}\n
\n
\n \n
\n
\n
\n );\n};\n\nexport default AgenticChat;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": ".copilotKitInput {\n border-bottom-left-radius: 0.75rem;\n border-bottom-right-radius: 0.75rem;\n border-top-left-radius: 0.75rem;\n border-top-right-radius: 0.75rem;\n border: 1px solid var(--copilot-kit-separator-color) !important;\n}\n \n.copilotKitChat {\n background-color: #fff !important;\n}\n ", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🤖 Agentic Chat with Reasoning\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **agentic chat** capabilities with **frontend\ntool integration**:\n\n1. **Natural Conversation**: Chat with your Copilot in a familiar chat interface\n2. **Frontend Tool Execution**: The Copilot can directly interacts with your UI\n by calling frontend functions\n3. **Seamless Integration**: Tools defined in the frontend and automatically\n discovered and made available to the agent\n\n## How to Interact\n\nTry asking your Copilot to:\n\n- \"Can you change the background color to something more vibrant?\"\n- \"Make the background a blue to purple gradient\"\n- \"Set the background to a sunset-themed gradient\"\n- \"Change it back to a simple light color\"\n\nYou can also chat about other topics - the agent will respond conversationally\nwhile having the ability to use your UI tools when appropriate.\n\n## ✨ Frontend Tool Integration in Action\n\n**What's happening technically:**\n\n- The React component defines a frontend function using `useCopilotAction`\n- CopilotKit automatically exposes this function to the agent\n- When you make a request, the agent determines whether to use the tool\n- The agent calls the function with the appropriate parameters\n- The UI immediately updates in response\n\n**What you'll see in this demo:**\n\n- The Copilot understands requests to change the background\n- It generates CSS values for colors and gradients\n- When it calls the tool, the background changes instantly\n- The agent provides a conversational response about the changes it made\n\nThis technique of exposing frontend functions to your Copilot can be extended to\nany UI manipulation you want to enable, from theme changes to data filtering,\nnavigation, or complex UI state management!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agentic_chat_reasoning.py", + "content": "\"\"\"\nAn agentic chat flow that surfaces the model's reasoning.\n\nThe reasoning cell lets the user pick a provider from the frontend; the choice\narrives on ``state.model``. Each provider is streamed over the channel that\nactually carries its reasoning, and the bridge maps both onto REASONING_*:\n\n* Anthropic (extended thinking) and Gemini reason on the litellm\n chat-completions delta, so they stream through ``acompletion``.\n* OpenAI's reasoning models emit reasoning summaries ONLY over the Responses\n API, so they stream through ``copilotkit_responses``. Over chat-completions\n they answer with no thinking trace at all.\n\nThe Responses channel is used only when the bridge probes it as available\n(``responses_channel_available``); otherwise the flow degrades to\nchat-completions with a warning, and OpenAI answers without a trace.\n\"\"\"\n\nimport logging\nfrom typing import Any, Dict, List\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\n\nfrom ..sdk import (\n CopilotKitState,\n copilotkit_responses,\n copilotkit_stream,\n responses_channel_available,\n)\n\nlogger = logging.getLogger(\"ag_ui_crewai\")\n\nSYSTEM_PROMPT = \"You are a helpful assistant.\"\n\n# The frontend dropdown's choices. This is a USER selection, not a capability\n# inference: which transport carries a provider's reasoning is decided by the\n# bridge's runtime probe, never by matching on these model strings.\nOPENAI_MODEL = \"openai/gpt-5.4\"\nANTHROPIC_MODEL = \"anthropic/claude-sonnet-4-5\"\nGEMINI_MODEL = \"gemini/gemini-2.5-pro\"\n\n\nclass AgentState(CopilotKitState):\n \"\"\"Chat state plus the frontend-selected reasoning model.\"\"\"\n\n model: str = \"OpenAI\"\n\n\ndef _chat_completion_kwargs(selected_model: str) -> Dict[str, Any]:\n \"\"\"Map a chat-completions provider choice to its model + reasoning config.\"\"\"\n if selected_model == \"Anthropic\":\n return {\n \"model\": ANTHROPIC_MODEL,\n \"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 2000},\n }\n if selected_model == \"Gemini\":\n return {\n \"model\": GEMINI_MODEL,\n \"reasoning_effort\": \"low\",\n }\n # OpenAI over chat-completions: no reasoning content is returned, and\n # reasoning_effort is rejected outright for the gpt-5 family. Reached only\n # when the Responses channel is unavailable.\n return {\"model\": OPENAI_MODEL}\n\n\nclass AgenticChatReasoningFlow(Flow[AgentState]):\n\n @start()\n async def chat(self):\n messages = [\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n *self.state.messages,\n ]\n tools: List[Any] = [*self.state.copilotkit.actions]\n selected_model = self.state.model\n\n if selected_model == \"OpenAI\" and responses_channel_available():\n stream = await copilotkit_responses(\n model=OPENAI_MODEL,\n messages=messages,\n tools=tools or None,\n # ``summary`` is what makes OpenAI stream the reasoning summary\n # deltas at all; without it the run succeeds silently with no\n # trace to surface.\n reasoning={\"effort\": \"medium\", \"summary\": \"auto\"},\n # Forwarded through ``**kwargs``. One frontend tool call at a\n # time, matching the chat-completions branch and every other demo;\n # the OpenAI default is parallel.\n **({\"parallel_tool_calls\": False} if tools else {}),\n )\n else:\n if selected_model == \"OpenAI\":\n logger.warning(\n \"The OpenAI Responses channel is unavailable, so this run \"\n \"streams over chat-completions and will surface no thinking \"\n \"trace. Upgrade litellm to a build exposing 'aresponses'.\"\n )\n stream = await acompletion(\n messages=messages,\n tools=tools or None,\n parallel_tool_calls=False if tools else None,\n stream=True,\n **_chat_completion_kwargs(selected_model),\n )\n\n response = await copilotkit_stream(stream)\n\n self.state.messages.append(response.choices[0].message)\n", + "language": "python", + "type": "file" + } + ], + "crewai::agentic_chat_multimodal": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport {\n useFrontendTool,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticChatMultimodalProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticChatMultimodal: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\nconst Chat = () => {\n const [background, setBackground] = useState(\"--copilot-kit-background-color\");\n\n useFrontendTool({\n name: \"change_background\",\n description:\n \"Change the background color of the chat. Can be anything that the CSS background attribute accepts. Regular colors, linear or radial gradients etc.\",\n parameters: z.object({\n background: z.string().describe(\"The background. Prefer gradients. Only use when asked.\"),\n }),\n handler: async ({ background }: { background: string }) => {\n setBackground(background);\n return {\n status: \"success\",\n message: `Background changed to ${background}`,\n };\n },\n });\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Upload an image\",\n message: \"Describe what you see in the image I upload.\",\n },\n {\n title: \"Analyze a photo\",\n message: \"What objects can you identify in this photo?\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n
\n \n
\n \n );\n};\n\nexport default AgenticChatMultimodal;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# Agentic Chat Multimodal\n\nThis example demonstrates multimodal input support in AG-UI. Users can upload images and other media alongside text messages, and the agent analyzes them.\n\n## How it works\n\n- The `CopilotChat` component is configured with `attachments={{ enabled: true }}` to allow file uploads\n- Uploaded images are sent as `ImageInputContent` with base64-encoded data through the AG-UI protocol\n- The backend agent uses a vision-capable model to analyze the uploaded content\n- The AG-UI integration layer automatically converts between AG-UI's multimodal content types and the framework's native format\n\n## Try it\n\n1. Click the attachment icon in the chat input\n2. Upload an image\n3. Ask the agent to describe or analyze the image\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agentic_chat_multimodal.py", + "content": "\"\"\"\nA multimodal agentic chat flow that can analyze images and other media.\n\nImages the user attaches are converted to LiteLLM's ``image_url`` shape by the\nintegration layer before the run, so the flow only has to point a vision-capable\nmodel at the conversation.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\n\nclass AgenticChatMultimodalFlow(Flow[CopilotKitState]):\n\n @start()\n async def chat(self):\n system_prompt = (\n \"You are a helpful assistant that can analyze images, documents, and \"\n \"other media. When a user shares an image, describe what you see in \"\n \"detail. When a user shares a document, summarize its contents.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=\"openai/gpt-5.4\",\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n *self.state.messages,\n ],\n tools=[\n *self.state.copilotkit.actions,\n ],\n parallel_tool_calls=False,\n stream=True,\n )\n )\n\n self.state.messages.append(response.choices[0].message)\n", "language": "python", "type": "file" } @@ -3644,7 +3690,7 @@ }, { "name": "backend_tool_rendering.py", - "content": "\"\"\"Backend tool rendering.\n\nThis flow binds a real backend tool to a crewai ``Agent``: crewai runs\n``get_weather`` server-side, and the bridge surfaces the call + result so the\nclient renders a weather card without ever executing the tool. (The other tool\ndemos instead stream a frontend action for the client to run.)\n\nRequires the StreamFrame transport (crewai >= 1.6).\n\"\"\"\n\nimport asyncio\nimport json\n\nfrom crewai import Agent, Crew, Process, Task\nfrom crewai.flow.flow import Flow, start\nfrom crewai.tools import tool\n\nfrom ..sdk import CopilotKitState, copilotkit_exit\n\n\n@tool(\"get_weather\")\ndef get_weather(location: str) -> str:\n \"\"\"Get the current weather for a given location.\"\"\"\n # Return a JSON string, not a dict: crewai stringifies a tool's return\n # (str(result)) before it reaches the bridge, so a dict would arrive as a\n # single-quoted Python repr the client's JSON.parse rejects.\n return json.dumps(\n {\n \"temperature\": 20,\n \"conditions\": \"sunny\",\n \"humidity\": 50,\n \"wind_speed\": 10,\n \"feelsLike\": 25,\n }\n )\n\n\ndef _latest_user_message(messages) -> str:\n \"\"\"Return the text of the most recent user message, or ``\"\"``.\n\n Messages in flow state can be plain dicts (wire shape) or objects, so read\n ``role`` / ``content`` defensively.\n \"\"\"\n for message in reversed(messages or []):\n if isinstance(message, dict):\n role = message.get(\"role\")\n content = message.get(\"content\")\n else:\n role = getattr(message, \"role\", None)\n content = getattr(message, \"content\", None)\n if role == \"user\":\n return content or \"\"\n return \"\"\n\n\nclass BackendToolRenderingFlow(Flow[CopilotKitState]):\n \"\"\"A weather agent whose ``get_weather`` tool executes on the server.\"\"\"\n\n @start()\n async def chat(self):\n user_message = _latest_user_message(self.state.messages)\n\n agent = Agent(\n role=\"Weather Assistant\",\n goal=\"Answer the user's weather questions using the get_weather tool.\",\n backstory=(\n \"You are a helpful weather assistant. Always call the \"\n \"get_weather tool to look up the weather before you answer.\"\n ),\n tools=[get_weather],\n llm=\"openai/gpt-4o\",\n verbose=False,\n )\n task = Task(\n description=(\n \"Answer the user's request about the weather. \"\n f\"User request: {user_message}\"\n ),\n expected_output=\"A short, friendly summary of the weather.\",\n agent=agent,\n )\n crew = Crew(\n agents=[agent],\n tasks=[task],\n process=Process.sequential,\n verbose=False,\n )\n\n # Run the synchronous crew off the event loop so SSE keeps flushing and\n # cancellation/teardown can fire during the run. to_thread copies the\n # scoped sink + flow_context, so the crew's tool events still stream.\n result = await asyncio.to_thread(crew.kickoff)\n\n # The crew's final text; the tool card renders from the streamed events.\n self.state.messages.append(\n {\n \"role\": \"assistant\",\n \"content\": getattr(result, \"raw\", None) or str(result),\n }\n )\n\n await copilotkit_exit()\n", + "content": "\"\"\"Backend tool rendering.\n\nThis flow binds a real backend tool to a crewai ``Agent``: crewai runs\n``get_weather`` server-side, and the bridge surfaces the call + result so the\nclient renders a weather card without ever executing the tool. (The other tool\ndemos instead stream a frontend action for the client to run.)\n\nRequires the StreamFrame transport (crewai >= 1.6).\n\"\"\"\n\nimport asyncio\nimport json\n\nfrom crewai import Agent, Crew, Process, Task\nfrom crewai.flow.flow import Flow, start\nfrom crewai.tools import tool\n\nfrom ..sdk import CopilotKitState, copilotkit_exit\n\n\n@tool(\"get_weather\")\ndef get_weather(location: str) -> str:\n \"\"\"Get the current weather for a given location.\"\"\"\n # Return a JSON string, not a dict: crewai stringifies a tool's return\n # (str(result)) before it reaches the bridge, so a dict would arrive as a\n # single-quoted Python repr the client's JSON.parse rejects.\n return json.dumps(\n {\n \"temperature\": 20,\n \"conditions\": \"sunny\",\n \"humidity\": 50,\n \"wind_speed\": 10,\n \"feelsLike\": 25,\n }\n )\n\n\ndef _latest_user_message(messages) -> str:\n \"\"\"Return the text of the most recent user message, or ``\"\"``.\n\n Messages in flow state can be plain dicts (wire shape) or objects, so read\n ``role`` / ``content`` defensively.\n \"\"\"\n for message in reversed(messages or []):\n if isinstance(message, dict):\n role = message.get(\"role\")\n content = message.get(\"content\")\n else:\n role = getattr(message, \"role\", None)\n content = getattr(message, \"content\", None)\n if role == \"user\":\n return content or \"\"\n return \"\"\n\n\nclass BackendToolRenderingFlow(Flow[CopilotKitState]):\n \"\"\"A weather agent whose ``get_weather`` tool executes on the server.\"\"\"\n\n @start()\n async def chat(self):\n user_message = _latest_user_message(self.state.messages)\n\n agent = Agent(\n role=\"Weather Assistant\",\n goal=\"Answer the user's weather questions using the get_weather tool.\",\n backstory=(\n \"You are a helpful weather assistant. Always call the \"\n \"get_weather tool to look up the weather before you answer.\"\n ),\n tools=[get_weather],\n llm=\"openai/gpt-5.4\",\n verbose=False,\n )\n task = Task(\n description=(\n \"Answer the user's request about the weather. \"\n f\"User request: {user_message}\"\n ),\n expected_output=\"A short, friendly summary of the weather.\",\n agent=agent,\n )\n crew = Crew(\n agents=[agent],\n tasks=[task],\n process=Process.sequential,\n verbose=False,\n )\n\n # Run the synchronous crew off the event loop so SSE keeps flushing and\n # cancellation/teardown can fire during the run. to_thread copies the\n # scoped sink + flow_context, so the crew's tool events still stream.\n result = await asyncio.to_thread(crew.kickoff)\n\n # The crew's final text; the tool card renders from the streamed events.\n self.state.messages.append(\n {\n \"role\": \"assistant\",\n \"content\": getattr(result, \"raw\", None) or str(result),\n }\n )\n\n await copilotkit_exit()\n", "language": "python", "type": "file" } @@ -3678,7 +3724,7 @@ }, { "name": "human_in_the_loop.py", - "content": "\"\"\"\nAn example demonstrating human-in-the-loop.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom litellm import acompletion\nfrom pydantic import BaseModel\nfrom typing import Literal, List\nfrom ..sdk import (\n copilotkit_stream,\n CopilotKitState,\n)\n\n# This tool simulates performing a task on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nDEFINE_TASK_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_task_steps\",\n \"description\": \"Make up 10 steps (only a couple of words per step) that are required for a task. The step should be in imperative form (i.e. Dig hole, Open door, ...)\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"steps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"The text of the step in imperative form\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"enabled\"],\n \"description\": \"The status of the step, always 'enabled'\"\n }\n },\n \"required\": [\"description\", \"status\"]\n },\n \"description\": \"An array of 10 step objects, each containing text and status\"\n }\n },\n \"required\": [\"steps\"]\n }\n }\n}\n\nclass TaskStep(BaseModel):\n description: str\n status: Literal[\"enabled\", \"disabled\"]\n\nclass AgentState(CopilotKitState):\n \"\"\"\n Here we define the state of the agent\n\n In this instance, we're inheriting from CopilotKitState, which will bring in\n the CopilotKitState fields. We're also adding a custom field, `steps`,\n which will be used to store the steps of the task.\n \"\"\"\n steps: List[TaskStep] = []\n\n\nclass HumanInTheLoopFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates a human-in-the-loop agent.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = \"\"\"\n You are a helpful assistant that can perform any task.\n You MUST call the `generate_task_steps` function when the user asks you to perform a task.\n When the function `generate_task_steps` is called, the user will decide to enable or disable a step.\n After the user has decided which steps to perform, provide a textual description of how you are performing the task.\n If the user has disabled a step, you are not allowed to perform that step.\n However, you should find a creative workaround to perform the task, and if an essential step is disabled, you can even use\n some humor in the description of how you are performing the task.\n Don't just repeat a list of steps, come up with a creative but short description (3 sentences max) of how you are performing the task.\n \"\"\"\n\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-4o\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n DEFINE_TASK_TOOL\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "content": "\"\"\"\nAn example demonstrating human-in-the-loop.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom litellm import acompletion\nfrom pydantic import BaseModel\nfrom typing import Literal, List\nfrom ..sdk import (\n copilotkit_stream,\n CopilotKitState,\n)\n\n# This tool simulates performing a task on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nDEFINE_TASK_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_task_steps\",\n \"description\": \"Make up the number of task steps requested by the user (only a couple of words per step). If the user does not request a count, make a concise plan. Each step should be in imperative form (i.e. Dig hole, Open door, ...)\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"steps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"The text of the step in imperative form\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"enabled\"],\n \"description\": \"The status of the step, always 'enabled'\"\n }\n },\n \"required\": [\"description\", \"status\"]\n },\n \"description\": \"An array containing the requested number of step objects, each with text and status\"\n }\n },\n \"required\": [\"steps\"]\n }\n }\n}\n\nHITL_SYSTEM_PROMPT = \"\"\"\nYou are a helpful assistant that can perform any task.\nCRITICAL: You MUST call the `generate_task_steps` function when the user asks you to perform a task.\nCRITICAL: Generate exactly the step count requested by the user. If no count is requested, generate a concise plan.\nWhen the function `generate_task_steps` is called, the user will decide to enable or disable a step and either accept or reject the plan.\nCRITICAL: If the tool result has `accepted: false`, the plan was rejected. Do not perform the rejected plan. Wait for revision instructions from the user.\nCRITICAL: After a rejection, interpret a terse numeric reply such as `5.` as a revised requested step count, then call `generate_task_steps` again with exactly that many steps.\nIf the tool result has `accepted: true`, provide a textual description of how you are performing only the accepted, enabled steps.\nIf the user has disabled a step, you are not allowed to perform that step.\nHowever, you should find a creative workaround to perform the task, and if an essential step is disabled, you can even use\nsome humor in the description of how you are performing the task.\nDon't just repeat a list of steps, come up with a creative but short description (3 sentences max) of how you are performing the task.\n\"\"\"\n\nclass TaskStep(BaseModel):\n description: str\n status: Literal[\"enabled\", \"disabled\"]\n\nclass AgentState(CopilotKitState):\n \"\"\"\n Here we define the state of the agent\n\n In this instance, we're inheriting from CopilotKitState, which will bring in\n the CopilotKitState fields. We're also adding a custom field, `steps`,\n which will be used to store the steps of the task.\n \"\"\"\n steps: List[TaskStep] = []\n\n\nclass HumanInTheLoopFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates a human-in-the-loop agent.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": HITL_SYSTEM_PROMPT\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n DEFINE_TASK_TOOL\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", "language": "python", "type": "file" } @@ -3704,7 +3750,7 @@ }, { "name": "agentic_generative_ui.py", - "content": "\"\"\"\nAn example demonstrating agentic generative UI.\n\"\"\"\n\nimport json\nimport asyncio\nfrom crewai.flow.flow import Flow, start, router, listen, or_\nfrom litellm import acompletion\nfrom pydantic import BaseModel\nfrom typing import Literal, List\n\nfrom ..sdk import (\n copilotkit_stream,\n CopilotKitState,\n copilotkit_predict_state,\n copilotkit_emit_state\n)\n\n# This tool simulates performing a task on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nPERFORM_TASK_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_task_steps\",\n \"description\": \"Make up 10 steps (only a couple of words per step) that are required for a task. The step should be in gerund form (i.e. Digging hole, opening door, ...)\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"steps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"The text of the step in gerund form\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"pending\"],\n \"description\": \"The status of the step, always 'pending'\"\n }\n },\n \"required\": [\"description\", \"status\"]\n },\n \"description\": \"An array of 10 step objects, each containing text and status\"\n }\n },\n \"required\": [\"steps\"]\n }\n }\n}\n\nclass TaskStep(BaseModel):\n description: str\n status: Literal[\"pending\", \"completed\"]\n\nclass AgentState(CopilotKitState):\n \"\"\"\n Here we define the state of the agent\n\n In this instance, we're inheriting from CopilotKitState, which will bring in\n the CopilotKitState fields. We're also adding a custom field, `steps`,\n which will be used to store the steps of the task.\n \"\"\"\n steps: List[TaskStep] = []\n\n\nclass AgenticGenerativeUIFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that uses the CopilotKit framework to create a chat agent.\n \"\"\"\n\n \n @start()\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n self.state.steps = []\n\n @router(or_(start_flow, \"simulate_task\"))\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = \"\"\"\n You are a helpful assistant assisting with any task. \n When asked to do something, you MUST call the function `generate_task_steps`\n that was provided to you.\n If you called the function, you MUST NOT repeat the steps in your next response to the user.\n Just give a very brief summary (one sentence) of what you did with some emojis. \n Always say you actually did the steps, not merely generated them.\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to generate_task_steps\n # to the frontend as state.\n await copilotkit_predict_state({\n \"steps\": {\n \"tool_name\": \"generate_task_steps\",\n \"tool_argument\": \"steps\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-4o\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n PERFORM_TASK_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"generate_task_steps\":\n # Convert each step in the JSON array to a TaskStep instance\n self.state.steps = [TaskStep(**step) for step in tool_call_args[\"steps\"]]\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Steps executed.\",\n \"tool_call_id\": tool_call_id\n })\n return \"route_simulate_task\"\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_simulate_task\")\n async def simulate_task(self):\n \"\"\"\n Simulate the task.\n \"\"\"\n for step in self.state.steps:\n # simulate executing the step\n await asyncio.sleep(1)\n step.status = \"completed\"\n await copilotkit_emit_state(self.state)\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "content": "\"\"\"\nAn example demonstrating agentic generative UI.\n\"\"\"\n\nimport json\nimport asyncio\nfrom crewai.flow.flow import Flow, start, router, listen, or_\nfrom litellm import acompletion\nfrom pydantic import BaseModel\nfrom typing import Literal, List\n\nfrom ..sdk import (\n copilotkit_stream,\n CopilotKitState,\n copilotkit_predict_state,\n copilotkit_emit_state\n)\n\n# This tool simulates performing a task on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nPERFORM_TASK_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_task_steps\",\n \"description\": \"Make up 10 steps (only a couple of words per step) that are required for a task. The step should be in gerund form (i.e. Digging hole, opening door, ...)\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"steps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"The text of the step in gerund form\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"pending\"],\n \"description\": \"The status of the step, always 'pending'\"\n }\n },\n \"required\": [\"description\", \"status\"]\n },\n \"description\": \"An array of 10 step objects, each containing text and status\"\n }\n },\n \"required\": [\"steps\"]\n }\n }\n}\n\nclass TaskStep(BaseModel):\n description: str\n status: Literal[\"pending\", \"completed\"]\n\nclass AgentState(CopilotKitState):\n \"\"\"\n Here we define the state of the agent\n\n In this instance, we're inheriting from CopilotKitState, which will bring in\n the CopilotKitState fields. We're also adding a custom field, `steps`,\n which will be used to store the steps of the task.\n \"\"\"\n steps: List[TaskStep] = []\n\n\nclass AgenticGenerativeUIFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that uses the CopilotKit framework to create a chat agent.\n \"\"\"\n\n \n @start()\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n self.state.steps = []\n\n @router(or_(start_flow, \"simulate_task\"))\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = \"\"\"\n You are a helpful assistant assisting with any task. \n When asked to do something, you MUST call the function `generate_task_steps`\n that was provided to you.\n If you called the function, you MUST NOT repeat the steps in your next response to the user.\n Just give a very brief summary (one sentence) of what you did with some emojis. \n Always say you actually did the steps, not merely generated them.\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to generate_task_steps\n # to the frontend as state.\n await copilotkit_predict_state({\n \"steps\": {\n \"tool_name\": \"generate_task_steps\",\n \"tool_argument\": \"steps\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n PERFORM_TASK_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"generate_task_steps\":\n # Convert each step in the JSON array to a TaskStep instance\n self.state.steps = [TaskStep(**step) for step in tool_call_args[\"steps\"]]\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Steps executed.\",\n \"tool_call_id\": tool_call_id\n })\n return \"route_simulate_task\"\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_simulate_task\")\n async def simulate_task(self):\n \"\"\"\n Simulate the task.\n \"\"\"\n for step in self.state.steps:\n # simulate executing the step\n await asyncio.sleep(1)\n step.status = \"completed\"\n await copilotkit_emit_state(self.state)\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", "language": "python", "type": "file" } @@ -3730,7 +3776,7 @@ }, { "name": "predictive_state_updates.py", - "content": "\"\"\"\nA demo of predictive state updates.\n\"\"\"\n\nimport json\nimport uuid\nfrom typing import Optional\nfrom litellm import acompletion\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom ..sdk import (\n copilotkit_stream, \n copilotkit_predict_state,\n CopilotKitState\n)\n\nWRITE_DOCUMENT_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"write_document_local\",\n \"description\": \" \".join(\"\"\"\n Write a document. Use markdown formatting to format the document.\n It's good to format the document extensively so it's easy to read.\n You can use all kinds of markdown.\n However, do not use italic or strike-through formatting, it's reserved for another purpose.\n You MUST write the full document, even when changing only a few words.\n When making edits to the document, try to make them minimal - do not change every word.\n Keep stories SHORT!\n \"\"\".split()),\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"document\": {\n \"type\": \"string\",\n \"description\": \"The document to write\"\n },\n },\n }\n }\n}\n\n\nclass AgentState(CopilotKitState):\n \"\"\"\n The state of the agent.\n \"\"\"\n document: Optional[str] = None\n\nclass PredictiveStateUpdatesFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates predictive state updates.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = f\"\"\"\n You are a helpful assistant for writing documents.\n To write the document, you MUST use the write_document_local tool.\n You MUST write the full document, even when changing only a few words.\n When you wrote the document, DO NOT repeat it as a message. \n Just briefly summarize the changes you made. 2 sentences max.\n This is the current state of the document: ----\\n {self.state.document}\\n-----\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to write_document_local\n # to the frontend as state.\n await copilotkit_predict_state({\n \"document\": {\n \"tool_name\": \"write_document_local\",\n \"tool_argument\": \"document\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-4o\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n WRITE_DOCUMENT_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"write_document_local\":\n self.state.document = tool_call_args[\"document\"]\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Document written.\",\n \"tool_call_id\": tool_call_id\n })\n\n # 4.2 Append a tool call to confirm changes\n self.state.messages.append({\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [{\n \"id\": str(uuid.uuid4()),\n \"function\": {\n \"name\": \"confirm_changes\",\n \"arguments\": \"{}\"\n }\n }]\n })\n\n return \"route_end\"\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "content": "\"\"\"\nA demo of predictive state updates.\n\"\"\"\n\nimport json\nimport uuid\nfrom typing import Optional\nfrom litellm import acompletion\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom ..sdk import (\n copilotkit_stream, \n copilotkit_predict_state,\n CopilotKitState\n)\n\nWRITE_DOCUMENT_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"write_document_local\",\n \"description\": \" \".join(\"\"\"\n Write a document. Use markdown formatting to format the document.\n It's good to format the document extensively so it's easy to read.\n You can use all kinds of markdown.\n However, do not use italic or strike-through formatting, it's reserved for another purpose.\n You MUST write the full document, even when changing only a few words.\n When making edits to the document, try to make them minimal - do not change every word.\n Keep stories SHORT!\n \"\"\".split()),\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"document\": {\n \"type\": \"string\",\n \"description\": \"The document to write\"\n },\n },\n }\n }\n}\n\n\nclass AgentState(CopilotKitState):\n \"\"\"\n The state of the agent.\n \"\"\"\n document: Optional[str] = None\n\nclass PredictiveStateUpdatesFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates predictive state updates.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = f\"\"\"\n You are a helpful assistant for writing documents.\n To write the document, you MUST use the write_document_local tool.\n You MUST write the full document, even when changing only a few words.\n When you wrote the document, DO NOT repeat it as a message. \n Just briefly summarize the changes you made. 2 sentences max.\n This is the current state of the document: ----\\n {self.state.document}\\n-----\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to write_document_local\n # to the frontend as state.\n await copilotkit_predict_state({\n \"document\": {\n \"tool_name\": \"write_document_local\",\n \"tool_argument\": \"document\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n WRITE_DOCUMENT_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"write_document_local\":\n self.state.document = tool_call_args[\"document\"]\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Document written.\",\n \"tool_call_id\": tool_call_id\n })\n\n # 4.2 Append a tool call to confirm changes\n self.state.messages.append({\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [{\n \"id\": str(uuid.uuid4()),\n \"function\": {\n \"name\": \"confirm_changes\",\n \"arguments\": \"{}\"\n }\n }]\n })\n\n return \"route_end\"\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", "language": "python", "type": "file" } @@ -3756,7 +3802,7 @@ }, { "name": "shared_state.py", - "content": "\"\"\"\nA demo of shared state between the agent and CopilotKit.\n\"\"\"\n\nimport json\nfrom enum import Enum\nfrom typing import List, Optional\nfrom litellm import acompletion\nfrom pydantic import BaseModel, Field\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom ..sdk import (\n copilotkit_stream, \n copilotkit_predict_state,\n CopilotKitState\n)\n\nclass SkillLevel(str, Enum):\n \"\"\"\n The level of skill required for the recipe.\n \"\"\"\n BEGINNER = \"Beginner\"\n INTERMEDIATE = \"Intermediate\"\n ADVANCED = \"Advanced\"\n\nclass CookingTime(str, Enum):\n \"\"\"\n The cooking time of the recipe.\n \"\"\"\n FIVE_MIN = \"5 min\"\n FIFTEEN_MIN = \"15 min\"\n THIRTY_MIN = \"30 min\"\n FORTY_FIVE_MIN = \"45 min\"\n SIXTY_PLUS_MIN = \"60+ min\"\n\nclass Ingredient(BaseModel):\n \"\"\"\n An ingredient with its details.\n \"\"\"\n icon: str = Field(..., description=\"Emoji icon representing the ingredient.\")\n name: str = Field(..., description=\"Name of the ingredient.\")\n amount: str = Field(..., description=\"Amount or quantity of the ingredient.\")\n\nGENERATE_RECIPE_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_recipe\",\n \"description\": \" \".join(\"\"\"Generate or modify an existing recipe. \n When creating a new recipe, specify all fields. \n When modifying, only fill optional fields if they need changes; \n otherwise, leave them empty.\"\"\".split()),\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"recipe\": {\n \"description\": \"The recipe object containing all details.\",\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"The title of the recipe.\"\n },\n \"skill_level\": {\n \"type\": \"string\",\n \"enum\": [level.value for level in SkillLevel],\n \"description\": \"The skill level required for the recipe.\"\n },\n \"special_preferences\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"A list of dietary preferences (e.g., Vegetarian, Gluten-free).\"\n },\n \"cooking_time\": {\n \"type\": \"string\",\n \"enum\": [time.value for time in CookingTime],\n \"description\": \"The estimated cooking time for the recipe.\"\n },\n \"ingredients\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"icon\": {\"type\": \"string\", \"description\": \"Emoji icon for the ingredient.\"},\n \"name\": {\"type\": \"string\", \"description\": \"Name of the ingredient.\"},\n \"amount\": {\"type\": \"string\", \"description\": \"Amount/quantity of the ingredient.\"}\n },\n \"required\": [\"icon\", \"name\", \"amount\"]\n },\n \"description\": \"A list of ingredients required for the recipe.\"\n },\n \"instructions\": {\n \"type\": \"array\",\n \"items\": {\"type\": \"string\"},\n \"description\": \"Step-by-step instructions for preparing the recipe.\"\n }\n },\n \"required\": [\"title\", \"skill_level\", \"cooking_time\", \"special_preferences\", \"ingredients\", \"instructions\"]\n }\n },\n \"required\": [\"recipe\"]\n }\n }\n}\n\nclass Recipe(BaseModel):\n \"\"\"\n A recipe.\n \"\"\"\n title: str\n skill_level: SkillLevel\n special_preferences: List[str] = Field(default_factory=list)\n cooking_time: CookingTime\n ingredients: List[Ingredient] = Field(default_factory=list)\n instructions: List[str] = Field(default_factory=list)\n\n\nclass AgentState(CopilotKitState):\n \"\"\"\n The state of the recipe.\n \"\"\"\n recipe: Optional[Recipe] = None\n\nclass SharedStateFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates shared state between the agent and CopilotKit.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n \n system_prompt = f\"\"\"You are a helpful assistant for creating recipes. \n This is the current state of the recipe: {self.state.model_dump_json(indent=2)}\n You can modify the recipe by calling the generate_recipe tool.\n If you have just created or modified the recipe, just answer in one sentence what you did.\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to generate_recipe\n # to the frontend as state.\n await copilotkit_predict_state({\n \"recipe\": {\n \"tool_name\": \"generate_recipe\",\n \"tool_argument\": \"recipe\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-4o\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n GENERATE_RECIPE_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"generate_recipe\":\n # Attempt to update the recipe state using the data from the tool call\n try:\n updated_recipe_data = tool_call_args[\"recipe\"]\n # Validate and update the state. Pydantic will raise an error if the structure is wrong.\n self.state.recipe = Recipe(**updated_recipe_data)\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Recipe updated.\", # More accurate message\n \"tool_call_id\": tool_call_id\n })\n return \"route_follow_up\"\n except Exception: # pylint: disable=broad-exception-caught\n # Handle validation or other errors during update\n # Optionally inform the user via a tool message, though it might be noisy\n # self.state.messages.append({\"role\": \"tool\", \"content\": f\"Error processing recipe update: {e}\", \"tool_call_id\": tool_call_id})\n return \"route_end\" # End the flow on error for now\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "content": "\"\"\"\nA demo of shared state between the agent and CopilotKit.\n\"\"\"\n\nimport json\nfrom enum import Enum\nfrom typing import List, Optional\nfrom litellm import acompletion\nfrom pydantic import BaseModel, Field\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom ..sdk import (\n copilotkit_stream, \n copilotkit_predict_state,\n CopilotKitState\n)\n\nclass SkillLevel(str, Enum):\n \"\"\"\n The level of skill required for the recipe.\n \"\"\"\n BEGINNER = \"Beginner\"\n INTERMEDIATE = \"Intermediate\"\n ADVANCED = \"Advanced\"\n\nclass CookingTime(str, Enum):\n \"\"\"\n The cooking time of the recipe.\n \"\"\"\n FIVE_MIN = \"5 min\"\n FIFTEEN_MIN = \"15 min\"\n THIRTY_MIN = \"30 min\"\n FORTY_FIVE_MIN = \"45 min\"\n SIXTY_PLUS_MIN = \"60+ min\"\n\nclass Ingredient(BaseModel):\n \"\"\"\n An ingredient with its details.\n \"\"\"\n icon: str = Field(..., description=\"Emoji icon representing the ingredient.\")\n name: str = Field(..., description=\"Name of the ingredient.\")\n amount: str = Field(..., description=\"Amount or quantity of the ingredient.\")\n\nGENERATE_RECIPE_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_recipe\",\n \"description\": \" \".join(\"\"\"Generate or modify an existing recipe. \n When creating a new recipe, specify all fields. \n When modifying, only fill optional fields if they need changes; \n otherwise, leave them empty.\"\"\".split()),\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"recipe\": {\n \"description\": \"The recipe object containing all details.\",\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"The title of the recipe.\"\n },\n \"skill_level\": {\n \"type\": \"string\",\n \"enum\": [level.value for level in SkillLevel],\n \"description\": \"The skill level required for the recipe.\"\n },\n \"special_preferences\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"A list of dietary preferences (e.g., Vegetarian, Gluten-free).\"\n },\n \"cooking_time\": {\n \"type\": \"string\",\n \"enum\": [time.value for time in CookingTime],\n \"description\": \"The estimated cooking time for the recipe.\"\n },\n \"ingredients\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"icon\": {\"type\": \"string\", \"description\": \"Emoji icon for the ingredient.\"},\n \"name\": {\"type\": \"string\", \"description\": \"Name of the ingredient.\"},\n \"amount\": {\"type\": \"string\", \"description\": \"Amount/quantity of the ingredient.\"}\n },\n \"required\": [\"icon\", \"name\", \"amount\"]\n },\n \"description\": \"A list of ingredients required for the recipe.\"\n },\n \"instructions\": {\n \"type\": \"array\",\n \"items\": {\"type\": \"string\"},\n \"description\": \"Step-by-step instructions for preparing the recipe.\"\n }\n },\n \"required\": [\"title\", \"skill_level\", \"cooking_time\", \"special_preferences\", \"ingredients\", \"instructions\"]\n }\n },\n \"required\": [\"recipe\"]\n }\n }\n}\n\nclass Recipe(BaseModel):\n \"\"\"\n A recipe.\n \"\"\"\n title: str\n skill_level: SkillLevel\n special_preferences: List[str] = Field(default_factory=list)\n cooking_time: CookingTime\n ingredients: List[Ingredient] = Field(default_factory=list)\n instructions: List[str] = Field(default_factory=list)\n\n\nclass AgentState(CopilotKitState):\n \"\"\"\n The state of the recipe.\n \"\"\"\n recipe: Optional[Recipe] = None\n\nclass SharedStateFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates shared state between the agent and CopilotKit.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n \n recipe_json = (\n self.state.recipe.model_dump_json(indent=2)\n if self.state.recipe is not None\n else \"{}\"\n )\n system_prompt = f\"\"\"You are a helpful assistant for creating recipes.\n This is the current state of the recipe: {recipe_json}\n You can improve the recipe by calling the generate_recipe tool.\n\n IMPORTANT:\n 1. Create a recipe using the existing ingredients and instructions. Make sure the recipe is complete.\n 2. The recipe MUST comply with the selected dietary preferences (special_preferences). If an existing ingredient violates a selected preference (for example butter or Parmesan cheese when \"Vegan\" is selected), REPLACE it with a compliant alternative (e.g. olive oil, a plant-based butter, nutritional yeast) or remove it, and update the affected instructions to match.\n 3. Keep the selected special_preferences in the recipe you return, and keep every existing ingredient and instruction that already complies, appending any new ones.\n 4. 'ingredients' is always an array of objects with 'icon', 'name', and 'amount' fields\n 5. 'instructions' is always an array of strings\n 6. For the 'icon' field in ingredients, ALWAYS use actual Unicode emoji characters (like 🥕 🍅 🧅 🥖 🧈 🥛 🧂 etc.), NEVER use text, ANSI codes, or placeholders\n\n If you have just created or modified the recipe, just answer in one sentence what you did. dont describe the recipe, just say what you did.\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to generate_recipe\n # to the frontend as state.\n await copilotkit_predict_state({\n \"recipe\": {\n \"tool_name\": \"generate_recipe\",\n \"tool_argument\": \"recipe\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n GENERATE_RECIPE_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"generate_recipe\":\n # Attempt to update the recipe state using the data from the tool call\n try:\n updated_recipe_data = tool_call_args[\"recipe\"]\n # Validate and update the state. Pydantic will raise an error if the structure is wrong.\n self.state.recipe = Recipe(**updated_recipe_data)\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Recipe updated.\", # More accurate message\n \"tool_call_id\": tool_call_id\n })\n return \"route_follow_up\"\n except Exception: # pylint: disable=broad-exception-caught\n # Handle validation or other errors during update\n # Optionally inform the user via a tool message, though it might be noisy\n # self.state.messages.append({\"role\": \"tool\", \"content\": f\"Error processing recipe update: {e}\", \"tool_call_id\": tool_call_id})\n return \"route_end\" # End the flow on error for now\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", "language": "python", "type": "file" } @@ -3782,7 +3828,111 @@ }, { "name": "tool_based_generative_ui.py", - "content": "\"\"\"\nAn example demonstrating tool-based generative UI.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\n\n# This tool generates a haiku on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nGENERATE_HAIKU_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_haiku\",\n \"description\": \"Generate a haiku in Japanese and its English translation\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"japanese\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"An array of three lines of the haiku in Japanese\"\n },\n \"english\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"An array of three lines of the haiku in English\"\n },\n \"image_names\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Names of 3 relevant images from the provided list\"\n }\n },\n \"required\": [\"japanese\", \"english\", \"image_names\"]\n }\n }\n}\n\n\nclass ToolBasedGenerativeUIFlow(Flow[CopilotKitState]):\n \"\"\"\n A flow that demonstrates tool-based generative UI.\n \"\"\"\n\n @start()\n async def chat(self):\n \"\"\"\n The main function handling chat and tool calls.\n \"\"\"\n system_prompt = \"You assist the user in generating a haiku. When generating a haiku using the 'generate_haiku' tool, you MUST also select exactly 3 image filenames from the following list that are most relevant to the haiku's content or theme. Return the filenames in the 'image_names' parameter. Dont provide the relavent image names in your final response to the user. \"\n\n\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-4o\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the available tools to the model\n tools=[ GENERATE_HAIKU_TOOL ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 3. If there are tool calls, append a tool message to the messages in state\n if message.tool_calls:\n self.state.messages.append(\n {\n \"tool_call_id\": message.tool_calls[0].id,\n \"role\": \"tool\",\n \"content\": \"Haiku generated.\"\n }\n )\n", + "content": "\"\"\"\nAn example demonstrating tool-based generative UI.\n\nThe ``generate_haiku`` tool is defined on the FRONTEND (via ``useFrontendTool``):\nits handler renders the haiku onto the main canvas and picks the background\nimage and gradient. So the flow binds the frontend actions and lets the model\ncall that tool, rather than defining a backend tool of the same name (which would\nrender the chat card but never run the frontend handler that updates the canvas).\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\n\nclass ToolBasedGenerativeUIFlow(Flow[CopilotKitState]):\n \"\"\"\n A flow that demonstrates tool-based generative UI.\n \"\"\"\n\n @start()\n async def chat(self):\n system_prompt = (\n \"Help the user write haikus. When the user asks for a haiku, call the \"\n \"generate_haiku tool to display it. Choose a fitting background image \"\n \"and gradient for the haiku's theme.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=\"openai/gpt-5.4\",\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n *self.state.messages,\n ],\n # Bind the frontend-provided tools (generate_haiku lives on the\n # frontend, so its handler updates the canvas when called).\n tools=[\n *self.state.copilotkit.actions,\n ],\n parallel_tool_calls=False,\n stream=True,\n )\n )\n\n self.state.messages.append(response.choices[0].message)\n", + "language": "python", + "type": "file" + } + ], + "crewai::subgraphs": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => {\n // CrewAI suspends the whole flow, so the specialist's payload arrives under\n // metadata.crewai.output; LangGraph puts the raw value on event.value directly.\n const raw = (event.value ?? {}) as any;\n const value = raw?.metadata?.crewai?.output ?? raw;\n return ;\n },\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "/* Travel Planning Subgraphs Demo Styles */\n/* Essential styles that cannot be achieved with Tailwind classes */\n\n/* Main container with CopilotSidebar layout */\n.travel-planner-container {\n min-height: 100vh;\n padding: 2rem;\n background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);\n}\n\n/* Travel content area styles */\n.travel-content {\n max-width: 1200px;\n margin: 0 auto;\n padding: 0 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n/* Itinerary strip */\n.itinerary-strip {\n background: white;\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid #e5e7eb;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n}\n\n.itinerary-label {\n font-size: 0.875rem;\n font-weight: 600;\n color: #6b7280;\n margin-bottom: 0.5rem;\n}\n\n.itinerary-items {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n}\n\n.itinerary-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n background: #f9fafb;\n border-radius: 0.375rem;\n font-size: 0.875rem;\n}\n\n.item-icon {\n font-size: 1rem;\n}\n\n/* Agent status */\n.agent-status {\n background: white;\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid #e5e7eb;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n}\n\n.status-label {\n font-size: 0.875rem;\n font-weight: 600;\n color: #6b7280;\n margin-bottom: 0.5rem;\n}\n\n.agent-indicators {\n display: flex;\n gap: 0.75rem;\n}\n\n.agent-indicator {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-radius: 0.375rem;\n font-size: 0.875rem;\n background: #f9fafb;\n border: 1px solid #e5e7eb;\n transition: all 0.2s ease;\n}\n\n.agent-indicator.active {\n background: #dbeafe;\n border-color: #3b82f6;\n color: #1d4ed8;\n box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);\n}\n\n/* Travel details sections */\n.travel-details {\n background: white;\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid #e5e7eb;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n display: grid;\n gap: 1rem;\n}\n\n.details-section h4 {\n font-size: 1rem;\n font-weight: 600;\n color: #1f2937;\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.detail-items {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.detail-item {\n padding: 0.5rem;\n background: #f9fafb;\n border-radius: 0.25rem;\n font-size: 0.875rem;\n display: flex;\n justify-content: space-between;\n}\n\n.detail-item strong {\n color: #6b7280;\n font-weight: 500;\n}\n\n.detail-tips {\n padding: 0.5rem;\n background: #eff6ff;\n border-radius: 0.25rem;\n font-size: 0.75rem;\n color: #1d4ed8;\n}\n\n.activity-item {\n padding: 0.75rem;\n background: #f0f9ff;\n border-radius: 0.25rem;\n border-left: 2px solid #0ea5e9;\n}\n\n.activity-name {\n font-weight: 600;\n color: #1f2937;\n font-size: 0.875rem;\n margin-bottom: 0.25rem;\n}\n\n.activity-category {\n font-size: 0.75rem;\n color: #0ea5e9;\n margin-bottom: 0.25rem;\n}\n\n.activity-description {\n color: #4b5563;\n font-size: 0.75rem;\n margin-bottom: 0.25rem;\n}\n\n.activity-meta {\n font-size: 0.75rem;\n color: #6b7280;\n}\n\n.no-activities {\n text-align: center;\n color: #9ca3af;\n font-style: italic;\n padding: 1rem;\n font-size: 0.875rem;\n}\n\n/* Interrupt UI for Chat Sidebar (Generative UI) */\n.interrupt-container {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n max-width: 100%;\n padding-top: 34px;\n}\n\n.interrupt-header {\n margin-bottom: 0.5rem;\n}\n\n.agent-name {\n font-size: 0.875rem;\n font-weight: 600;\n color: #1f2937;\n margin: 0 0 0.25rem 0;\n}\n\n.agent-message {\n font-size: 0.75rem;\n color: #6b7280;\n margin: 0;\n line-height: 1.4;\n}\n\n.interrupt-options {\n padding: 0.75rem;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n max-height: 300px;\n overflow-y: auto;\n}\n\n.option-card {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.75rem;\n background: #f9fafb;\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n cursor: pointer;\n transition: all 0.2s ease;\n text-align: left;\n position: relative;\n min-height: auto;\n}\n\n.option-card:hover {\n background: #f3f4f6;\n border-color: #d1d5db;\n}\n\n.option-card:active {\n background: #e5e7eb;\n}\n\n.option-card.recommended {\n background: #eff6ff;\n border-color: #3b82f6;\n box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.1);\n}\n\n.option-card.recommended:hover {\n background: #dbeafe;\n}\n\n.recommendation-badge {\n position: absolute;\n top: -2px;\n right: -2px;\n background: #3b82f6;\n color: white;\n font-size: 0.625rem;\n padding: 0.125rem 0.375rem;\n border-radius: 0.75rem;\n font-weight: 500;\n}\n\n.option-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 0.125rem;\n}\n\n.airline-name, .hotel-name {\n font-weight: 600;\n font-size: 0.8rem;\n color: #1f2937;\n}\n\n.price, .rating {\n font-weight: 600;\n font-size: 0.75rem;\n color: #059669;\n}\n\n.route-info, .location-info {\n font-size: 0.7rem;\n color: #6b7280;\n margin-bottom: 0.125rem;\n}\n\n.duration-info, .price-info {\n font-size: 0.7rem;\n color: #9ca3af;\n}\n\n/* Mobile responsive adjustments */\n@media (max-width: 768px) {\n .travel-planner-container {\n padding: 0.5rem;\n padding-bottom: 120px; /* Space for mobile chat */\n }\n \n .travel-content {\n padding: 0;\n gap: 0.75rem;\n }\n \n .itinerary-items {\n flex-direction: column;\n gap: 0.5rem;\n }\n \n .agent-indicators {\n flex-direction: column;\n gap: 0.5rem;\n }\n \n .agent-indicator {\n padding: 0.75rem;\n }\n \n .travel-details {\n padding: 0.75rem;\n }\n\n .interrupt-container {\n padding: 0.5rem;\n }\n\n .option-card {\n padding: 0.625rem;\n }\n\n .interrupt-options {\n max-height: 250px;\n }\n}", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# LangGraph Subgraphs Demo: Travel Planning Assistant ✈️\n\nThis demo showcases **LangGraph subgraphs** through an interactive travel planning assistant. Watch as specialized AI agents collaborate to plan your perfect trip!\n\n## What are LangGraph Subgraphs? 🤖\n\n**Subgraphs** are the key to building modular, scalable AI systems in LangGraph. A subgraph is essentially \"a graph that is used as a node in another graph\" - enabling powerful encapsulation and reusability.\nFor more info, check out the [LangGraph docs](https://langchain-ai.github.io/langgraph/concepts/subgraphs/).\n\n### Key Concepts\n\n- **Encapsulation**: Each subgraph handles a specific domain with its own expertise\n- **Modularity**: Subgraphs can be developed, tested, and maintained independently \n- **Reusability**: The same subgraph can be used across multiple parent graphs\n- **State Communication**: Subgraphs can share state or use different schemas with transformations\n\n## Demo Architecture 🗺️\n\nThis travel planner demonstrates **supervisor-coordinated subgraphs** with **human-in-the-loop** decision making:\n\n### Parent Graph: Travel Supervisor\n- **Role**: Coordinates the travel planning process and routes to specialized agents\n- **State Management**: Maintains a shared itinerary object across all subgraphs\n- **Intelligence**: Determines what's needed and when each agent should be called\n\n### Subgraph 1: ✈️ Flights Agent\n- **Specialization**: Finding and booking flight options\n- **Process**: Presents flight options from Amsterdam to San Francisco with recommendations\n- **Interaction**: Uses interrupts to let users choose their preferred flight\n- **Data**: Static flight options (KLM, United) with pricing and duration\n\n### Subgraph 2: 🏨 Hotels Agent \n- **Specialization**: Finding and booking accommodation\n- **Process**: Shows hotel options in San Francisco with different price points\n- **Interaction**: Uses interrupts for user to select their preferred hotel\n- **Data**: Static hotel options (Hotel Zephyr, Ritz-Carlton, Hotel Zoe)\n\n### Subgraph 3: 🎯 Experiences Agent\n- **Specialization**: Curating restaurants and activities\n- **Process**: AI-powered recommendations based on selected flights and hotels\n- **Features**: Combines 2 restaurants and 2 activities with location-aware suggestions\n- **Data**: Static experiences (Pier 39, Golden Gate Bridge, Swan Oyster Depot, Tartine Bakery)\n\n## How It Works 🔄\n\n1. **User Request**: \"Help me plan a trip to San Francisco\"\n2. **Supervisor Analysis**: Determines what travel components are needed\n3. **Sequential Routing**: Routes to each agent in logical order:\n - First: Flights Agent (get transportation sorted)\n - Then: Hotels Agent (book accommodation) \n - Finally: Experiences Agent (plan activities)\n4. **Human Decisions**: Each agent presents options and waits for user choice via interrupts\n5. **State Building**: Selected choices are stored in the shared itinerary object\n6. **Completion**: All agents report back to supervisor for final coordination\n\n## State Communication Patterns 📊\n\n### Shared State Schema\nAll subgraph agents share and contribute to a common state object. When any agent updates the shared state, these changes are immediately reflected in the frontend through real-time syncing. This ensures that:\n\n- **Flight selections** from the Flights Agent are visible to subsequent agents\n- **Hotel choices** influence the Experiences Agent's recommendations \n- **All updates** are synchronized with the frontend UI in real-time\n- **State persistence** maintains the travel itinerary throughout the workflow\n\n### Human-in-the-Loop Pattern\nTwo of the specialist agents use **interrupts** to pause execution and gather user preferences:\n\n- **Flights Agent**: Presents options → interrupt → waits for selection → continues\n- **Hotels Agent**: Shows hotels → interrupt → waits for choice → continues\n\n## Try These Examples! 💡\n\n### Getting Started\n- \"Help me plan a trip to San Francisco\"\n- \"I want to visit San Francisco from Amsterdam\"\n- \"Plan my travel itinerary\"\n\n### During the Process\nWhen the Flights Agent presents options:\n- Choose between KLM ($650, 11h 30m) or United ($720, 12h 15m)\n\nWhen the Hotels Agent shows accommodations:\n- Select from Hotel Zephyr, The Ritz-Carlton, or Hotel Zoe\n\nThe Experiences Agent will then provide tailored recommendations based on your choices!\n\n## Frontend Capabilities 👁️\n\n- **Human-in-the-loop with interrupts** from subgraphs for user decision making\n- **Subgraphs detection and streaming** to show which agent is currently active\n- **Real-time state updates** as the shared itinerary is built across agents\n", + "language": "markdown", + "type": "file" + }, + { + "name": "subgraphs.py", + "content": "\"\"\"\nA travel-planner demo showcasing a multi-agent flow with human-in-the-loop.\n\nA supervisor coordinates three specialists (flights, hotels, experiences). The\nflights and hotels steps pause the flow so the user picks an option; the\nexperiences step narrates recommendations. ``active_agent`` tracks who is\nworking so the UI can light up the current specialist, and each pick lands in a\nshared ``itinerary``.\n\"\"\"\n\nimport json\nimport uuid\nfrom typing import Any, Dict, List\n\nfrom crewai.flow.flow import Flow, listen, start\nfrom crewai.flow import human_feedback\nfrom litellm import acompletion\n\nfrom ..sdk import CopilotKitState, copilotkit_stream\nfrom .._hitl import agui_feedback_provider\n\nMODEL = \"openai/gpt-5.4\"\n\nSTATIC_FLIGHTS: List[Dict[str, str]] = [\n {\n \"airline\": \"KLM\",\n \"departure\": \"Amsterdam (AMS)\",\n \"arrival\": \"San Francisco (SFO)\",\n \"price\": \"$650\",\n \"duration\": \"11h 30m\",\n },\n {\n \"airline\": \"United\",\n \"departure\": \"Amsterdam (AMS)\",\n \"arrival\": \"San Francisco (SFO)\",\n \"price\": \"$720\",\n \"duration\": \"12h 15m\",\n },\n]\n\nSTATIC_HOTELS: List[Dict[str, str]] = [\n {\n \"name\": \"Hotel Zephyr\",\n \"location\": \"Fisherman's Wharf\",\n \"price_per_night\": \"$280/night\",\n \"rating\": \"4.2 stars\",\n },\n {\n \"name\": \"The Ritz-Carlton\",\n \"location\": \"Nob Hill\",\n \"price_per_night\": \"$550/night\",\n \"rating\": \"4.8 stars\",\n },\n {\n \"name\": \"Hotel Zoe\",\n \"location\": \"Union Square\",\n \"price_per_night\": \"$320/night\",\n \"rating\": \"4.4 stars\",\n },\n]\n\nSTATIC_EXPERIENCES: List[Dict[str, str]] = [\n {\n \"name\": \"Pier 39\",\n \"type\": \"activity\",\n \"description\": \"Iconic waterfront destination with shops and sea lions\",\n \"location\": \"Fisherman's Wharf\",\n },\n {\n \"name\": \"Golden Gate Bridge\",\n \"type\": \"activity\",\n \"description\": \"World-famous suspension bridge with stunning views\",\n \"location\": \"Golden Gate\",\n },\n {\n \"name\": \"Swan Oyster Depot\",\n \"type\": \"restaurant\",\n \"description\": \"Historic seafood counter serving fresh oysters\",\n \"location\": \"Polk Street\",\n },\n {\n \"name\": \"Tartine Bakery\",\n \"type\": \"restaurant\",\n \"description\": \"Artisanal bakery famous for bread and pastries\",\n \"location\": \"Mission District\",\n },\n]\n\n\nclass TravelAgentState(CopilotKitState):\n \"\"\"Shared state for the travel-planner, read by the UI.\"\"\"\n\n origin: str = \"Amsterdam\"\n destination: str = \"San Francisco\"\n flights: List[Dict[str, Any]] = []\n hotels: List[Dict[str, Any]] = []\n experiences: List[Dict[str, Any]] = []\n itinerary: Dict[str, Any] = {}\n active_agent: str = \"supervisor\"\n planning_step: str = \"start\"\n\n\ndef _parse_selection(raw: Any) -> Dict[str, Any]:\n \"\"\"Best-effort parse of the resume payload (a JSON-encoded option) to a dict.\"\"\"\n if isinstance(raw, dict):\n return raw\n if not isinstance(raw, str):\n return {}\n text = raw.strip()\n if text.startswith(\"```\"):\n text = text.strip(\"`\")\n if \"{\" in text:\n text = text[text.index(\"{\"):]\n try:\n parsed = json.loads(text)\n except (ValueError, TypeError):\n return {}\n return parsed if isinstance(parsed, dict) else {}\n\n\nclass SubgraphsFlow(Flow[TravelAgentState]):\n \"\"\"Supervisor-coordinated travel planner with two HITL selection steps.\"\"\"\n\n @start()\n async def supervisor(self):\n \"\"\"Kick off planning: greet and hand over to the flights specialist.\"\"\"\n self.state.active_agent = \"supervisor\"\n self.state.planning_step = \"flights\"\n\n @listen(supervisor)\n async def prepare_flights(self):\n \"\"\"Flights specialist takes over.\n\n A step of its own so the state (active agent + found flights) is\n snapshotted for the UI before the next step suspends the flow.\n \"\"\"\n self.state.active_agent = \"flights\"\n self.state.flights = STATIC_FLIGHTS\n\n @listen(prepare_flights)\n @human_feedback(\n message=\"Select a flight option.\",\n provider=agui_feedback_provider,\n )\n def find_flights(self):\n \"\"\"Present the flight options and pause for the user's choice.\"\"\"\n return {\n \"message\": (\n f\"Found {len(STATIC_FLIGHTS)} flights from {self.state.origin} to \"\n f\"{self.state.destination}. I recommend {STATIC_FLIGHTS[0]['airline']} \"\n \"since it is on time and cheaper.\"\n ),\n \"options\": STATIC_FLIGHTS,\n \"recommendation\": STATIC_FLIGHTS[0],\n \"agent\": \"flights\",\n }\n\n @listen(find_flights)\n async def select_flight(self, feedback):\n \"\"\"Resumed with the flight pick: record it and hand over to hotels.\"\"\"\n answer = getattr(feedback, \"feedback\", feedback)\n selected = _parse_selection(answer) or STATIC_FLIGHTS[0]\n self.state.itinerary = {**self.state.itinerary, \"flight\": selected}\n self.state.messages.append({\n \"id\": str(uuid.uuid4()),\n \"role\": \"assistant\",\n \"content\": (\n f\"Flights Agent: Booked the {selected.get('airline')} flight from \"\n f\"{selected.get('departure')} to {selected.get('arrival')}.\"\n ),\n })\n self.state.planning_step = \"hotels\"\n\n @listen(select_flight)\n async def prepare_hotels(self):\n \"\"\"Hotels specialist takes over; snapshot state before the next suspend.\"\"\"\n self.state.active_agent = \"hotels\"\n self.state.hotels = STATIC_HOTELS\n\n @listen(prepare_hotels)\n @human_feedback(\n message=\"Select a hotel option.\",\n provider=agui_feedback_provider,\n )\n def find_hotels(self):\n \"\"\"Present the hotel options and pause for the user's choice.\"\"\"\n return {\n \"message\": (\n f\"Found {len(STATIC_HOTELS)} hotels in {self.state.destination}. I \"\n f\"recommend {STATIC_HOTELS[2]['name']} for its balance of rating, \"\n \"price, and location.\"\n ),\n \"options\": STATIC_HOTELS,\n \"recommendation\": STATIC_HOTELS[2],\n \"agent\": \"hotels\",\n }\n\n @listen(find_hotels)\n async def select_hotel(self, feedback):\n \"\"\"Resumed with the hotel pick: record it and hand over to experiences.\"\"\"\n answer = getattr(feedback, \"feedback\", feedback)\n selected = _parse_selection(answer) or STATIC_HOTELS[2]\n self.state.itinerary = {**self.state.itinerary, \"hotel\": selected}\n self.state.messages.append({\n \"id\": str(uuid.uuid4()),\n \"role\": \"assistant\",\n \"content\": f\"Hotels Agent: Great choice, you'll love {selected.get('name')}.\",\n })\n self.state.planning_step = \"experiences\"\n\n @listen(select_hotel)\n async def prepare_experiences(self):\n \"\"\"Experiences specialist takes over; snapshot state before narrating.\"\"\"\n self.state.active_agent = \"experiences\"\n self.state.experiences = STATIC_EXPERIENCES\n\n @listen(prepare_experiences)\n async def find_experiences(self):\n \"\"\"Narrate the experiences the specialist found.\"\"\"\n itinerary = self.state.itinerary\n system_prompt = (\n \"You are the experiences agent for a trip to \"\n f\"{self.state.destination}. The traveller has chosen the \"\n f\"{itinerary.get('flight', {}).get('airline', 'selected')} flight and \"\n f\"the {itinerary.get('hotel', {}).get('name', 'selected')} hotel. You \"\n \"already found these experiences: \"\n f\"{json.dumps(STATIC_EXPERIENCES)}. In two or three friendly sentences, \"\n \"let the traveller know what you found. Do not ask questions.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n *self.state.messages,\n ],\n stream=True,\n )\n )\n self.state.messages.append(response.choices[0].message)\n self.state.planning_step = \"complete\"\n", + "language": "python", + "type": "file" + } + ], + "crewai::a2ui_dynamic_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Dynamic Schema\n\n## What This Demo Shows\n\nDynamic A2UI where a secondary LLM generates the entire UI schema and data from the conversation context.\n\n1. **LLM-generated UI**: A secondary GPT-4.1 call produces the `render_a2ui` tool call with components and data\n2. **No pre-defined schema**: The UI layout is created on-the-fly based on what the user asks for\n3. **Progressive streaming**: Components and data stream as the secondary LLM generates them\n4. **Built-in progress indicator**: Shows generation progress while the schema is being created\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_dynamic_schema.py", + "content": "\"\"\"A2UI dynamic-schema demo.\n\nA plain agentic-chat flow with no A2UI tool wired: the frontend a2ui middleware\nforwards ``injectA2UITool`` and the adapter auto-injects ``generate_a2ui``,\nwhich designs a surface from the conversation against the dojo's dynamic catalog\n(pillars 1-4). See ``_a2ui_subagent`` for the shared turn.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\n\nfrom ._a2ui_subagent import run_a2ui_subagent_turn\n\n\nclass A2UIDynamicSchemaFlow(Flow):\n \"\"\"Dynamic A2UI surfaces generated on the fly via the auto-injected tool.\"\"\"\n\n @start()\n async def chat(self):\n await run_a2ui_subagent_turn(self.state)\n", + "language": "python", + "type": "file" + } + ], + "crewai::a2ui_recovery": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Recover from an error\",\n message: \"Compare 3 luxury hotels with ratings and prices.\",\n },\n {\n title: \"Hard failure\",\n message: \"Compare 3 broken hotels with ratings and prices.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Error Recovery\n\n## What This Demo Shows\n\nAutomatic, no-wipe recovery when a secondary LLM generates an **invalid** A2UI surface.\n\n1. **Server-side validation gate**: Each generated component tree is validated before it can paint. Invalid trees are suppressed — the user never sees a broken surface flash and disappear.\n2. **Structured-error feedback loop**: The validation errors are fed back to the generating sub-agent, which regenerates (up to a configurable cap, default 3 attempts).\n3. **No wipes**: Only a validated surface ever commits. Faulty attempts never paint, so there's no stream → error → wipe → retry flicker.\n4. **Tasteful hard-failure**: If every attempt fails, a clean failure state is shown and the conversation stays usable. Developers get full per-attempt detail; end users don't see transient noise.\n\n## How to Interact\n\nTwo suggestions are wired for this demo:\n\n- **\"Compare 3 luxury hotels with ratings and prices.\"** — the first generated surface references a UI template the model \"forgot\" to include (a dangling child reference). The gate rejects it, the error is fed back, and the **second attempt is valid** and paints. You see the recovered surface, not the broken one.\n- **\"Compare 3 broken hotels with ratings and prices.\"** — every attempt is invalid, so the loop **exhausts** and the clean hard-failure state appears. The chat remains interactive afterward.\n\n## How It Works Technically\n\n- The **commit point is the component-tree close** — the only moment a tree is knowable as complete — where the middleware runs `validateA2UIComponents` and emits the surface **only if valid**.\n- On rejection, `augmentPromptWithValidationErrors` appends the machine-readable errors to the sub-agent's prompt and the adapter re-invokes it (`runA2UIGenerationWithRecovery`), never retrying after a validated paint.\n- Recovery is surfaced as an `a2ui_recovery` activity: a delayed \"Retrying…\" hint for slow/repeated retries, and a hard-failure state once the attempt cap is reached.\n- The retry cap, the threshold before the retry hint appears, and how much debug state is exposed are all configurable.\n\nThis feature drives errors deterministically via ai-mock fixtures so the recovery and hard-failure paths can be demonstrated and tested reliably.\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_recovery.py", + "content": "\"\"\"A2UI error-recovery demo.\n\nSame subagent path as the dynamic-schema demo: the adapter auto-injects\n``generate_a2ui``, which validates each generated surface and retries on failure\n(up to 3 attempts) before a tasteful hard-failure. Recovery is inherent to the\ntoolkit loop, so this shares the dynamic-schema turn.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\n\nfrom ._a2ui_subagent import run_a2ui_subagent_turn\n\n\nclass A2UIRecoveryFlow(Flow):\n \"\"\"Dynamic A2UI with automatic validate/retry recovery.\"\"\"\n\n @start()\n async def chat(self):\n await run_a2ui_subagent_turn(self.state)\n", + "language": "python", + "type": "file" + } + ], + "crewai::a2ui_fixed_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { fixedSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Search flights\",\n message: \"Find flights from SFO to JFK for next Tuesday.\",\n },\n {\n title: \"Search hotels\",\n message: \"Find hotels in downtown Manhattan for next weekend.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Fixed Schema\n\n## What This Demo Shows\n\nFixed-schema A2UI rendering where the UI schema is pre-defined in JSON files and only the data changes per invocation.\n\n1. **Pre-built schemas**: Flight card layout loaded from `flight_schema.json`\n2. **Data binding**: The agent populates flight data into the schema template\n3. **Action handlers**: \"Select\" button triggers an optimistic booking confirmation\n4. **No streaming**: All cards render at once after the tool completes\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_fixed_schema.py", + "content": "\"\"\"A2UI fixed-schema flow.\n\nUnlike the dynamic demo (which auto-injects generate_a2ui to GENERATE a\nsurface), the fixed-schema demo wires two backend tools, ``search_flights`` and\n``search_hotels``. The component layout is pre-authored JSON loaded at import;\nonly the data changes per call. Each tool returns the ``a2ui_operations``\nenvelope (createSurface -> updateComponents -> updateDataModel) as a tool\nresult, which the frontend A2UIMiddleware detects and paints. No sub-agent, no\ngeneration, no recovery.\n\"\"\"\n\nimport json\nimport logging\nimport uuid\nfrom pathlib import Path\nfrom typing import Any\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\n\nfrom ag_ui_a2ui_toolkit import (\n A2UI_OPERATIONS_KEY,\n create_surface,\n update_components,\n update_data_model,\n)\n\nfrom ..sdk import copilotkit_emit_tool_result, copilotkit_stream\nfrom ._model_turn import (\n append_assistant_message,\n resolve_client_tools,\n sort_tool_calls,\n)\n\nlogger = logging.getLogger(\"ag_ui_crewai\")\n\nMODEL = \"openai/gpt-5.4\"\n\n# Model turns per run: one search plus its closing reply, with headroom for a\n# flight-and-hotel request. Bounded so a model that keeps calling tools cannot\n# spin the run.\nMAX_MODEL_TURNS = 4\n\n# Both surfaces render against the dojo's fixed catalog (Row / FlightCard /\n# HotelCard / StarRating); the dojo page supplies the catalog components, we\n# only reference its id in createSurface.\nFIXED_CATALOG_ID = \"https://a2ui.org/demos/dojo/fixed_catalog.json\"\n\n_SCHEMAS_DIR = Path(__file__).parent / \"a2ui_fixed_schema_schemas\"\n\n\ndef _load_schema(name: str) -> list[dict[str, Any]]:\n with open(_SCHEMAS_DIR / name, encoding=\"utf-8\") as f:\n return json.load(f)\n\n\nFLIGHT_SURFACE_ID = \"flight-search-results\"\nFLIGHT_SCHEMA = _load_schema(\"flight_schema.json\")\nHOTEL_SURFACE_ID = \"hotel-search-results\"\nHOTEL_SCHEMA = _load_schema(\"hotel_schema.json\")\n\n\ndef _envelope(surface_id: str, schema: list[dict[str, Any]], data: dict[str, Any]) -> str:\n \"\"\"Build the A2UI operations envelope JSON for a fixed-schema surface.\"\"\"\n return json.dumps(\n {\n A2UI_OPERATIONS_KEY: [\n create_surface(surface_id, catalog_id=FIXED_CATALOG_ID),\n update_components(surface_id, schema),\n update_data_model(surface_id, data),\n ]\n }\n )\n\n\nSEARCH_FLIGHTS_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"search_flights\",\n \"description\": \"Search for flights and display the results as rich cards.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"flights\": {\n \"type\": \"array\",\n \"description\": (\n \"Flight objects, each with: id, airline, airlineLogo \"\n \"(Google favicon API: \"\n \"https://www.google.com/s2/favicons?domain={airline_domain}&sz=128), \"\n \"flightNumber, origin, destination, date (short readable, \"\n \"near-future), departureTime, arrivalTime, duration, \"\n \"status, price.\"\n ),\n \"items\": {\"type\": \"object\"},\n }\n },\n \"required\": [\"flights\"],\n },\n },\n}\n\nSEARCH_HOTELS_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"search_hotels\",\n \"description\": \"Search for hotels and display the results as rich cards with star ratings.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"hotels\": {\n \"type\": \"array\",\n \"description\": (\n \"Hotel objects, each with: id, name, location, rating \"\n \"(float 0-5), price (per night). Generate 3-4 realistic \"\n \"results.\"\n ),\n \"items\": {\"type\": \"object\"},\n }\n },\n \"required\": [\"hotels\"],\n },\n },\n}\n\nSYSTEM_PROMPT = (\n \"You are a helpful travel assistant that can search for flights and hotels. \"\n \"When the user asks about flights, use the search_flights tool; for hotels, \"\n \"use search_hotels. After calling a tool, do NOT repeat or summarize the \"\n \"data in your text response; the tool renders a rich UI automatically. Just \"\n \"say something brief like 'Here are your results'. Generate 3-5 realistic \"\n \"results.\\n\\n\"\n \"The conversation may already contain a report that the user interacted with \"\n \"results you rendered earlier (booked a hotel or selected a flight, for \"\n \"example). That report is history, not a new request: do NOT run another \"\n \"search and do NOT call any tool. Reply in text, naming the specific item the \"\n \"user chose and what happens next.\"\n)\n\n\ndef _results(args: dict[str, Any], key: str) -> list:\n \"\"\"The results list for a search call. A missing OR explicitly-null argument\n becomes an empty list: ``updateDataModel {\"hotels\": null}`` paints nothing at\n all, where an empty surface is what a no-results search means.\"\"\"\n value = args.get(key)\n return value if isinstance(value, list) else []\n\n\n_TOOL_ENVELOPE = {\n \"search_flights\": lambda args: _envelope(\n FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {\"flights\": _results(args, \"flights\")}\n ),\n \"search_hotels\": lambda args: _envelope(\n HOTEL_SURFACE_ID, HOTEL_SCHEMA, {\"hotels\": _results(args, \"hotels\")}\n ),\n}\n\n\nclass A2UIFixedSchemaFlow(Flow):\n \"\"\"A2UI surfaces from fixed, pre-authored schemas via direct backend tools.\n\n Loops the model over its own tool results (bounded by ``MAX_MODEL_TURNS``)\n so a turn that ends in a search still gets a closing model reply.\n\n What the loop does for a user action on a rendered surface, precisely: the\n middleware appends the action and its report to the NEXT run's input, so the\n report is already in history on the first turn and a model that answers it in\n text needs no loop at all. The loop saves the case the live model actually\n takes: it tool-calls FIRST (running another search), which without a loop\n would end the run on that call and leave the user's choice unacknowledged.\n \"\"\"\n\n @start()\n async def chat(self):\n state = self.state\n actions = (state.get(\"copilotkit\") or {}).get(\"actions\") or []\n # A frontend action sharing a search tool's name is dropped in favour of\n # the backend tool (and logged), so the model is offered one tool per name\n # rather than two definitions of the same one.\n offered, client_names = resolve_client_tools(\n actions, backend_names=set(_TOOL_ENVELOPE)\n )\n tools = [*offered, SEARCH_FLIGHTS_TOOL, SEARCH_HOTELS_TOOL]\n\n for _ in range(MAX_MODEL_TURNS):\n response = await copilotkit_stream(\n await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n *state[\"messages\"],\n ],\n tools=tools,\n parallel_tool_calls=False,\n stream=True,\n )\n )\n message = response.choices[0].message\n tool_calls = message.tool_calls or []\n # An orphan call (a name neither this flow's searches nor a frontend\n # tool) is answered by nobody, so it is dropped instead of persisted:\n # an assistant tool_calls entry with no matching tool result 400s\n # every later run on this thread.\n backend, client, orphan = sort_tool_calls(\n tool_calls,\n backend_names=set(_TOOL_ENVELOPE),\n client_names=client_names,\n )\n append_assistant_message(\n state, response, message, drop_indexes={i for i, _ in orphan}\n )\n\n if not tool_calls:\n return\n\n for _, tool_call in backend:\n build = _TOOL_ENVELOPE[tool_call.function.name]\n try:\n args = json.loads(tool_call.function.arguments or \"{}\")\n except (json.JSONDecodeError, TypeError):\n logger.warning(\n \"%s tool-call args were not valid JSON; rendering an \"\n \"empty surface: %r\",\n tool_call.function.name,\n tool_call.function.arguments,\n )\n args = {}\n envelope = build(args)\n # One id for the streamed result and the persisted message: the\n # terminal MESSAGES_SNAPSHOT then updates that message in place.\n # Left unstamped, the snapshot mints a second id and the client\n # remounts the surface card it just painted.\n result_id = str(uuid.uuid4())\n state[\"messages\"].append(\n {\n \"id\": result_id,\n \"role\": \"tool\",\n \"content\": envelope,\n \"tool_call_id\": tool_call.id,\n }\n )\n # The A2UI middleware paints the fixed surface from the tool\n # RESULT (a2ui_operations envelope), which the bridge otherwise\n # surfaces only via MESSAGES_SNAPSHOT. Emit it as a\n # TOOL_CALL_RESULT so the middleware detects and renders it.\n await copilotkit_emit_tool_result(\n tool_call.id, envelope, message_id=result_id\n )\n\n # A frontend call ends the run so the client can run it and send the\n # result back on the next one; feeding the model again here would\n # leave that call unanswered. An orphan call does NOT end the run: it\n # was dropped, so the history is well-formed, and ending here would\n # cost the user a reply. The model gets another turn to answer in text\n # instead, bounded by MAX_MODEL_TURNS.\n if client:\n return\n\n logger.warning(\n \"Fixed-schema turn hit the %d-model-turn cap with the model still \"\n \"calling tools; ending the run without a closing reply\",\n MAX_MODEL_TURNS,\n )\n", "language": "python", "type": "file" } @@ -3802,32 +3952,366 @@ }, { "name": "crew_chat.py", - "content": "\"\"\"Minimal CrewAI Crew for testing the dict-state code path (add_crewai_crew_fastapi_endpoint).\"\"\"\n\nfrom crewai import Agent, Crew, Task, Process\n\n\nclass CrewChatCrew:\n \"\"\"A minimal crew wrapper with .crew() and .name, used to test the\n add_crewai_crew_fastapi_endpoint() code path where state is a plain dict.\n\n Does NOT use @CrewBase to avoid config file lookups and init-time LLM calls\n from crew_chat_generate_crew_chat_inputs, which would fail before aimock starts.\"\"\"\n\n name = \"CrewChatCrew\"\n\n def crew(self) -> Crew:\n assistant = Agent(\n role=\"General Assistant\",\n goal=\"Help the user with their request\",\n backstory=\"You are a helpful general-purpose assistant.\",\n verbose=False,\n )\n\n assist_task = Task(\n description=\"{user_message}\",\n expected_output=\"A helpful response to the user's message\",\n agent=assistant,\n )\n\n return Crew(\n agents=[assistant],\n tasks=[assist_task],\n process=Process.sequential,\n verbose=False,\n chat_llm=\"openai/gpt-4o\",\n )\n", + "content": "\"\"\"Minimal CrewAI Crew for testing the dict-state code path (add_crewai_crew_fastapi_endpoint).\"\"\"\n\nfrom crewai import Agent, Crew, Task, Process\n\n\nclass CrewChatCrew:\n \"\"\"A minimal crew wrapper with .crew() and .name, used to test the\n add_crewai_crew_fastapi_endpoint() code path where state is a plain dict.\n\n Does NOT use @CrewBase to avoid config file lookups and init-time LLM calls\n from crew_chat_generate_crew_chat_inputs, which would fail before aimock starts.\"\"\"\n\n name = \"CrewChatCrew\"\n\n def crew(self) -> Crew:\n assistant = Agent(\n role=\"General Assistant\",\n goal=\"Help the user with their request\",\n backstory=\"You are a helpful general-purpose assistant.\",\n verbose=False,\n )\n\n assist_task = Task(\n description=\"{user_message}\",\n expected_output=\"A helpful response to the user's message\",\n agent=assistant,\n )\n\n return Crew(\n agents=[assistant],\n tasks=[assist_task],\n process=Process.sequential,\n verbose=False,\n chat_llm=\"openai/gpt-5.4\",\n )\n", "language": "python", "type": "file" } ], - "crewai::error_flow": [ + "crewai-conversational-flows::agentic_chat": [ { "name": "page.tsx", - "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport { CopilotChat } from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface ErrorFlowProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst ErrorFlowPage: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n};\n\nexport default ErrorFlowPage;\n", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport { \n useFrontendTool,\n useRenderTool,\n useAgentContext,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticChatProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticChat: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\nconst Chat = () => {\n const [background, setBackground] = useState(\"--copilot-kit-background-color\");\n\n useAgentContext({\n description: 'Name of the user',\n value: 'Bob'\n });\n\n useFrontendTool({\n name: \"change_background\",\n description:\n \"Change the background color of the chat. Can be anything that the CSS background attribute accepts. Regular colors, linear of radial gradients etc.\",\n parameters: z.object({\n background: z.string().describe(\"The background. Prefer gradients. Only use when asked.\"),\n }) ,\n handler: async ({ background }: { background: string }) => {\n setBackground(background);\n return {\n status: \"success\",\n message: `Background changed to ${background}`,\n };\n },\n });\n\n useRenderTool({\n name: \"get_weather\",\n parameters: z.object({\n location: z.string(),\n }) ,\n render: ({ args, result, status }: any) => {\n if (status !== \"complete\") {\n return
Loading weather...
;\n }\n\n // Some integrations (e.g. LangGraph) deliver tool results as a JSON-encoded\n // string in the ToolMessage content rather than a parsed object. Normalize\n // so property access works in either case; otherwise every field reads as\n // undefined and the card renders empty values.\n let parsed: any = result;\n if (typeof parsed === \"string\") {\n try {\n parsed = JSON.parse(parsed);\n } catch {\n parsed = {};\n }\n }\n parsed = parsed ?? {};\n\n return (\n
\n Weather in {parsed.city ?? args.location}\n
Temperature: {parsed.temperature}°C
\n
Humidity: {parsed.humidity}%
\n
Wind Speed: {parsed.windSpeed ?? parsed.wind_speed} mph
\n
Conditions: {parsed.conditions}
\n
\n );\n },\n });\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Change background\",\n message: \"Change the background to something new.\",\n },\n {\n title: \"Generate sonnet\",\n message: \"Write a short sonnet about AI.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n
\n \n
\n \n );\n};\n\nexport default AgenticChat;\n", "language": "typescript", "type": "file" }, { "name": "README.mdx", - "content": "# ⚠️ Error Flow (RunErrorEvent Test)\n\n## What This Demo Shows\n\nThis demo exercises the **error handling path** in the CrewAI endpoint. The\nbackend flow intentionally raises a `RuntimeError` on every request, which\ntriggers the `except Exception` handler in `endpoint.py` that emits a\n`RunErrorEvent` via SSE.\n\n## How to Interact\n\nSend any message — the flow will raise immediately and no successful assistant\nresponse will be generated.\n\n## Technical Details\n\n- `ErrorFlow` raises `RuntimeError` in its `@start()` method before any LLM call\n- The `event_generator` exception handler catches it and emits `RunErrorEvent`\n- This verifies that backend exceptions are properly surfaced to the client\n rather than silently swallowed\n", + "content": "# 🤖 Agentic Chat with Frontend Tools\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **agentic chat** capabilities with **frontend\ntool integration**:\n\n1. **Natural Conversation**: Chat with your Copilot in a familiar chat interface\n2. **Frontend Tool Execution**: The Copilot can directly interacts with your UI\n by calling frontend functions\n3. **Seamless Integration**: Tools defined in the frontend and automatically\n discovered and made available to the agent\n\n## How to Interact\n\nTry asking your Copilot to:\n\n- \"Can you change the background color to something more vibrant?\"\n- \"Make the background a blue to purple gradient\"\n- \"Set the background to a sunset-themed gradient\"\n- \"Change it back to a simple light color\"\n\nYou can also chat about other topics - the agent will respond conversationally\nwhile having the ability to use your UI tools when appropriate.\n\n## ✨ Frontend Tool Integration in Action\n\n**What's happening technically:**\n\n- The React component defines a frontend function using `useCopilotAction`\n- CopilotKit automatically exposes this function to the agent\n- When you make a request, the agent determines whether to use the tool\n- The agent calls the function with the appropriate parameters\n- The UI immediately updates in response\n\n**What you'll see in this demo:**\n\n- The Copilot understands requests to change the background\n- It generates CSS values for colors and gradients\n- When it calls the tool, the background changes instantly\n- The agent provides a conversational response about the changes it made\n\nThis technique of exposing frontend functions to your Copilot can be extended to\nany UI manipulation you want to enable, from theme changes to data filtering,\nnavigation, or complex UI state management!\n", "language": "markdown", "type": "file" }, { - "name": "error_flow.py", - "content": "\"\"\"Flow that intentionally raises to test the RunErrorEvent error handling path.\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom ..sdk import CopilotKitState\n\n\nclass ErrorFlow(Flow[CopilotKitState]):\n \"\"\"A flow that always raises an exception on kickoff.\n Used to test that endpoint.py's except handler emits RunErrorEvent correctly.\"\"\"\n\n @start()\n async def chat(self):\n raise RuntimeError(\"Intentional error for testing RunErrorEvent handling\")\n", + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "agentic_chat.py", + "content": "\"\"\"\nA simple agentic chat flow.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\nclass AgenticChatFlow(Flow[CopilotKitState]):\n\n @start()\n async def chat(self):\n system_prompt = \"You are a helpful assistant.\"\n\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the available tools to the model\n tools=[\n *self.state.copilotkit.actions,\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n", "language": "python", "type": "file" } ], - "crewai::a2ui_dynamic_schema": [ + "crewai-conversational-flows::agentic_chat_reasoning": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useFrontendTool,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { ChevronDown } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticChatProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticChat: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\ninterface AgentState {\n model: string;\n}\n\nconst Chat = () => {\n const [background, setBackground] = useState(\"--copilot-kit-background-color\");\n const { agent } = useAgent({\n agentId: \"agentic_chat_reasoning\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as AgentState | undefined;\n\n // Initialize model if not set\n const selectedModel = agentState?.model || \"OpenAI\";\n\n const handleModelChange = (model: string) => {\n agent.setState({ model });\n };\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Change background\",\n message: \"Change the background to something new.\",\n },\n {\n title: \"Generate sonnet\",\n message: \"Write a short sonnet about AI.\",\n },\n ],\n available: \"always\",\n });\n\n useFrontendTool({\n agentId: \"agentic_chat_reasoning\",\n name: \"change_background\",\n description:\n \"Change the background color of the chat. Can be anything that the CSS background attribute accepts. Regular colors, linear of radial gradients etc.\",\n parameters: z.object({\n background: z.string().describe(\"The background. Prefer gradients.\"),\n }) ,\n handler: async ({ background }: { background: string }) => {\n setBackground(background);\n },\n });\n\n return (\n
\n {/* Reasoning Model Dropdown */}\n
\n
\n
\n \n Reasoning Model:\n \n \n \n \n \n \n Select Model\n \n handleModelChange(\"OpenAI\")}>\n OpenAI\n \n handleModelChange(\"Anthropic\")}>\n Anthropic\n \n handleModelChange(\"Gemini\")}>\n Gemini\n \n \n \n
\n
\n
\n\n {/* Chat Container */}\n
\n
\n \n
\n
\n
\n );\n};\n\nexport default AgenticChat;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": ".copilotKitInput {\n border-bottom-left-radius: 0.75rem;\n border-bottom-right-radius: 0.75rem;\n border-top-left-radius: 0.75rem;\n border-top-right-radius: 0.75rem;\n border: 1px solid var(--copilot-kit-separator-color) !important;\n}\n \n.copilotKitChat {\n background-color: #fff !important;\n}\n ", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🤖 Agentic Chat with Reasoning\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **agentic chat** capabilities with **frontend\ntool integration**:\n\n1. **Natural Conversation**: Chat with your Copilot in a familiar chat interface\n2. **Frontend Tool Execution**: The Copilot can directly interacts with your UI\n by calling frontend functions\n3. **Seamless Integration**: Tools defined in the frontend and automatically\n discovered and made available to the agent\n\n## How to Interact\n\nTry asking your Copilot to:\n\n- \"Can you change the background color to something more vibrant?\"\n- \"Make the background a blue to purple gradient\"\n- \"Set the background to a sunset-themed gradient\"\n- \"Change it back to a simple light color\"\n\nYou can also chat about other topics - the agent will respond conversationally\nwhile having the ability to use your UI tools when appropriate.\n\n## ✨ Frontend Tool Integration in Action\n\n**What's happening technically:**\n\n- The React component defines a frontend function using `useCopilotAction`\n- CopilotKit automatically exposes this function to the agent\n- When you make a request, the agent determines whether to use the tool\n- The agent calls the function with the appropriate parameters\n- The UI immediately updates in response\n\n**What you'll see in this demo:**\n\n- The Copilot understands requests to change the background\n- It generates CSS values for colors and gradients\n- When it calls the tool, the background changes instantly\n- The agent provides a conversational response about the changes it made\n\nThis technique of exposing frontend functions to your Copilot can be extended to\nany UI manipulation you want to enable, from theme changes to data filtering,\nnavigation, or complex UI state management!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "agentic_chat_reasoning.py", + "content": "\"\"\"\nAn agentic chat flow that surfaces the model's reasoning.\n\nThe reasoning cell lets the user pick a provider from the frontend; the choice\narrives on ``state.model``. Each provider is streamed over the channel that\nactually carries its reasoning, and the bridge maps both onto REASONING_*:\n\n* Anthropic (extended thinking) and Gemini reason on the litellm\n chat-completions delta, so they stream through ``acompletion``.\n* OpenAI's reasoning models emit reasoning summaries ONLY over the Responses\n API, so they stream through ``copilotkit_responses``. Over chat-completions\n they answer with no thinking trace at all.\n\nThe Responses channel is used only when the bridge probes it as available\n(``responses_channel_available``); otherwise the flow degrades to\nchat-completions with a warning, and OpenAI answers without a trace.\n\"\"\"\n\nimport logging\nfrom typing import Any, Dict, List\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\n\nfrom ..sdk import (\n CopilotKitState,\n copilotkit_responses,\n copilotkit_stream,\n responses_channel_available,\n)\n\nlogger = logging.getLogger(\"ag_ui_crewai\")\n\nSYSTEM_PROMPT = \"You are a helpful assistant.\"\n\n# The frontend dropdown's choices. This is a USER selection, not a capability\n# inference: which transport carries a provider's reasoning is decided by the\n# bridge's runtime probe, never by matching on these model strings.\nOPENAI_MODEL = \"openai/gpt-5.4\"\nANTHROPIC_MODEL = \"anthropic/claude-sonnet-4-5\"\nGEMINI_MODEL = \"gemini/gemini-2.5-pro\"\n\n\nclass AgentState(CopilotKitState):\n \"\"\"Chat state plus the frontend-selected reasoning model.\"\"\"\n\n model: str = \"OpenAI\"\n\n\ndef _chat_completion_kwargs(selected_model: str) -> Dict[str, Any]:\n \"\"\"Map a chat-completions provider choice to its model + reasoning config.\"\"\"\n if selected_model == \"Anthropic\":\n return {\n \"model\": ANTHROPIC_MODEL,\n \"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 2000},\n }\n if selected_model == \"Gemini\":\n return {\n \"model\": GEMINI_MODEL,\n \"reasoning_effort\": \"low\",\n }\n # OpenAI over chat-completions: no reasoning content is returned, and\n # reasoning_effort is rejected outright for the gpt-5 family. Reached only\n # when the Responses channel is unavailable.\n return {\"model\": OPENAI_MODEL}\n\n\nclass AgenticChatReasoningFlow(Flow[AgentState]):\n\n @start()\n async def chat(self):\n messages = [\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n *self.state.messages,\n ]\n tools: List[Any] = [*self.state.copilotkit.actions]\n selected_model = self.state.model\n\n if selected_model == \"OpenAI\" and responses_channel_available():\n stream = await copilotkit_responses(\n model=OPENAI_MODEL,\n messages=messages,\n tools=tools or None,\n # ``summary`` is what makes OpenAI stream the reasoning summary\n # deltas at all; without it the run succeeds silently with no\n # trace to surface.\n reasoning={\"effort\": \"medium\", \"summary\": \"auto\"},\n # Forwarded through ``**kwargs``. One frontend tool call at a\n # time, matching the chat-completions branch and every other demo;\n # the OpenAI default is parallel.\n **({\"parallel_tool_calls\": False} if tools else {}),\n )\n else:\n if selected_model == \"OpenAI\":\n logger.warning(\n \"The OpenAI Responses channel is unavailable, so this run \"\n \"streams over chat-completions and will surface no thinking \"\n \"trace. Upgrade litellm to a build exposing 'aresponses'.\"\n )\n stream = await acompletion(\n messages=messages,\n tools=tools or None,\n parallel_tool_calls=False if tools else None,\n stream=True,\n **_chat_completion_kwargs(selected_model),\n )\n\n response = await copilotkit_stream(stream)\n\n self.state.messages.append(response.choices[0].message)\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::agentic_chat_multimodal": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport {\n useFrontendTool,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticChatMultimodalProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticChatMultimodal: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\nconst Chat = () => {\n const [background, setBackground] = useState(\"--copilot-kit-background-color\");\n\n useFrontendTool({\n name: \"change_background\",\n description:\n \"Change the background color of the chat. Can be anything that the CSS background attribute accepts. Regular colors, linear or radial gradients etc.\",\n parameters: z.object({\n background: z.string().describe(\"The background. Prefer gradients. Only use when asked.\"),\n }),\n handler: async ({ background }: { background: string }) => {\n setBackground(background);\n return {\n status: \"success\",\n message: `Background changed to ${background}`,\n };\n },\n });\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Upload an image\",\n message: \"Describe what you see in the image I upload.\",\n },\n {\n title: \"Analyze a photo\",\n message: \"What objects can you identify in this photo?\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n
\n \n
\n \n );\n};\n\nexport default AgenticChatMultimodal;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# Agentic Chat Multimodal\n\nThis example demonstrates multimodal input support in AG-UI. Users can upload images and other media alongside text messages, and the agent analyzes them.\n\n## How it works\n\n- The `CopilotChat` component is configured with `attachments={{ enabled: true }}` to allow file uploads\n- Uploaded images are sent as `ImageInputContent` with base64-encoded data through the AG-UI protocol\n- The backend agent uses a vision-capable model to analyze the uploaded content\n- The AG-UI integration layer automatically converts between AG-UI's multimodal content types and the framework's native format\n\n## Try it\n\n1. Click the attachment icon in the chat input\n2. Upload an image\n3. Ask the agent to describe or analyze the image\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "agentic_chat_multimodal.py", + "content": "\"\"\"\nA multimodal agentic chat flow that can analyze images and other media.\n\nImages the user attaches are converted to LiteLLM's ``image_url`` shape by the\nintegration layer before the run, so the flow only has to point a vision-capable\nmodel at the conversation.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\n\nclass AgenticChatMultimodalFlow(Flow[CopilotKitState]):\n\n @start()\n async def chat(self):\n system_prompt = (\n \"You are a helpful assistant that can analyze images, documents, and \"\n \"other media. When a user shares an image, describe what you see in \"\n \"detail. When a user shares a document, summarize its contents.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=\"openai/gpt-5.4\",\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n *self.state.messages,\n ],\n tools=[\n *self.state.copilotkit.actions,\n ],\n parallel_tool_calls=False,\n stream=True,\n )\n )\n\n self.state.messages.append(response.choices[0].message)\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::v1_agentic_chat": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { CopilotChat } from \"@copilotkit/react-ui\";\nimport \"@copilotkit/react-ui/styles.css\";\n\ninterface V1AgenticChatProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst V1AgenticChat: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n};\n\nexport default V1AgenticChat;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🤖 V1 Agentic Chat\n\n## What This Demo Shows\n\nThis demo verifies **CopilotKit v1 API compatibility**. It uses the original v1\ncomponents (`CopilotKit` provider and `CopilotChat`) to ensure that v1 APIs\ncontinue to work correctly against the current runtime.\n\n1. **V1 Provider**: Uses `CopilotKit` from `@copilotkit/react-core` with the\n `agent` prop for agent selection\n2. **V1 Chat UI**: Uses `CopilotChat` from `@copilotkit/react-ui` with v1\n styling\n3. **Same Backend**: Connects to the same runtime endpoint as v2, validating\n backward compatibility\n\n## How to Interact\n\nThis is a standard chat interface — type a message and the agent will respond\nconversationally, just like the v2 agentic chat demo.\n\n## ✨ V1 Compatibility\n\n**What's happening technically:**\n\n- The v1 `CopilotKit` provider connects to the same `/api/copilotkit/[integration]` endpoint\n- The v1 chat UI renders with v1 CSS classes (`.copilotKitInput`, `.copilotKitAssistantMessage`, etc.)\n- The agent selected via the `agent` prop maps to the same `agentic_chat` backend agent\n- This ensures that applications built with v1 APIs continue to function after runtime upgrades\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "agentic_chat.py", + "content": "\"\"\"\nA simple agentic chat flow.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\nclass AgenticChatFlow(Flow[CopilotKitState]):\n\n @start()\n async def chat(self):\n system_prompt = \"You are a helpful assistant.\"\n\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the available tools to the model\n tools=[\n *self.state.copilotkit.actions,\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::backend_tool_rendering": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport { \n useRenderTool,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticChatProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticChat: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\nconst Chat = () => {\n useRenderTool({\n \n name: \"get_weather\",\n parameters: z.object({\n location: z.string(),\n }) ,\n render: ({ args, result, status }: any) => {\n if (status !== \"complete\") {\n return (\n
\n ⚙️ Retrieving weather...\n
\n );\n }\n\n // Some integrations (e.g. LangGraph) deliver tool results as a JSON-encoded\n // string in the ToolMessage content rather than a parsed object. Normalize\n // so property access works in either case; otherwise every field falls\n // through to its `|| 0` default and the card shows 0° C.\n let parsed: any = result;\n if (typeof parsed === \"string\") {\n try {\n parsed = JSON.parse(parsed);\n } catch {\n parsed = {};\n }\n }\n parsed = parsed ?? {};\n\n const weatherResult: WeatherToolResult = {\n temperature: parsed.temperature ?? 0,\n conditions: parsed.conditions ?? \"clear\",\n humidity: parsed.humidity ?? 0,\n windSpeed: parsed.wind_speed ?? parsed.windSpeed ?? 0,\n feelsLike:\n parsed.feels_like ?? parsed.feelsLike ?? parsed.temperature ?? 0,\n };\n\n const themeColor = getThemeColor(weatherResult.conditions);\n\n return (\n \n );\n },\n });\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Weather in San Francisco\",\n message: \"What's the weather like in San Francisco?\",\n },\n {\n title: \"Weather in New York\",\n message: \"Tell me about the weather in New York.\",\n },\n {\n title: \"Weather in Tokyo\",\n message: \"How's the weather in Tokyo today?\",\n },\n ],\n available: \"always\",\n });\n\n return (\n
\n
\n \n
\n
\n );\n};\n\ninterface WeatherToolResult {\n temperature: number;\n conditions: string;\n humidity: number;\n windSpeed: number;\n feelsLike: number;\n}\n\nfunction getThemeColor(conditions: string): string {\n const conditionLower = conditions.toLowerCase();\n if (conditionLower.includes(\"clear\") || conditionLower.includes(\"sunny\")) {\n return \"#667eea\";\n }\n if (conditionLower.includes(\"rain\") || conditionLower.includes(\"storm\")) {\n return \"#4A5568\";\n }\n if (conditionLower.includes(\"cloud\")) {\n return \"#718096\";\n }\n if (conditionLower.includes(\"snow\")) {\n return \"#63B3ED\";\n }\n return \"#764ba2\";\n}\n\nfunction WeatherCard({\n location,\n themeColor,\n result,\n status,\n}: {\n location?: string;\n themeColor: string;\n result: WeatherToolResult;\n status: \"inProgress\" | \"executing\" | \"complete\";\n}) {\n return (\n \n
\n
\n
\n

\n {location}\n

\n

Current Weather

\n
\n \n
\n\n
\n
\n {result.temperature}° C\n \n {\" / \"}\n {((result.temperature * 9) / 5 + 32).toFixed(1)}° F\n \n
\n
{result.conditions}
\n
\n\n
\n
\n
\n

Humidity

\n

{result.humidity}%

\n
\n
\n

Wind

\n

{result.windSpeed} mph

\n
\n
\n

Feels Like

\n

{result.feelsLike}°

\n
\n
\n
\n
\n \n );\n}\n\nfunction WeatherIcon({ conditions }: { conditions: string }) {\n if (!conditions) return null;\n\n if (conditions.toLowerCase().includes(\"clear\") || conditions.toLowerCase().includes(\"sunny\")) {\n return ;\n }\n\n if (\n conditions.toLowerCase().includes(\"rain\") ||\n conditions.toLowerCase().includes(\"drizzle\") ||\n conditions.toLowerCase().includes(\"snow\") ||\n conditions.toLowerCase().includes(\"thunderstorm\")\n ) {\n return ;\n }\n\n if (\n conditions.toLowerCase().includes(\"fog\") ||\n conditions.toLowerCase().includes(\"cloud\") ||\n conditions.toLowerCase().includes(\"overcast\")\n ) {\n return ;\n }\n\n return ;\n}\n\n// Simple sun icon for the weather card\nfunction SunIcon() {\n return (\n \n \n \n \n );\n}\n\nfunction RainIcon() {\n return (\n \n {/* Cloud */}\n \n {/* Rain drops */}\n \n \n );\n}\n\nfunction CloudIcon() {\n return (\n \n \n \n );\n}\n\nexport default AgenticChat;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": ".copilotKitInput {\n border-bottom-left-radius: 0.75rem;\n border-bottom-right-radius: 0.75rem;\n border-top-left-radius: 0.75rem;\n border-top-right-radius: 0.75rem;\n border: 1px solid var(--copilot-kit-separator-color) !important;\n}\n\n.copilotKitChat {\n background-color: #fff !important;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🤖 Agentic Chat with Frontend Tools\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **agentic chat** capabilities with **frontend\ntool integration**:\n\n1. **Natural Conversation**: Chat with your Copilot in a familiar chat interface\n2. **Frontend Tool Execution**: The Copilot can directly interacts with your UI\n by calling frontend functions\n3. **Seamless Integration**: Tools defined in the frontend and automatically\n discovered and made available to the agent\n\n## How to Interact\n\nTry asking your Copilot to:\n\n- \"Can you change the background color to something more vibrant?\"\n- \"Make the background a blue to purple gradient\"\n- \"Set the background to a sunset-themed gradient\"\n- \"Change it back to a simple light color\"\n\nYou can also chat about other topics - the agent will respond conversationally\nwhile having the ability to use your UI tools when appropriate.\n\n## ✨ Frontend Tool Integration in Action\n\n**What's happening technically:**\n\n- The React component defines a frontend function using `useCopilotAction`\n- CopilotKit automatically exposes this function to the agent\n- When you make a request, the agent determines whether to use the tool\n- The agent calls the function with the appropriate parameters\n- The UI immediately updates in response\n\n**What you'll see in this demo:**\n\n- The Copilot understands requests to change the background\n- It generates CSS values for colors and gradients\n- When it calls the tool, the background changes instantly\n- The agent provides a conversational response about the changes it made\n\nThis technique of exposing frontend functions to your Copilot can be extended to\nany UI manipulation you want to enable, from theme changes to data filtering,\nnavigation, or complex UI state management!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "backend_tool_rendering.py", + "content": "\"\"\"Backend tool rendering.\n\nThis flow binds a real backend tool to a crewai ``Agent``: crewai runs\n``get_weather`` server-side, and the bridge surfaces the call + result so the\nclient renders a weather card without ever executing the tool. (The other tool\ndemos instead stream a frontend action for the client to run.)\n\nRequires the StreamFrame transport (crewai >= 1.6).\n\"\"\"\n\nimport asyncio\nimport json\n\nfrom crewai import Agent, Crew, Process, Task\nfrom crewai.flow.flow import Flow, start\nfrom crewai.tools import tool\n\nfrom ..sdk import CopilotKitState, copilotkit_exit\n\n\n@tool(\"get_weather\")\ndef get_weather(location: str) -> str:\n \"\"\"Get the current weather for a given location.\"\"\"\n # Return a JSON string, not a dict: crewai stringifies a tool's return\n # (str(result)) before it reaches the bridge, so a dict would arrive as a\n # single-quoted Python repr the client's JSON.parse rejects.\n return json.dumps(\n {\n \"temperature\": 20,\n \"conditions\": \"sunny\",\n \"humidity\": 50,\n \"wind_speed\": 10,\n \"feelsLike\": 25,\n }\n )\n\n\ndef _latest_user_message(messages) -> str:\n \"\"\"Return the text of the most recent user message, or ``\"\"``.\n\n Messages in flow state can be plain dicts (wire shape) or objects, so read\n ``role`` / ``content`` defensively.\n \"\"\"\n for message in reversed(messages or []):\n if isinstance(message, dict):\n role = message.get(\"role\")\n content = message.get(\"content\")\n else:\n role = getattr(message, \"role\", None)\n content = getattr(message, \"content\", None)\n if role == \"user\":\n return content or \"\"\n return \"\"\n\n\nclass BackendToolRenderingFlow(Flow[CopilotKitState]):\n \"\"\"A weather agent whose ``get_weather`` tool executes on the server.\"\"\"\n\n @start()\n async def chat(self):\n user_message = _latest_user_message(self.state.messages)\n\n agent = Agent(\n role=\"Weather Assistant\",\n goal=\"Answer the user's weather questions using the get_weather tool.\",\n backstory=(\n \"You are a helpful weather assistant. Always call the \"\n \"get_weather tool to look up the weather before you answer.\"\n ),\n tools=[get_weather],\n llm=\"openai/gpt-5.4\",\n verbose=False,\n )\n task = Task(\n description=(\n \"Answer the user's request about the weather. \"\n f\"User request: {user_message}\"\n ),\n expected_output=\"A short, friendly summary of the weather.\",\n agent=agent,\n )\n crew = Crew(\n agents=[agent],\n tasks=[task],\n process=Process.sequential,\n verbose=False,\n )\n\n # Run the synchronous crew off the event loop so SSE keeps flushing and\n # cancellation/teardown can fire during the run. to_thread copies the\n # scoped sink + flow_context, so the crew's tool events still stream.\n result = await asyncio.to_thread(crew.kickoff)\n\n # The crew's final text; the tool card renders from the streamed events.\n self.state.messages.append(\n {\n \"role\": \"assistant\",\n \"content\": getattr(result, \"raw\", None) or str(result),\n }\n )\n\n await copilotkit_exit()\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::interrupt": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useMemo, useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport {\n CopilotChat,\n CopilotChatConfigurationProvider,\n useConfigureSuggestions,\n useInterrupt,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { useTheme } from \"next-themes\";\n\ninterface InterruptProps {\n params: Promise<{ integrationId: string }>;\n}\n\n// Payload the Mastra `schedule_meeting` tool sends via `suspend(...)`. The\n// @ag-ui/mastra bridge wraps it in the on_interrupt CUSTOM event under\n// `suspendPayload` (the Mastra contract — it carries `toolName`/`toolCallId`/\n// `runId` the LangGraph raw-value shape doesn't). We read `suspendPayload`.\ninterface SuspendPayload {\n topic?: string;\n attendee?: string;\n}\n\ninterface TimeSlot {\n iso: string;\n label: string;\n}\n\n// Generate a few future slots relative to \"now\" so the picker is always valid.\nfunction generateSlots(): TimeSlot[] {\n const slots: TimeSlot[] = [];\n const now = new Date();\n for (let day = 1; day <= 2; day++) {\n for (const hour of [10, 14]) {\n const d = new Date(now);\n d.setDate(now.getDate() + day);\n d.setHours(hour, 0, 0, 0);\n slots.push({\n iso: d.toISOString(),\n label: d.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n }),\n });\n }\n }\n return slots;\n}\n\nconst Interrupt: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n \n \n );\n};\n\nconst ChatContent = () => {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Book a call with sales\",\n message: \"Book an intro call with the sales team to discuss pricing.\",\n },\n {\n title: \"Schedule a 1:1 with Alice\",\n message: \"Schedule a 1:1 with Alice next week to review Q2 goals.\",\n },\n ],\n available: \"always\",\n });\n\n // Native interrupt handling. The Mastra agent suspends its `schedule_meeting`\n // tool; the bridge emits `on_interrupt`; this renders the picker and\n // `resolve(...)` resumes the suspended tool with the user's choice.\n useInterrupt({\n agentId: \"interrupt\",\n renderInChat: true,\n render: ({ event, resolve }) => {\n // The adapter JSON-stringifies the interrupt value, so parse it.\n const raw = event.value ?? {};\n const parsed = (typeof raw === \"string\" ? JSON.parse(raw) : raw) as {\n // Mastra suspends a tool and carries the payload under `suspendPayload`.\n suspendPayload?: SuspendPayload;\n // CrewAI suspends the FLOW, so the value is the AG-UI Interrupt shape and\n // the paused method's output sits under `metadata.crewai.output`.\n metadata?: { crewai?: { output?: SuspendPayload } };\n };\n\n // Same picker either way: both frameworks pause to ask for a meeting time,\n // and both take the same resume payload back through `resolve(...)`.\n const payload = parsed.suspendPayload ?? parsed.metadata?.crewai?.output ?? {};\n return (\n \n resolve({ chosen_time: slot.iso, chosen_label: slot.label })\n }\n onCancel={() => resolve({ cancelled: true })}\n />\n );\n },\n });\n\n return (\n
\n
\n \n
\n
\n );\n};\n\nconst TimePickerCard: React.FC<{\n topic: string;\n attendee?: string;\n onPick: (slot: TimeSlot) => void;\n onCancel: () => void;\n}> = ({ topic, attendee, onPick, onCancel }) => {\n const { theme } = useTheme();\n const slots = useMemo(() => generateSlots(), []);\n const [done, setDone] = useState(null);\n\n const dark = theme === \"dark\";\n\n // Once the user picks/cancels, render nothing: `resolve()` resumes the run and\n // this interrupt render unmounts moments later, with the agent's text taking\n // over as the confirmation. Blanking on click (rather than leaving the slots\n // up) gives an instant, clean handoff and prevents a double-pick. We don't\n // show a \"Booked\" card here — it would only flash before unmounting; the\n // agent's message is the record.\n if (done) {\n return null;\n }\n\n return (\n \n

{topic}

\n {attendee ? (\n

with {attendee}

\n ) : (\n
\n )}\n\n
\n {slots.map((slot) => (\n {\n setDone(slot);\n onPick(slot);\n }}\n className={`rounded-lg border px-3 py-3 text-sm font-medium transition-colors ${\n dark\n ? \"border-slate-600 hover:border-blue-400 hover:bg-slate-700\"\n : \"border-gray-200 hover:border-blue-400 hover:bg-blue-50\"\n }`}\n >\n {slot.label}\n \n ))}\n
\n\n {\n setDone(\"cancelled\");\n onCancel();\n }}\n className={`mt-4 w-full rounded-lg border px-3 py-2 text-xs font-medium uppercase tracking-wide transition-colors ${\n dark\n ? \"border-slate-600 hover:bg-slate-700\"\n : \"border-gray-200 hover:bg-gray-50\"\n }`}\n >\n Cancel\n \n
\n );\n};\n\nexport default Interrupt;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# Interrupt (Suspend / Resume)\n\nDemonstrates **native interrupt-based Human-in-the-Loop** built on a framework's\nown suspend/resume primitive, bridged onto AG-UI's `on_interrupt` event and\nrendered with CopilotKit's v2 `useInterrupt` hook.\n\n## Flow\n\n1. The user asks the agent to book a meeting.\n2. The agent calls a backend tool (`schedule_meeting`) that **suspends its own\n execution** mid-tool, emitting the meeting context to the frontend.\n3. The adapter surfaces this as a CUSTOM `on_interrupt` event. `useInterrupt`\n captures it and renders an in-chat time picker.\n4. The user picks a slot (or cancels). `resolve(...)` sends the choice back and\n **resumes the suspended tool**, which returns a confirmation the agent\n summarizes.\n\n## Mastra notes\n\nThis is wired for both **Mastra (Local)** and the remote **Mastra** integration.\nMastra's tool `suspend()` is the native HITL primitive; the `@ag-ui/mastra`\nadapter bridges `tool-call-suspended` to `on_interrupt`. The interrupt value\nkeeps Mastra's own shape (the renderer reads `event.value.suspendPayload`).\nResume works for local agents (via the agent resume stream) and for remote\nagents (round-tripped over `@mastra/client-js`' `resumeStream`). Remote resume\nrequires the remote Mastra instance to have storage configured, since the\nsuspended run's snapshot is loaded from storage on resume.\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "interrupt_flow.py", + "content": "\"\"\"An example demonstrating async human-in-the-loop via a flow interrupt.\n\nA scheduling assistant, mirroring the interrupt demo the other integrations\nship: the model works out which meeting the user wants, the flow PAUSES so the\nuser can pick a time, and on resume the model confirms the booking. The human\ndecision lands in the middle of the agent's own work, which is the point of\nhuman-in-the-loop.\n\nThe pause is a real flow suspend: ``@human_feedback`` with the bridge's\n``agui_feedback_provider`` raises ``HumanFeedbackPending``, so the run ends with\nan AG-UI interrupt and the next request carrying ``RunAgentInput.resume[]``\ncontinues it via ``Flow.from_pending`` + ``resume_async``.\n\nContrast with ``human_in_the_loop.py``: that demo round-trips a FRONTEND tool\nand never pauses the flow. This one suspends the flow itself.\n\"\"\"\n\nimport json\nfrom typing import Any\n\nfrom crewai.flow.flow import Flow, listen, start\nfrom crewai.flow import human_feedback\nfrom litellm import acompletion\n\nfrom ..sdk import CopilotKitState, copilotkit_stream\nfrom .._hitl import agui_feedback_provider\n\nMODEL = \"openai/gpt-5.4\"\n\nEXTRACT_PROMPT = \"\"\"You are a scheduling assistant. From the conversation, work out which meeting the user wants to book.\n\nReply with ONLY a JSON object, no prose:\n{\"topic\": \"\", \"attendee\": \"\"}\"\"\"\n\nCONFIRM_PROMPT = \"\"\"You are a scheduling assistant. You asked the user to pick a meeting time and they have now responded.\n\nConfirm in 1-2 short, friendly sentences: state that the meeting is booked and for when, or acknowledge that they cancelled. Do not ask any further questions.\"\"\"\n\n\nclass AgentState(CopilotKitState):\n \"\"\"Flow state: what is being scheduled, and the outcome.\"\"\"\n\n topic: str = \"\"\n attendee: str = \"\"\n booked: str = \"\"\n\n\ndef _parse_json_object(raw: Any) -> dict:\n \"\"\"Best-effort parse of a model / resume payload into a dict.\n\n The model is asked for bare JSON but may fence it, and the resume payload\n arrives as a JSON-encoded string (or plain text if the user typed one).\n Never raises: a demo degrades to sensible defaults instead.\n \"\"\"\n if isinstance(raw, dict):\n return raw\n if not isinstance(raw, str):\n return {}\n text = raw.strip()\n if text.startswith(\"```\"):\n text = text.strip(\"`\")\n if \"{\" in text:\n text = text[text.index(\"{\"):]\n try:\n parsed = json.loads(text)\n except (ValueError, TypeError):\n return {}\n return parsed if isinstance(parsed, dict) else {}\n\n\nclass InterruptFlow(Flow[AgentState]):\n \"\"\"Scheduling assistant that suspends mid-run for the user's time choice.\"\"\"\n\n @start()\n async def understand_request(self):\n \"\"\"Work out the meeting to book from the conversation so far.\"\"\"\n response = await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": EXTRACT_PROMPT},\n *self.state.messages,\n ],\n )\n details = _parse_json_object(response.choices[0].message.content)\n self.state.topic = (details.get(\"topic\") or \"a call\").strip()\n self.state.attendee = (details.get(\"attendee\") or \"\").strip()\n\n @listen(understand_request)\n @human_feedback(\n message=\"Pick a time for this meeting.\",\n provider=agui_feedback_provider,\n )\n def request_time(self):\n \"\"\"Pause for the user's time choice.\n\n The return value is what the client renders (the time picker reads\n ``topic`` / ``attendee``). The provider raises ``HumanFeedbackPending``\n here, which suspends the flow and persists it for resume.\n \"\"\"\n return {\"topic\": self.state.topic, \"attendee\": self.state.attendee}\n\n @listen(request_time)\n async def confirm_booking(self, feedback):\n \"\"\"Resumed with the user's choice: confirm the booking in chat.\"\"\"\n answer = getattr(feedback, \"feedback\", feedback)\n choice = _parse_json_object(answer)\n label = choice.get(\"chosen_label\") or choice.get(\"chosen_time\") or \"\"\n cancelled = bool(choice.get(\"cancelled\"))\n\n if cancelled:\n self.state.booked = \"\"\n outcome = f\"The user cancelled. Do not book '{self.state.topic}'.\"\n elif label:\n self.state.booked = label\n outcome = (\n f\"The user picked {label}. '{self.state.topic}' is booked for then.\"\n )\n else:\n # Free-text feedback (no picker payload): pass it through verbatim.\n self.state.booked = str(answer)\n outcome = (\n f\"The user replied: {answer!r}. Treat that as their answer for \"\n f\"'{self.state.topic}'.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": CONFIRM_PROMPT},\n *self.state.messages,\n {\"role\": \"system\", \"content\": outcome},\n ],\n stream=True,\n )\n )\n self.state.messages.append(response.choices[0].message)\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::human_in_the_loop": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport { \n useHumanInTheLoop,\n useConfigureSuggestions,\n CopilotChat,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { z } from \"zod\";\nimport { useTheme } from \"next-themes\";\n\ninterface HumanInTheLoopProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst HumanInTheLoop: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n\n return (\n \n \n \n );\n};\n\ninterface Step {\n description: string;\n status: \"disabled\" | \"enabled\" | \"executing\";\n}\n\n// Shared UI Components\nconst StepContainer = ({ theme, children }: { theme?: string; children: React.ReactNode }) => (\n
\n \n {children}\n
\n \n);\n\nconst StepHeader = ({\n theme,\n enabledCount,\n totalCount,\n status,\n showStatus = false,\n}: {\n theme?: string;\n enabledCount: number;\n totalCount: number;\n status?: string;\n showStatus?: boolean;\n}) => (\n
\n
\n

\n Select Steps\n

\n
\n
\n {enabledCount}/{totalCount} Selected\n
\n {showStatus && (\n \n {status === \"executing\" ? \"Ready\" : \"Waiting\"}\n
\n )}\n
\n
\n\n \n 0 ? (enabledCount / totalCount) * 100 : 0}%` }}\n />\n \n \n);\n\nconst StepItem = ({\n step,\n theme,\n status,\n onToggle,\n disabled = false,\n}: {\n step: { description: string; status: string };\n theme?: string;\n status?: string;\n onToggle: () => void;\n disabled?: boolean;\n}) => (\n \n \n \n);\n\nconst ActionButton = ({\n variant,\n theme,\n disabled,\n onClick,\n children,\n}: {\n variant: \"primary\" | \"secondary\" | \"success\" | \"danger\";\n theme?: string;\n disabled?: boolean;\n onClick: () => void;\n children: React.ReactNode;\n}) => {\n const baseClasses = \"px-6 py-3 rounded-lg font-semibold transition-all duration-200\";\n const enabledClasses = \"hover:scale-105 shadow-md hover:shadow-lg\";\n const disabledClasses = \"opacity-50 cursor-not-allowed\";\n\n const variantClasses = {\n primary:\n \"bg-gradient-to-r from-purple-500 to-purple-700 hover:from-purple-600 hover:to-purple-800 text-white shadow-lg hover:shadow-xl\",\n secondary:\n theme === \"dark\"\n ? \"bg-slate-700 hover:bg-slate-600 text-white border border-slate-600 hover:border-slate-500\"\n : \"bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-300 hover:border-gray-400\",\n success:\n \"bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white shadow-lg hover:shadow-xl\",\n danger:\n \"bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white shadow-lg hover:shadow-xl\",\n };\n\n return (\n \n {children}\n \n );\n};\n\nconst DecorativeElements = ({\n theme,\n variant = \"default\",\n}: {\n theme?: string;\n variant?: \"default\" | \"success\" | \"danger\";\n}) => (\n <>\n \n \n \n);\nconst InterruptHumanInTheLoop: React.FC<{\n event: { value: { steps: Step[] } };\n resolve: (value: string) => void;\n}> = ({ event, resolve }) => {\n const { theme } = useTheme();\n\n // Parse and initialize steps data\n let initialSteps: Step[] = [];\n if (event.value && event.value.steps && Array.isArray(event.value.steps)) {\n initialSteps = event.value.steps.map((step: any) => ({\n description: typeof step === \"string\" ? step : step.description || \"\",\n status: typeof step === \"object\" && step.status ? step.status : \"enabled\",\n }));\n }\n\n const [localSteps, setLocalSteps] = useState(initialSteps);\n const enabledCount = localSteps.filter((step) => step.status === \"enabled\").length;\n\n const handleStepToggle = (index: number) => {\n setLocalSteps((prevSteps) =>\n prevSteps.map((step, i) =>\n i === index\n ? { ...step, status: step.status === \"enabled\" ? \"disabled\" : \"enabled\" }\n : step,\n ),\n );\n };\n\n const handlePerformSteps = () => {\n const selectedSteps = localSteps\n .filter((step) => step.status === \"enabled\")\n .map((step) => step.description);\n resolve(\"The user selected the following steps: \" + selectedSteps.join(\", \"));\n };\n\n return (\n \n \n\n
\n {localSteps.map((step, index) => (\n handleStepToggle(index)}\n />\n ))}\n
\n\n
\n \n \n Perform Steps\n \n {enabledCount}\n \n \n
\n\n \n
\n );\n};\n\nconst Chat = ({ integrationId }: { integrationId: string }) => {\n return (\n \n \n \n );\n};\n\nconst ChatContent = () => {\n useConfigureSuggestions({\n suggestions: [\n { title: \"Simple plan\", message: \"Please plan a trip to mars in 5 steps.\" },\n { title: \"Complex plan\", message: \"Please plan a pasta dish in 10 steps.\" },\n ],\n available: \"always\",\n });\n\n // Langgraph uses it's own hook to handle human-in-the-loop interactions via langgraph interrupts,\n // This hook won't do anything for other integrations.\n useLangGraphInterrupt({\n \n render: ({ event, resolve }) => ,\n });\n useHumanInTheLoop({\n agentId: \"human_in_the_loop\",\n name: \"generate_task_steps\",\n description: \"Generates a list of steps for the user to perform\",\n parameters: z.object({\n steps: z.array(\n z.object({\n description: z.string(),\n status: z.enum([\"enabled\", \"disabled\", \"executing\"]),\n }),\n ),\n }) ,\n // Note: In v1, `available` was used to disable this for langgraph integrations.\n // In v2, availability is handled at the agent/backend level.\n render: ({ args, respond, status }: any) => {\n return ;\n },\n });\n\n return (\n
\n
\n \n
\n
\n );\n};\n\nconst StepsFeedback = ({ args, respond, status }: { args: any; respond: any; status: any }) => {\n const { theme } = useTheme();\n const [localSteps, setLocalSteps] = useState([]);\n const [accepted, setAccepted] = useState(null);\n\n useEffect(() => {\n if (status === \"executing\" && localSteps.length === 0 && Array.isArray(args?.steps) && args.steps.length > 0) {\n setLocalSteps(args.steps);\n }\n }, [status, args?.steps, localSteps]);\n\n if (!Array.isArray(args?.steps) || args.steps.length === 0) {\n return <>;\n }\n\n const steps = Array.isArray(localSteps) && localSteps.length > 0 ? localSteps : args.steps;\n const enabledCount = steps.filter((step: any) => step.status === \"enabled\").length;\n\n const handleStepToggle = (index: number) => {\n setLocalSteps((prevSteps) =>\n prevSteps.map((step, i) =>\n i === index\n ? { ...step, status: step.status === \"enabled\" ? \"disabled\" : \"enabled\" }\n : step,\n ),\n );\n };\n\n const handleReject = () => {\n if (respond) {\n setAccepted(false);\n respond({ accepted: false });\n }\n };\n\n const handleConfirm = () => {\n if (respond) {\n const confirmedSteps = localSteps.filter((step) => step.status === \"enabled\");\n setAccepted(true);\n respond({ accepted: true, steps: confirmedSteps });\n }\n };\n\n return (\n \n \n\n
\n {steps.map((step: any, index: any) => (\n handleStepToggle(index)}\n disabled={status !== \"executing\"}\n />\n ))}\n
\n\n {/* Action Buttons - Different logic from InterruptHumanInTheLoop */}\n {accepted === null && (\n
\n \n \n Reject\n \n \n \n Confirm\n \n {enabledCount}\n \n \n
\n )}\n\n {/* Result State - Unique to StepsFeedback */}\n {accepted !== null && (\n
\n \n {accepted ? \"✓\" : \"✗\"}\n {accepted ? \"Accepted\" : \"Rejected\"}\n
\n \n )}\n\n \n
\n );\n};\n\nexport default HumanInTheLoop;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🤝 Human-in-the-Loop Task Planner\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **human-in-the-loop** capabilities:\n\n1. **Collaborative Planning**: The Copilot generates task steps and lets you\n decide which ones to perform\n2. **Interactive Decision Making**: Select or deselect steps to customize the\n execution plan\n3. **Adaptive Responses**: The Copilot adapts its execution based on your\n choices, even handling missing steps\n\n## How to Interact\n\nTry these steps to experience the demo:\n\n1. Ask your Copilot to help with a task, such as:\n\n - \"Make me a sandwich\"\n - \"Plan a weekend trip\"\n - \"Organize a birthday party\"\n - \"Start a garden\"\n\n2. Review the suggested steps provided by your Copilot\n\n3. Select or deselect steps using the checkboxes to customize the plan\n\n - Try removing essential steps to see how the Copilot adapts!\n\n4. Click \"Execute Plan\" to see the outcome based on your selections\n\n## ✨ Human-in-the-Loop Magic in Action\n\n**What's happening technically:**\n\n- The agent analyzes your request and breaks it down into logical steps\n- These steps are presented to you through a dynamic UI component\n- Your selections are captured as user input\n- The agent considers your choices when executing the plan\n- The agent adapts to missing steps with creative problem-solving\n\n**What you'll see in this demo:**\n\n- The Copilot provides a detailed, step-by-step plan for your task\n- You have complete control over which steps to include\n- If you remove essential steps, the Copilot provides entertaining and creative\n workarounds\n- The final execution reflects your choices, showing how human input shapes the\n outcome\n- Each response is tailored to your specific selections\n\nThis human-in-the-loop pattern creates a powerful collaborative experience where\nboth human judgment and AI capabilities work together to achieve better results\nthan either could alone!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "human_in_the_loop.py", + "content": "\"\"\"\nAn example demonstrating human-in-the-loop.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom litellm import acompletion\nfrom pydantic import BaseModel\nfrom typing import Literal, List\nfrom ..sdk import (\n copilotkit_stream,\n CopilotKitState,\n)\n\n# This tool simulates performing a task on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nDEFINE_TASK_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_task_steps\",\n \"description\": \"Make up the number of task steps requested by the user (only a couple of words per step). If the user does not request a count, make a concise plan. Each step should be in imperative form (i.e. Dig hole, Open door, ...)\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"steps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"The text of the step in imperative form\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"enabled\"],\n \"description\": \"The status of the step, always 'enabled'\"\n }\n },\n \"required\": [\"description\", \"status\"]\n },\n \"description\": \"An array containing the requested number of step objects, each with text and status\"\n }\n },\n \"required\": [\"steps\"]\n }\n }\n}\n\nHITL_SYSTEM_PROMPT = \"\"\"\nYou are a helpful assistant that can perform any task.\nCRITICAL: You MUST call the `generate_task_steps` function when the user asks you to perform a task.\nCRITICAL: Generate exactly the step count requested by the user. If no count is requested, generate a concise plan.\nWhen the function `generate_task_steps` is called, the user will decide to enable or disable a step and either accept or reject the plan.\nCRITICAL: If the tool result has `accepted: false`, the plan was rejected. Do not perform the rejected plan. Wait for revision instructions from the user.\nCRITICAL: After a rejection, interpret a terse numeric reply such as `5.` as a revised requested step count, then call `generate_task_steps` again with exactly that many steps.\nIf the tool result has `accepted: true`, provide a textual description of how you are performing only the accepted, enabled steps.\nIf the user has disabled a step, you are not allowed to perform that step.\nHowever, you should find a creative workaround to perform the task, and if an essential step is disabled, you can even use\nsome humor in the description of how you are performing the task.\nDon't just repeat a list of steps, come up with a creative but short description (3 sentences max) of how you are performing the task.\n\"\"\"\n\nclass TaskStep(BaseModel):\n description: str\n status: Literal[\"enabled\", \"disabled\"]\n\nclass AgentState(CopilotKitState):\n \"\"\"\n Here we define the state of the agent\n\n In this instance, we're inheriting from CopilotKitState, which will bring in\n the CopilotKitState fields. We're also adding a custom field, `steps`,\n which will be used to store the steps of the task.\n \"\"\"\n steps: List[TaskStep] = []\n\n\nclass HumanInTheLoopFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates a human-in-the-loop agent.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n # 1. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 1.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": HITL_SYSTEM_PROMPT\n },\n *self.state.messages\n ],\n\n # 1.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n DEFINE_TASK_TOOL\n ],\n\n # 1.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 2. Append the message to the messages in state\n self.state.messages.append(message)\n\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::agentic_generative_ui": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport { \n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotChat,\n} from \"@copilotkit/react-core/v2\";\nimport { useTheme } from \"next-themes\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface AgenticGenerativeUIProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nconst AgenticGenerativeUI: React.FC = ({ params }) => {\n const { integrationId } = React.use(params);\n return (\n \n \n \n );\n};\n\ninterface AgentState {\n steps: {\n description: string;\n status: \"pending\" | \"completed\";\n }[];\n}\n\nconst Chat = () => {\n const { theme } = useTheme();\n const { agent } = useAgent({\n agentId: \"agentic_generative_ui\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as AgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Simple plan\",\n message: \"Please build a plan to go to mars in 5 steps.\",\n },\n {\n title: \"Complex plan\",\n message: \"Please build a plan to go to make pizza in 10 steps.\",\n },\n ],\n available: \"always\",\n });\n\n const steps = agentState?.steps;\n\n return (\n
\n
\n (\n
\n {messageElements}\n {steps && steps.length > 0 && (\n
\n \n
\n )}\n {interruptElement}\n
\n ),\n }}\n />\n
\n
\n );\n};\n\nfunction TaskProgress({ steps, theme }: { steps: AgentState[\"steps\"]; theme?: string }) {\n const completedCount = steps.filter((step) => step.status === \"completed\").length;\n const progressPercentage = (completedCount / steps.length) * 100;\n\n return (\n
\n \n {/* Header */}\n
\n
\n

\n Task Progress\n

\n
\n {completedCount}/{steps.length} Complete\n
\n
\n\n {/* Progress Bar */}\n \n \n \n
\n
\n\n {/* Steps */}\n
\n {steps.map((step, index) => {\n const isCompleted = step.status === \"completed\";\n const isCurrentPending =\n step.status === \"pending\" &&\n index === steps.findIndex((s) => s.status === \"pending\");\n const isFuturePending = step.status === \"pending\" && !isCurrentPending;\n\n return (\n \n {/* Connector Line */}\n {index < steps.length - 1 && (\n \n )}\n\n {/* Status Icon */}\n \n {isCompleted ? (\n \n ) : isCurrentPending ? (\n \n ) : (\n \n )}\n
\n\n {/* Step Content */}\n
\n \n {step.description}\n
\n {isCurrentPending && (\n \n Processing...\n \n )}\n \n\n {/* Animated Background for Current Step */}\n {isCurrentPending && (\n \n )}\n \n );\n })}\n \n\n {/* Decorative Elements */}\n \n \n \n \n );\n}\n\n// Enhanced Icons\nfunction CheckIcon() {\n return (\n \n \n \n );\n}\n\nfunction SpinnerIcon() {\n return (\n \n \n \n \n );\n}\n\nfunction ClockIcon({ theme }: { theme?: string }) {\n return (\n \n \n \n \n );\n}\n\nexport default AgenticGenerativeUI;\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": ".copilotKitInput {\n border-bottom-left-radius: 0.75rem;\n border-bottom-right-radius: 0.75rem;\n border-top-left-radius: 0.75rem;\n border-top-right-radius: 0.75rem;\n border: 1px solid var(--copilot-kit-separator-color) !important;\n}\n\n.copilotKitChat {\n background-color: #fff !important;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🚀 Agentic Generative UI Task Executor\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **agentic generative UI** capabilities:\n\n1. **Real-time Status Updates**: The Copilot provides live feedback as it works\n through complex tasks\n2. **Long-running Task Execution**: See how agents can handle extended processes\n with continuous feedback\n3. **Dynamic UI Generation**: The interface updates in real-time to reflect the\n agent's progress\n\n## How to Interact\n\nSimply ask your Copilot to perform any moderately complex task:\n\n- \"Make me a sandwich\"\n- \"Plan a vacation to Japan\"\n- \"Create a weekly workout routine\"\n\nThe Copilot will break down the task into steps and begin \"executing\" them,\nproviding real-time status updates as it progresses.\n\n## ✨ Agentic Generative UI in Action\n\n**What's happening technically:**\n\n- The agent analyzes your request and creates a detailed execution plan\n- Each step is processed sequentially with realistic timing\n- Status updates are streamed to the frontend using CopilotKit's streaming\n capabilities\n- The UI dynamically renders these updates without page refreshes\n- The entire flow is managed by the agent, requiring no manual intervention\n\n**What you'll see in this demo:**\n\n- The Copilot breaks your task into logical steps\n- A status indicator shows the current progress\n- Each step is highlighted as it's being executed\n- Detailed status messages explain what's happening at each moment\n- Upon completion, you receive a summary of the task execution\n\nThis pattern of providing real-time progress for long-running tasks is perfect\nfor scenarios where users benefit from transparency into complex processes -\nfrom data analysis to content creation, system configurations, or multi-stage\nworkflows!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "agentic_generative_ui.py", + "content": "\"\"\"\nAn example demonstrating agentic generative UI.\n\"\"\"\n\nimport json\nimport asyncio\nfrom crewai.flow.flow import Flow, start, router, listen, or_\nfrom litellm import acompletion\nfrom pydantic import BaseModel\nfrom typing import Literal, List\n\nfrom ..sdk import (\n copilotkit_stream,\n CopilotKitState,\n copilotkit_predict_state,\n copilotkit_emit_state\n)\n\n# This tool simulates performing a task on the server.\n# The tool call will be streamed to the frontend as it is being generated.\nPERFORM_TASK_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_task_steps\",\n \"description\": \"Make up 10 steps (only a couple of words per step) that are required for a task. The step should be in gerund form (i.e. Digging hole, opening door, ...)\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"steps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"description\": {\n \"type\": \"string\",\n \"description\": \"The text of the step in gerund form\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"pending\"],\n \"description\": \"The status of the step, always 'pending'\"\n }\n },\n \"required\": [\"description\", \"status\"]\n },\n \"description\": \"An array of 10 step objects, each containing text and status\"\n }\n },\n \"required\": [\"steps\"]\n }\n }\n}\n\nclass TaskStep(BaseModel):\n description: str\n status: Literal[\"pending\", \"completed\"]\n\nclass AgentState(CopilotKitState):\n \"\"\"\n Here we define the state of the agent\n\n In this instance, we're inheriting from CopilotKitState, which will bring in\n the CopilotKitState fields. We're also adding a custom field, `steps`,\n which will be used to store the steps of the task.\n \"\"\"\n steps: List[TaskStep] = []\n\n\nclass AgenticGenerativeUIFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that uses the CopilotKit framework to create a chat agent.\n \"\"\"\n\n \n @start()\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n self.state.steps = []\n\n @router(or_(start_flow, \"simulate_task\"))\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = \"\"\"\n You are a helpful assistant assisting with any task. \n When asked to do something, you MUST call the function `generate_task_steps`\n that was provided to you.\n If you called the function, you MUST NOT repeat the steps in your next response to the user.\n Just give a very brief summary (one sentence) of what you did with some emojis. \n Always say you actually did the steps, not merely generated them.\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to generate_task_steps\n # to the frontend as state.\n await copilotkit_predict_state({\n \"steps\": {\n \"tool_name\": \"generate_task_steps\",\n \"tool_argument\": \"steps\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n PERFORM_TASK_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"generate_task_steps\":\n # Convert each step in the JSON array to a TaskStep instance\n self.state.steps = [TaskStep(**step) for step in tool_call_args[\"steps\"]]\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Steps executed.\",\n \"tool_call_id\": tool_call_id\n })\n return \"route_simulate_task\"\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_simulate_task\")\n async def simulate_task(self):\n \"\"\"\n Simulate the task.\n \"\"\"\n for step in self.state.steps:\n # simulate executing the step\n await asyncio.sleep(1)\n step.status = \"completed\"\n await copilotkit_emit_state(self.state)\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::predictive_state_updates": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\n\nimport MarkdownIt from \"markdown-it\";\nimport React from \"react\";\n\nimport { diffWords } from \"diff\";\nimport { useEditor, EditorContent } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { useEffect, useState, useRef } from \"react\";\nimport { \n useAgent,\n UseAgentUpdate,\n useHumanInTheLoop,\n useConfigureSuggestions,\n CopilotChat,\n CopilotSidebar,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\nconst extensions = [StarterKit];\n\ninterface PredictiveStateUpdatesProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nexport default function PredictiveStateUpdates({ params }: PredictiveStateUpdatesProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const { isChatOpen, setChatHeight, setIsChatOpen, isDragging, chatHeight, handleDragStart } =\n useMobileChat(defaultChatHeight);\n const chatTitle = \"AI Document Editor\";\n const chatDescription = \"Ask me to create or edit a document\";\n\n return (\n \n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight); // Reset to good default when opening\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n \n \n \n \n
\n \n \n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content - Flexible container for messages and input */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n
setIsChatOpen(false)} />\n )}\n \n ) : (\n \n )}\n \n
\n \n );\n}\n\ninterface AgentState {\n document: string;\n}\n\nconst DocumentEditor = () => {\n const editor = useEditor({\n extensions,\n immediatelyRender: false,\n editorProps: {\n attributes: { class: \"min-h-screen p-10\" },\n },\n });\n const [placeholderVisible, setPlaceholderVisible] = useState(false);\n const [currentDocument, setCurrentDocument] = useState(\"\");\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Write a pirate story\",\n message: \"Please write a story about a pirate named Candy Beard.\",\n },\n {\n title: \"Write a mermaid story\",\n message: \"Please write a story about a mermaid named Luna.\",\n },\n { title: \"Add character\", message: \"Please add a character named Courage.\" },\n ],\n available: \"always\",\n });\n\n const { agent } = useAgent({\n agentId: \"predictive_state_updates\",\n updates: [UseAgentUpdate.OnStateChanged, UseAgentUpdate.OnRunStatusChanged],\n });\n\n const agentState = agent.state as AgentState | undefined;\n const setAgentState = (s: AgentState) => agent.setState(s);\n const isLoading = agent.isRunning;\n\n // Track when a run transitions from running to not running (replaces nodeName == \"end\")\n const wasRunning = useRef(false);\n\n useEffect(() => {\n if (isLoading) {\n setCurrentDocument(editor?.getText() || \"\");\n }\n editor?.setEditable(!isLoading);\n }, [isLoading]);\n\n useEffect(() => {\n if (wasRunning.current && !isLoading) {\n // Run just finished - set the text one final time\n if (currentDocument.trim().length > 0 && currentDocument !== agentState?.document) {\n const newDocument = agentState?.document || \"\";\n const diff = diffPartialText(currentDocument, newDocument, true);\n const markdown = fromMarkdown(diff);\n editor?.commands.setContent(markdown);\n }\n }\n wasRunning.current = isLoading;\n }, [isLoading]);\n\n useEffect(() => {\n if (isLoading) {\n if (currentDocument.trim().length > 0) {\n const newDocument = agentState?.document || \"\";\n const diff = diffPartialText(currentDocument, newDocument);\n const markdown = fromMarkdown(diff);\n editor?.commands.setContent(markdown);\n } else {\n const markdown = fromMarkdown(agentState?.document || \"\");\n editor?.commands.setContent(markdown);\n }\n }\n }, [agentState?.document]);\n\n const text = editor?.getText() || \"\";\n\n useEffect(() => {\n setPlaceholderVisible(text.length === 0);\n\n if (!isLoading) {\n setCurrentDocument(text);\n setAgentState({\n document: text,\n });\n }\n }, [text]);\n\n // TODO(steve): Remove this when all agents have been updated to use write_document tool.\n useHumanInTheLoop(\n {\n agentId: \"predictive_state_updates\",\n name: \"confirm_changes\",\n render: ({ args, respond, status }) => (\n {\n editor?.commands.setContent(fromMarkdown(currentDocument));\n setAgentState({ document: currentDocument });\n }}\n onConfirm={() => {\n editor?.commands.setContent(fromMarkdown(agentState?.document || \"\"));\n setCurrentDocument(agentState?.document || \"\");\n setAgentState({ document: agentState?.document || \"\" });\n }}\n />\n ),\n },\n [agentState?.document],\n );\n\n // Action to write the document.\n useHumanInTheLoop(\n {\n agentId: \"predictive_state_updates\",\n name: \"write_document\",\n description: `Present the proposed changes to the user for review`,\n parameters: z.object({\n document: z.string().describe(\"The full updated document in markdown format\"),\n }) ,\n render({ args, status, respond }: { args: { document?: string }; status: string; respond?: (result: unknown) => Promise }) {\n if (status === \"executing\") {\n return (\n {\n editor?.commands.setContent(fromMarkdown(currentDocument));\n setAgentState({ document: currentDocument });\n }}\n onConfirm={() => {\n editor?.commands.setContent(fromMarkdown(agentState?.document || \"\"));\n setCurrentDocument(agentState?.document || \"\");\n setAgentState({ document: agentState?.document || \"\" });\n }}\n />\n );\n }\n return <>;\n },\n },\n [agentState?.document],\n );\n\n return (\n
\n {placeholderVisible && (\n
\n Write whatever you want here in Markdown format...\n
\n )}\n \n
\n );\n};\n\ninterface ConfirmChangesProps {\n args: any;\n respond: any;\n status: any;\n onReject: () => void;\n onConfirm: () => void;\n}\n\nfunction ConfirmChanges({ args, respond, status, onReject, onConfirm }: ConfirmChangesProps) {\n const [accepted, setAccepted] = useState(null);\n return (\n \n

Confirm Changes

\n

Do you want to accept the changes?

\n {accepted === null && (\n
\n {\n if (respond) {\n setAccepted(false);\n onReject();\n respond({ accepted: false });\n }\n }}\n >\n Reject\n \n {\n if (respond) {\n setAccepted(true);\n onConfirm();\n respond({ accepted: true });\n }\n }}\n >\n Confirm\n \n
\n )}\n {accepted !== null && (\n
\n \n {accepted ? \"✓ Accepted\" : \"✗ Rejected\"}\n
\n \n )}\n \n );\n}\n\nfunction fromMarkdown(text: string) {\n const md = new MarkdownIt({\n typographer: true,\n html: true,\n });\n\n return md.render(text);\n}\n\nfunction diffPartialText(oldText: string, newText: string, isComplete: boolean = false) {\n let oldTextToCompare = oldText;\n if (oldText.length > newText.length && !isComplete) {\n // make oldText shorter\n oldTextToCompare = oldText.slice(0, newText.length);\n }\n\n const changes = diffWords(oldTextToCompare, newText);\n\n let result = \"\";\n changes.forEach((part) => {\n if (part.added) {\n result += `${part.value}`;\n } else if (part.removed) {\n result += `${part.value}`;\n } else {\n result += part.value;\n }\n });\n\n if (oldText.length > newText.length && !isComplete) {\n result += oldText.slice(newText.length);\n }\n\n return result;\n}\n\nfunction isAlpha(text: string) {\n return /[a-zA-Z\\u00C0-\\u017F]/.test(text.trim());\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "/* Basic editor styles */\n.tiptap-container {\n height: 100vh; /* Full viewport height */\n width: 100vw; /* Full viewport width */\n display: flex;\n flex-direction: column;\n}\n\n.tiptap {\n flex: 1; /* Take up remaining space */\n overflow: auto; /* Allow scrolling if content overflows */\n}\n\n.tiptap :first-child {\n margin-top: 0;\n}\n\n/* List styles */\n.tiptap ul,\n.tiptap ol {\n padding: 0 1rem;\n margin: 1.25rem 1rem 1.25rem 0.4rem;\n}\n\n.tiptap ul li p,\n.tiptap ol li p {\n margin-top: 0.25em;\n margin-bottom: 0.25em;\n}\n\n/* Heading styles */\n.tiptap h1,\n.tiptap h2,\n.tiptap h3,\n.tiptap h4,\n.tiptap h5,\n.tiptap h6 {\n line-height: 1.1;\n margin-top: 2.5rem;\n text-wrap: pretty;\n font-weight: bold;\n}\n\n.tiptap h1,\n.tiptap h2,\n.tiptap h3,\n.tiptap h4,\n.tiptap h5,\n.tiptap h6 {\n margin-top: 3.5rem;\n margin-bottom: 1.5rem;\n}\n\n.tiptap p {\n margin-bottom: 1rem;\n}\n\n.tiptap h1 {\n font-size: 1.4rem;\n}\n\n.tiptap h2 {\n font-size: 1.2rem;\n}\n\n.tiptap h3 {\n font-size: 1.1rem;\n}\n\n.tiptap h4,\n.tiptap h5,\n.tiptap h6 {\n font-size: 1rem;\n}\n\n/* Code and preformatted text styles */\n.tiptap code {\n background-color: var(--purple-light);\n border-radius: 0.4rem;\n color: var(--black);\n font-size: 0.85rem;\n padding: 0.25em 0.3em;\n}\n\n.tiptap pre {\n background: var(--black);\n border-radius: 0.5rem;\n color: var(--white);\n font-family: \"JetBrainsMono\", monospace;\n margin: 1.5rem 0;\n padding: 0.75rem 1rem;\n}\n\n.tiptap pre code {\n background: none;\n color: inherit;\n font-size: 0.8rem;\n padding: 0;\n}\n\n.tiptap blockquote {\n border-left: 3px solid var(--gray-3);\n margin: 1.5rem 0;\n padding-left: 1rem;\n}\n\n.tiptap hr {\n border: none;\n border-top: 1px solid var(--gray-2);\n margin: 2rem 0;\n}\n\n.tiptap s {\n background-color: #f9818150;\n padding: 2px;\n font-weight: bold;\n color: rgba(0, 0, 0, 0.7);\n}\n\n.tiptap em {\n background-color: #b2f2bb;\n padding: 2px;\n font-weight: bold;\n font-style: normal;\n}\n\n.copilotKitWindow {\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);\n}\n\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 📝 Predictive State Updates Document Editor\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **predictive state updates** for real-time\ndocument collaboration:\n\n1. **Live Document Editing**: Watch as your Copilot makes changes to a document\n in real-time\n2. **Diff Visualization**: See exactly what's being changed as it happens\n3. **Streaming Updates**: Changes are displayed character-by-character as the\n Copilot works\n\n## How to Interact\n\nTry these interactions with the collaborative document editor:\n\n- \"Fix the grammar and typos in this document\"\n- \"Make this text more professional\"\n- \"Add a section about [topic]\"\n- \"Summarize this content in bullet points\"\n- \"Change the tone to be more casual\"\n\nWatch as the Copilot processes your request and edits the document in real-time\nright before your eyes.\n\n## ✨ Predictive State Updates in Action\n\n**What's happening technically:**\n\n- The document state is shared between your UI and the Copilot\n- As the Copilot generates content, changes are streamed to the UI\n- Each modification is visualized with additions and deletions\n- The UI renders these changes progressively, without waiting for completion\n- All edits are tracked and displayed in a visually intuitive way\n\n**What you'll see in this demo:**\n\n- Text changes are highlighted in different colors (green for additions, red for\n deletions)\n- The document updates character-by-character, creating a typing-like effect\n- You can see the Copilot's thought process as it refines the content\n- The final document seamlessly incorporates all changes\n- The experience feels collaborative, as if someone is editing alongside you\n\nThis pattern of real-time collaborative editing with diff visualization is\nperfect for document editors, code review tools, content creation platforms, or\nany application where users benefit from seeing exactly how content is being\ntransformed!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "predictive_state_updates.py", + "content": "\"\"\"\nA demo of predictive state updates.\n\"\"\"\n\nimport json\nimport uuid\nfrom typing import Optional\nfrom litellm import acompletion\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom ..sdk import (\n copilotkit_stream, \n copilotkit_predict_state,\n CopilotKitState\n)\n\nWRITE_DOCUMENT_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"write_document_local\",\n \"description\": \" \".join(\"\"\"\n Write a document. Use markdown formatting to format the document.\n It's good to format the document extensively so it's easy to read.\n You can use all kinds of markdown.\n However, do not use italic or strike-through formatting, it's reserved for another purpose.\n You MUST write the full document, even when changing only a few words.\n When making edits to the document, try to make them minimal - do not change every word.\n Keep stories SHORT!\n \"\"\".split()),\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"document\": {\n \"type\": \"string\",\n \"description\": \"The document to write\"\n },\n },\n }\n }\n}\n\n\nclass AgentState(CopilotKitState):\n \"\"\"\n The state of the agent.\n \"\"\"\n document: Optional[str] = None\n\nclass PredictiveStateUpdatesFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates predictive state updates.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n system_prompt = f\"\"\"\n You are a helpful assistant for writing documents.\n To write the document, you MUST use the write_document_local tool.\n You MUST write the full document, even when changing only a few words.\n When you wrote the document, DO NOT repeat it as a message. \n Just briefly summarize the changes you made. 2 sentences max.\n This is the current state of the document: ----\\n {self.state.document}\\n-----\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to write_document_local\n # to the frontend as state.\n await copilotkit_predict_state({\n \"document\": {\n \"tool_name\": \"write_document_local\",\n \"tool_argument\": \"document\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n WRITE_DOCUMENT_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"write_document_local\":\n self.state.document = tool_call_args[\"document\"]\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Document written.\",\n \"tool_call_id\": tool_call_id\n })\n\n # 4.2 Append a tool call to confirm changes\n self.state.messages.append({\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [{\n \"id\": str(uuid.uuid4()),\n \"function\": {\n \"name\": \"confirm_changes\",\n \"arguments\": \"{}\"\n }\n }]\n })\n\n return \"route_end\"\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::shared_state": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport {\n useAgent,\n UseAgentUpdate,\n useCopilotKit,\n useConfigureSuggestions,\n CopilotChat,\n CopilotSidebar,\n} from \"@copilotkit/react-core/v2\";\nimport React, { useState, useEffect, useRef } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface SharedStateProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\nexport default function SharedState({ params }: SharedStateProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const { isChatOpen, setChatHeight, setIsChatOpen, isDragging, chatHeight, handleDragStart } =\n useMobileChat(defaultChatHeight);\n\n const chatTitle = \"AI Recipe Assistant\";\n const chatDescription = \"Ask me to craft recipes\";\n\n return (\n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight); // Reset to good default when opening\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n \n \n \n \n
\n
\n \n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n \n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content - Flexible container for messages and input */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n
setIsChatOpen(false)} />\n )}\n \n ) : (\n \n )}\n
\n \n );\n}\n\nenum SkillLevel {\n BEGINNER = \"Beginner\",\n INTERMEDIATE = \"Intermediate\",\n ADVANCED = \"Advanced\",\n}\n\nenum CookingTime {\n FiveMin = \"5 min\",\n FifteenMin = \"15 min\",\n ThirtyMin = \"30 min\",\n FortyFiveMin = \"45 min\",\n SixtyPlusMin = \"60+ min\",\n}\n\nconst cookingTimeValues = [\n { label: CookingTime.FiveMin, value: 0 },\n { label: CookingTime.FifteenMin, value: 1 },\n { label: CookingTime.ThirtyMin, value: 2 },\n { label: CookingTime.FortyFiveMin, value: 3 },\n { label: CookingTime.SixtyPlusMin, value: 4 },\n];\n\nenum SpecialPreferences {\n HighProtein = \"High Protein\",\n LowCarb = \"Low Carb\",\n Spicy = \"Spicy\",\n BudgetFriendly = \"Budget-Friendly\",\n OnePotMeal = \"One-Pot Meal\",\n Vegetarian = \"Vegetarian\",\n Vegan = \"Vegan\",\n}\n\ninterface Ingredient {\n icon: string;\n name: string;\n amount: string;\n}\n\ninterface Recipe {\n title: string;\n skill_level: SkillLevel;\n cooking_time: CookingTime;\n special_preferences: string[];\n ingredients: Ingredient[];\n instructions: string[];\n}\n\ninterface RecipeAgentState {\n recipe: Recipe;\n}\n\nconst INITIAL_STATE: RecipeAgentState = {\n recipe: {\n title: \"Make Your Recipe\",\n skill_level: SkillLevel.INTERMEDIATE,\n cooking_time: CookingTime.FortyFiveMin,\n special_preferences: [],\n ingredients: [\n { icon: \"🥕\", name: \"Carrots\", amount: \"3 large, grated\" },\n { icon: \"🌾\", name: \"All-Purpose Flour\", amount: \"2 cups\" },\n ],\n instructions: [\"Preheat oven to 350°F (175°C)\"],\n },\n};\n\nfunction Recipe() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"shared_state\",\n updates: [UseAgentUpdate.OnStateChanged, UseAgentUpdate.OnRunStatusChanged],\n });\n const { copilotkit } = useCopilotKit();\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Create Italian recipe\",\n message: \"Create a delicious Italian pasta recipe.\",\n },\n {\n title: \"Make it healthier\",\n message: \"Make the recipe healthier with more vegetables.\",\n },\n {\n title: \"Suggest variations\",\n message: \"Suggest some creative variations of this recipe.\",\n },\n ],\n available: \"always\",\n });\n\n const agentState = agent.state as RecipeAgentState | undefined;\n const setAgentState = (s: RecipeAgentState) => agent.setState(s);\n const isLoading = agent.isRunning;\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState?.recipe) {\n setAgentState(INITIAL_STATE);\n }\n }, []);\n\n const [recipe, setRecipe] = useState(INITIAL_STATE.recipe);\n const [editingInstructionIndex, setEditingInstructionIndex] = useState(null);\n const newInstructionRef = useRef(null);\n\n const updateRecipe = (partialRecipe: Partial) => {\n setAgentState({\n ...(agentState || INITIAL_STATE),\n recipe: {\n ...recipe,\n ...partialRecipe,\n },\n });\n setRecipe({\n ...recipe,\n ...partialRecipe,\n });\n };\n\n const newRecipeState = { ...recipe };\n const newChangedKeys = [];\n const changedKeysRef = useRef([]);\n\n for (const key in recipe) {\n if (\n agentState &&\n agentState.recipe &&\n (agentState.recipe as any)[key] !== undefined &&\n (agentState.recipe as any)[key] !== null\n ) {\n let agentValue = (agentState.recipe as any)[key];\n const recipeValue = (recipe as any)[key];\n\n // Check if agentValue is a string and replace \\n with actual newlines\n if (typeof agentValue === \"string\") {\n agentValue = agentValue.replace(/\\\\n/g, \"\\n\");\n }\n\n if (JSON.stringify(agentValue) !== JSON.stringify(recipeValue)) {\n (newRecipeState as any)[key] = agentValue;\n newChangedKeys.push(key);\n }\n }\n }\n\n if (newChangedKeys.length > 0) {\n changedKeysRef.current = newChangedKeys;\n } else if (!isLoading) {\n changedKeysRef.current = [];\n }\n\n useEffect(() => {\n setRecipe(newRecipeState);\n }, [JSON.stringify(newRecipeState)]);\n\n const handleTitleChange = (event: React.ChangeEvent) => {\n updateRecipe({\n title: event.target.value,\n });\n };\n\n const handleSkillLevelChange = (event: React.ChangeEvent) => {\n updateRecipe({\n skill_level: event.target.value as SkillLevel,\n });\n };\n\n const handleDietaryChange = (preference: string, checked: boolean) => {\n if (checked) {\n updateRecipe({\n special_preferences: [...recipe.special_preferences, preference],\n });\n } else {\n updateRecipe({\n special_preferences: recipe.special_preferences.filter((p) => p !== preference),\n });\n }\n };\n\n const handleCookingTimeChange = (event: React.ChangeEvent) => {\n updateRecipe({\n cooking_time: cookingTimeValues[Number(event.target.value)].label,\n });\n };\n\n const addIngredient = () => {\n // Pick a random food emoji from our valid list\n updateRecipe({\n ingredients: [...recipe.ingredients, { icon: \"🍴\", name: \"\", amount: \"\" }],\n });\n };\n\n const updateIngredient = (index: number, field: keyof Ingredient, value: string) => {\n const updatedIngredients = [...recipe.ingredients];\n updatedIngredients[index] = {\n ...updatedIngredients[index],\n [field]: value,\n };\n updateRecipe({ ingredients: updatedIngredients });\n };\n\n const removeIngredient = (index: number) => {\n const updatedIngredients = [...recipe.ingredients];\n updatedIngredients.splice(index, 1);\n updateRecipe({ ingredients: updatedIngredients });\n };\n\n const addInstruction = () => {\n const newIndex = recipe.instructions.length;\n updateRecipe({\n instructions: [...recipe.instructions, \"\"],\n });\n // Set the new instruction as the editing one\n setEditingInstructionIndex(newIndex);\n\n // Focus the new instruction after render\n setTimeout(() => {\n const textareas = document.querySelectorAll(\".instructions-container textarea\");\n const newTextarea = textareas[textareas.length - 1] as HTMLTextAreaElement;\n if (newTextarea) {\n newTextarea.focus();\n }\n }, 50);\n };\n\n const updateInstruction = (index: number, value: string) => {\n const updatedInstructions = [...recipe.instructions];\n updatedInstructions[index] = value;\n updateRecipe({ instructions: updatedInstructions });\n };\n\n const removeInstruction = (index: number) => {\n const updatedInstructions = [...recipe.instructions];\n updatedInstructions.splice(index, 1);\n updateRecipe({ instructions: updatedInstructions });\n };\n\n // Simplified icon handler that defaults to a fork/knife for any problematic icons\n const getProperIcon = (icon: string | undefined): string => {\n // If icon is undefined return the default\n if (!icon) {\n return \"🍴\";\n }\n\n return icon;\n };\n\n return (\n \n {/* Recipe Title */}\n
\n \n\n
\n
\n 🕒\n t.label === recipe.cooking_time)?.value || 3}\n onChange={handleCookingTimeChange}\n style={{\n backgroundImage:\n \"url(\\\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23555' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\\\")\",\n backgroundRepeat: \"no-repeat\",\n backgroundPosition: \"right 0px center\",\n backgroundSize: \"12px\",\n appearance: \"none\",\n WebkitAppearance: \"none\",\n }}\n >\n {cookingTimeValues.map((time) => (\n \n ))}\n \n
\n\n
\n 🏆\n \n {Object.values(SkillLevel).map((level) => (\n \n ))}\n \n
\n
\n
\n\n {/* Dietary Preferences */}\n
\n {changedKeysRef.current.includes(\"special_preferences\") && }\n

Dietary Preferences

\n
\n {Object.values(SpecialPreferences).map((option) => (\n \n ))}\n
\n
\n\n {/* Ingredients */}\n
\n {changedKeysRef.current.includes(\"ingredients\") && }\n
\n

Ingredients

\n \n + Add Ingredient\n \n
\n
\n {recipe.ingredients.map((ingredient, index) => (\n
\n
{getProperIcon(ingredient.icon)}
\n
\n updateIngredient(index, \"name\", e.target.value)}\n placeholder=\"Ingredient name\"\n className=\"ingredient-name-input\"\n />\n updateIngredient(index, \"amount\", e.target.value)}\n placeholder=\"Amount\"\n className=\"ingredient-amount-input\"\n />\n
\n removeIngredient(index)}\n aria-label=\"Remove ingredient\"\n >\n ×\n \n
\n ))}\n
\n
\n\n {/* Instructions */}\n
\n {changedKeysRef.current.includes(\"instructions\") && }\n
\n

Instructions

\n \n
\n
\n {recipe.instructions.map((instruction, index) => (\n
\n {/* Number Circle */}\n
{index + 1}
\n\n {/* Vertical Line */}\n {index < recipe.instructions.length - 1 &&
}\n\n {/* Instruction Content */}\n setEditingInstructionIndex(index)}\n >\n updateInstruction(index, e.target.value)}\n placeholder={!instruction ? \"Enter cooking instruction...\" : \"\"}\n onFocus={() => setEditingInstructionIndex(index)}\n onBlur={(e) => {\n // Only blur if clicking outside this instruction\n if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) {\n setEditingInstructionIndex(null);\n }\n }}\n />\n\n {/* Delete Button (only visible on hover) */}\n {\n e.stopPropagation(); // Prevent triggering parent onClick\n removeInstruction(index);\n }}\n aria-label=\"Remove instruction\"\n >\n ×\n \n
\n
\n ))}\n
\n
\n\n {/* Improve with AI Button */}\n
\n {\n if (!isLoading) {\n agent.addMessage({\n id: crypto.randomUUID(),\n role: \"user\",\n content: \"Improve the recipe\",\n });\n copilotkit.runAgent({ agent });\n }\n }}\n disabled={isLoading}\n >\n {isLoading ? \"Please Wait...\" : \"Improve with AI\"}\n \n
\n \n );\n}\n\nfunction Ping() {\n return (\n \n \n \n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "/* Recipe App Styles */\n.app-container {\n min-height: 100vh;\n width: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n background-size: cover;\n background-position: center;\n background-repeat: no-repeat;\n background-attachment: fixed;\n position: relative;\n overflow: auto;\n}\n\n.recipe-card {\n background-color: rgba(255, 255, 255, 0.97);\n border-radius: 16px;\n box-shadow: 0 15px 30px rgba(0, 0, 0, 0.25), 0 5px 15px rgba(0, 0, 0, 0.15);\n width: 100%;\n max-width: 750px;\n margin: 20px auto;\n padding: 14px 32px;\n position: relative;\n z-index: 1;\n backdrop-filter: blur(5px);\n border: 1px solid rgba(255, 255, 255, 0.3);\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n animation: fadeIn 0.5s ease-out forwards;\n box-sizing: border-box;\n overflow: hidden;\n}\n\n.recipe-card:hover {\n transform: translateY(-5px);\n box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), 0 10px 20px rgba(0, 0, 0, 0.2);\n}\n\n/* Recipe Header */\n.recipe-header {\n margin-bottom: 24px;\n}\n\n.recipe-title-input {\n width: 100%;\n font-size: 24px;\n font-weight: bold;\n border: none;\n outline: none;\n padding: 8px 0;\n margin-bottom: 0px;\n}\n\n.recipe-meta {\n display: flex;\n align-items: center;\n gap: 20px;\n margin-top: 5px;\n margin-bottom: 14px;\n}\n\n.meta-item {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #555;\n}\n\n.meta-icon {\n font-size: 20px;\n color: #777;\n}\n\n.meta-text {\n font-size: 15px;\n}\n\n/* Recipe Meta Selects */\n.meta-item select {\n border: none;\n background: transparent;\n font-size: 15px;\n color: #555;\n cursor: pointer;\n outline: none;\n padding-right: 18px;\n transition: color 0.2s, transform 0.1s;\n font-weight: 500;\n}\n\n.meta-item select:hover,\n.meta-item select:focus {\n color: #FF5722;\n}\n\n.meta-item select:active {\n transform: scale(0.98);\n}\n\n.meta-item select option {\n color: #333;\n background-color: white;\n font-weight: normal;\n padding: 8px;\n}\n\n/* Section Container */\n.section-container {\n margin-bottom: 20px;\n position: relative;\n width: 100%;\n}\n\n.section-title {\n font-size: 20px;\n font-weight: 700;\n margin-bottom: 20px;\n color: #333;\n position: relative;\n display: inline-block;\n}\n\n.section-title:after {\n content: \"\";\n position: absolute;\n bottom: -8px;\n left: 0;\n width: 40px;\n height: 3px;\n background-color: #ff7043;\n border-radius: 3px;\n}\n\n/* Dietary Preferences */\n.dietary-options {\n display: flex;\n flex-wrap: wrap;\n gap: 10px 16px;\n margin-bottom: 16px;\n width: 100%;\n}\n\n.dietary-option {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n cursor: pointer;\n margin-bottom: 4px;\n}\n\n.dietary-option input {\n cursor: pointer;\n}\n\n/* Ingredients */\n.ingredients-container {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n margin-bottom: 15px;\n width: 100%;\n box-sizing: border-box;\n}\n\n.ingredient-card {\n display: flex;\n align-items: center;\n background-color: rgba(255, 255, 255, 0.9);\n border-radius: 12px;\n padding: 12px;\n margin-bottom: 10px;\n box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);\n position: relative;\n transition: all 0.2s ease;\n border: 1px solid rgba(240, 240, 240, 0.8);\n width: calc(33.333% - 7px);\n box-sizing: border-box;\n}\n\n.ingredient-card:hover {\n transform: translateY(-2px);\n box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12);\n}\n\n.ingredient-card .remove-button {\n position: absolute;\n right: 10px;\n top: 10px;\n background: none;\n border: none;\n color: #ccc;\n font-size: 16px;\n cursor: pointer;\n display: none;\n padding: 0;\n width: 24px;\n height: 24px;\n line-height: 1;\n}\n\n.ingredient-card:hover .remove-button {\n display: block;\n}\n\n.ingredient-icon {\n font-size: 24px;\n margin-right: 12px;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n background-color: #f7f7f7;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.ingredient-content {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 3px;\n min-width: 0;\n}\n\n.ingredient-name-input,\n.ingredient-amount-input {\n border: none;\n background: transparent;\n outline: none;\n width: 100%;\n padding: 0;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n}\n\n.ingredient-name-input {\n font-weight: 500;\n font-size: 14px;\n}\n\n.ingredient-amount-input {\n font-size: 13px;\n color: #666;\n}\n\n.ingredient-name-input::placeholder,\n.ingredient-amount-input::placeholder {\n color: #aaa;\n}\n\n.remove-button {\n background: none;\n border: none;\n color: #999;\n font-size: 20px;\n cursor: pointer;\n padding: 0;\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-left: 10px;\n}\n\n.remove-button:hover {\n color: #FF5722;\n}\n\n/* Instructions */\n.instructions-container {\n display: flex;\n flex-direction: column;\n gap: 6px;\n position: relative;\n margin-bottom: 12px;\n width: 100%;\n}\n\n.instruction-item {\n position: relative;\n display: flex;\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n align-items: flex-start;\n}\n\n.instruction-number {\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 26px;\n height: 26px;\n background-color: #ff7043;\n color: white;\n border-radius: 50%;\n font-weight: 600;\n flex-shrink: 0;\n box-shadow: 0 2px 4px rgba(255, 112, 67, 0.3);\n z-index: 1;\n font-size: 13px;\n margin-top: 2px;\n}\n\n.instruction-line {\n position: absolute;\n left: 13px; /* Half of the number circle width */\n top: 22px;\n bottom: -18px;\n width: 2px;\n background: linear-gradient(to bottom, #ff7043 60%, rgba(255, 112, 67, 0.4));\n z-index: 0;\n}\n\n.instruction-content {\n background-color: white;\n border-radius: 10px;\n padding: 10px 14px;\n margin-left: 12px;\n flex-grow: 1;\n transition: all 0.2s ease;\n box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);\n border: 1px solid rgba(240, 240, 240, 0.8);\n position: relative;\n width: calc(100% - 38px);\n box-sizing: border-box;\n display: flex;\n align-items: center;\n}\n\n.instruction-content-editing {\n background-color: #fff9f6;\n box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12), 0 0 0 2px rgba(255, 112, 67, 0.2);\n}\n\n.instruction-content:hover {\n transform: translateY(-2px);\n box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);\n}\n\n.instruction-textarea {\n width: 100%;\n background: transparent;\n border: none;\n resize: vertical;\n font-family: inherit;\n font-size: 14px;\n line-height: 1.4;\n min-height: 20px;\n outline: none;\n padding: 0;\n margin: 0;\n}\n\n.instruction-delete-btn {\n position: absolute;\n background: none;\n border: none;\n color: #ccc;\n font-size: 16px;\n cursor: pointer;\n display: none;\n padding: 0;\n width: 20px;\n height: 20px;\n line-height: 1;\n top: 50%;\n transform: translateY(-50%);\n right: 8px;\n}\n\n.instruction-content:hover .instruction-delete-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n/* Action Button */\n.action-container {\n display: flex;\n justify-content: center;\n margin-top: 40px;\n padding-bottom: 20px;\n position: relative;\n}\n\n.improve-button {\n background-color: #ff7043;\n border: none;\n color: white;\n border-radius: 30px;\n font-size: 18px;\n font-weight: 600;\n padding: 14px 28px;\n cursor: pointer;\n transition: all 0.3s ease;\n box-shadow: 0 4px 15px rgba(255, 112, 67, 0.4);\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n position: relative;\n min-width: 180px;\n}\n\n.improve-button:hover {\n background-color: #ff5722;\n transform: translateY(-2px);\n box-shadow: 0 8px 20px rgba(255, 112, 67, 0.5);\n}\n\n.improve-button.loading {\n background-color: #ff7043;\n opacity: 0.8;\n cursor: not-allowed;\n padding-left: 42px; /* Reduced padding to bring text closer to icon */\n padding-right: 22px; /* Balance the button */\n justify-content: flex-start; /* Left align text for better alignment with icon */\n}\n\n.improve-button.loading:after {\n content: \"\"; /* Add space between icon and text */\n display: inline-block;\n width: 8px; /* Width of the space */\n}\n\n.improve-button:before {\n content: \"\";\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83'/%3E%3C/svg%3E\");\n width: 20px; /* Slightly smaller icon */\n height: 20px;\n background-repeat: no-repeat;\n background-size: contain;\n position: absolute;\n left: 16px; /* Slightly adjusted */\n top: 50%;\n transform: translateY(-50%);\n display: none;\n}\n\n.improve-button.loading:before {\n display: block;\n animation: spin 1.5s linear infinite;\n}\n\n@keyframes spin {\n 0% { transform: translateY(-50%) rotate(0deg); }\n 100% { transform: translateY(-50%) rotate(360deg); }\n}\n\n/* Ping Animation */\n.ping-animation {\n position: absolute;\n display: flex;\n width: 12px;\n height: 12px;\n top: 0;\n right: 0;\n}\n\n.ping-circle {\n position: absolute;\n display: inline-flex;\n width: 100%;\n height: 100%;\n border-radius: 50%;\n background-color: #38BDF8;\n opacity: 0.75;\n animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;\n}\n\n.ping-dot {\n position: relative;\n display: inline-flex;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background-color: #0EA5E9;\n}\n\n@keyframes ping {\n 75%, 100% {\n transform: scale(2);\n opacity: 0;\n }\n}\n\n/* Instruction hover effects */\n.instruction-item:hover .instruction-delete-btn {\n display: flex !important;\n}\n\n/* Add some subtle animations */\n@keyframes fadeIn {\n from { opacity: 0; transform: translateY(20px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n/* Better center alignment for the recipe card */\n.recipe-card-container {\n display: flex;\n justify-content: center;\n width: 100%;\n position: relative;\n z-index: 1;\n margin: 0 auto;\n box-sizing: border-box;\n}\n\n/* Add Buttons */\n.add-button {\n background-color: transparent;\n color: #FF5722;\n border: 1px dashed #FF5722;\n border-radius: 8px;\n padding: 10px 16px;\n cursor: pointer;\n font-weight: 500;\n display: inline-block;\n font-size: 14px;\n margin-bottom: 0;\n}\n\n.add-step-button {\n background-color: transparent;\n color: #FF5722;\n border: 1px dashed #FF5722;\n border-radius: 6px;\n padding: 6px 12px;\n cursor: pointer;\n font-weight: 500;\n font-size: 13px;\n}\n\n/* Section Headers */\n.section-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 12px;\n}", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🍳 Shared State Recipe Creator\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **shared state** functionality - a powerful\nfeature that enables bidirectional data flow between:\n\n1. **Frontend → Agent**: UI controls update the agent's context in real-time\n2. **Agent → Frontend**: The Copilot's recipe creations instantly update the UI\n components\n\nIt's like having a cooking buddy who not only listens to what you want but also\nupdates your recipe card as you chat - no refresh needed! ✨\n\n## How to Interact\n\nMix and match any of these parameters (or none at all - it's up to you!):\n\n- **Skill Level**: Beginner to expert 👨‍🍳\n- **Cooking Time**: Quick meals or slow cooking ⏱️\n- **Special Preferences**: Dietary needs, flavor profiles, health goals 🥗\n- **Ingredients**: Items you want to include 🧅🥩🍄\n- **Instructions**: Any specific steps\n\nThen chat with your Copilot chef with prompts like:\n\n- \"I'm a beginner cook. Can you make me a quick dinner?\"\n- \"I need something spicy with chicken that takes under 30 minutes!\"\n\n## ✨ Shared State Magic in Action\n\n**What's happening technically:**\n\n- The UI and Copilot agent share the same state object (**Agent State = UI\n State**)\n- Changes from either side automatically update the other\n- Neither side needs to manually request updates from the other\n\n**What you'll see in this demo:**\n\n- Set cooking time to 20 minutes in the UI and watch the Copilot immediately\n respect your time constraint\n- Add ingredients through the UI and see them appear in your recipe\n- When the Copilot suggests new ingredients, watch them automatically appear in\n the UI ingredients list\n- Change your skill level and see how the Copilot adapts its instructions in\n real-time\n\nThis synchronized state creates a seamless experience where the agent always has\nyour current preferences, and any updates to the recipe are instantly reflected\nin both places.\n\nThis shared state pattern can be applied to any application where you want your\nUI and Copilot to work together in perfect harmony!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "shared_state.py", + "content": "\"\"\"\nA demo of shared state between the agent and CopilotKit.\n\"\"\"\n\nimport json\nfrom enum import Enum\nfrom typing import List, Optional\nfrom litellm import acompletion\nfrom pydantic import BaseModel, Field\nfrom crewai.flow.flow import Flow, start, router, listen\nfrom ..sdk import (\n copilotkit_stream, \n copilotkit_predict_state,\n CopilotKitState\n)\n\nclass SkillLevel(str, Enum):\n \"\"\"\n The level of skill required for the recipe.\n \"\"\"\n BEGINNER = \"Beginner\"\n INTERMEDIATE = \"Intermediate\"\n ADVANCED = \"Advanced\"\n\nclass CookingTime(str, Enum):\n \"\"\"\n The cooking time of the recipe.\n \"\"\"\n FIVE_MIN = \"5 min\"\n FIFTEEN_MIN = \"15 min\"\n THIRTY_MIN = \"30 min\"\n FORTY_FIVE_MIN = \"45 min\"\n SIXTY_PLUS_MIN = \"60+ min\"\n\nclass Ingredient(BaseModel):\n \"\"\"\n An ingredient with its details.\n \"\"\"\n icon: str = Field(..., description=\"Emoji icon representing the ingredient.\")\n name: str = Field(..., description=\"Name of the ingredient.\")\n amount: str = Field(..., description=\"Amount or quantity of the ingredient.\")\n\nGENERATE_RECIPE_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"generate_recipe\",\n \"description\": \" \".join(\"\"\"Generate or modify an existing recipe. \n When creating a new recipe, specify all fields. \n When modifying, only fill optional fields if they need changes; \n otherwise, leave them empty.\"\"\".split()),\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"recipe\": {\n \"description\": \"The recipe object containing all details.\",\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"The title of the recipe.\"\n },\n \"skill_level\": {\n \"type\": \"string\",\n \"enum\": [level.value for level in SkillLevel],\n \"description\": \"The skill level required for the recipe.\"\n },\n \"special_preferences\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"A list of dietary preferences (e.g., Vegetarian, Gluten-free).\"\n },\n \"cooking_time\": {\n \"type\": \"string\",\n \"enum\": [time.value for time in CookingTime],\n \"description\": \"The estimated cooking time for the recipe.\"\n },\n \"ingredients\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"icon\": {\"type\": \"string\", \"description\": \"Emoji icon for the ingredient.\"},\n \"name\": {\"type\": \"string\", \"description\": \"Name of the ingredient.\"},\n \"amount\": {\"type\": \"string\", \"description\": \"Amount/quantity of the ingredient.\"}\n },\n \"required\": [\"icon\", \"name\", \"amount\"]\n },\n \"description\": \"A list of ingredients required for the recipe.\"\n },\n \"instructions\": {\n \"type\": \"array\",\n \"items\": {\"type\": \"string\"},\n \"description\": \"Step-by-step instructions for preparing the recipe.\"\n }\n },\n \"required\": [\"title\", \"skill_level\", \"cooking_time\", \"special_preferences\", \"ingredients\", \"instructions\"]\n }\n },\n \"required\": [\"recipe\"]\n }\n }\n}\n\nclass Recipe(BaseModel):\n \"\"\"\n A recipe.\n \"\"\"\n title: str\n skill_level: SkillLevel\n special_preferences: List[str] = Field(default_factory=list)\n cooking_time: CookingTime\n ingredients: List[Ingredient] = Field(default_factory=list)\n instructions: List[str] = Field(default_factory=list)\n\n\nclass AgentState(CopilotKitState):\n \"\"\"\n The state of the recipe.\n \"\"\"\n recipe: Optional[Recipe] = None\n\nclass SharedStateFlow(Flow[AgentState]):\n \"\"\"\n This is a sample flow that demonstrates shared state between the agent and CopilotKit.\n \"\"\"\n\n @start()\n @listen(\"route_follow_up\")\n async def start_flow(self):\n \"\"\"\n This is the entry point for the flow.\n \"\"\"\n\n @router(start_flow)\n async def chat(self):\n \"\"\"\n Standard chat node.\n \"\"\"\n \n recipe_json = (\n self.state.recipe.model_dump_json(indent=2)\n if self.state.recipe is not None\n else \"{}\"\n )\n system_prompt = f\"\"\"You are a helpful assistant for creating recipes.\n This is the current state of the recipe: {recipe_json}\n You can improve the recipe by calling the generate_recipe tool.\n\n IMPORTANT:\n 1. Create a recipe using the existing ingredients and instructions. Make sure the recipe is complete.\n 2. The recipe MUST comply with the selected dietary preferences (special_preferences). If an existing ingredient violates a selected preference (for example butter or Parmesan cheese when \"Vegan\" is selected), REPLACE it with a compliant alternative (e.g. olive oil, a plant-based butter, nutritional yeast) or remove it, and update the affected instructions to match.\n 3. Keep the selected special_preferences in the recipe you return, and keep every existing ingredient and instruction that already complies, appending any new ones.\n 4. 'ingredients' is always an array of objects with 'icon', 'name', and 'amount' fields\n 5. 'instructions' is always an array of strings\n 6. For the 'icon' field in ingredients, ALWAYS use actual Unicode emoji characters (like 🥕 🍅 🧅 🥖 🧈 🥛 🧂 etc.), NEVER use text, ANSI codes, or placeholders\n\n If you have just created or modified the recipe, just answer in one sentence what you did. dont describe the recipe, just say what you did.\n \"\"\"\n\n # 1. Here we specify that we want to stream the tool call to generate_recipe\n # to the frontend as state.\n await copilotkit_predict_state({\n \"recipe\": {\n \"tool_name\": \"generate_recipe\",\n \"tool_argument\": \"recipe\"\n }\n })\n\n # 2. Run the model and stream the response\n # Note: In order to stream the response, wrap the completion call in\n # copilotkit_stream and set stream=True.\n response = await copilotkit_stream(\n await acompletion(\n\n # 2.1 Specify the model to use\n model=\"openai/gpt-5.4\",\n messages=[\n {\n \"role\": \"system\", \n \"content\": system_prompt\n },\n *self.state.messages\n ],\n\n # 2.2 Bind the tools to the model\n tools=[\n *self.state.copilotkit.actions,\n GENERATE_RECIPE_TOOL\n ],\n\n # 2.3 Disable parallel tool calls to avoid race conditions,\n # enable this for faster performance if you want to manage\n # the complexity of running tool calls in parallel.\n parallel_tool_calls=False,\n stream=True\n )\n )\n\n message = response.choices[0].message\n\n # 3. Append the message to the messages in state\n self.state.messages.append(message)\n\n # 4. Handle tool call\n if message.get(\"tool_calls\"):\n tool_call = message[\"tool_calls\"][0]\n tool_call_id = tool_call[\"id\"]\n tool_call_name = tool_call[\"function\"][\"name\"]\n tool_call_args = json.loads(tool_call[\"function\"][\"arguments\"])\n\n if tool_call_name == \"generate_recipe\":\n # Attempt to update the recipe state using the data from the tool call\n try:\n updated_recipe_data = tool_call_args[\"recipe\"]\n # Validate and update the state. Pydantic will raise an error if the structure is wrong.\n self.state.recipe = Recipe(**updated_recipe_data)\n\n # 4.1 Append the result to the messages in state\n self.state.messages.append({\n \"role\": \"tool\",\n \"content\": \"Recipe updated.\", # More accurate message\n \"tool_call_id\": tool_call_id\n })\n return \"route_follow_up\"\n except Exception: # pylint: disable=broad-exception-caught\n # Handle validation or other errors during update\n # Optionally inform the user via a tool message, though it might be noisy\n # self.state.messages.append({\"role\": \"tool\", \"content\": f\"Error processing recipe update: {e}\", \"tool_call_id\": tool_call_id})\n return \"route_end\" # End the flow on error for now\n\n # 5. If our tool was not called, return to the end route\n return \"route_end\"\n\n @listen(\"route_end\")\n async def end(self):\n \"\"\"\n End the flow.\n \"\"\"\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::tool_based_generative_ui": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport { \n useFrontendTool,\n useConfigureSuggestions,\n CopilotSidebar,\n} from \"@copilotkit/react-core/v2\";\nimport { z } from \"zod\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n CarouselNext,\n CarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\n\ninterface ToolBasedGenerativeUIProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\ninterface Haiku {\n japanese: string[];\n english: string[];\n image_name: string | null;\n gradient: string;\n}\n\nexport default function ToolBasedGenerativeUI({ params }: ToolBasedGenerativeUIProps) {\n const { integrationId } = React.use(params);\n const { chatDefaultOpen } = useURLParams();\n\n return (\n \n \n \n \n );\n}\n\nfunction SidebarWithSuggestions({ defaultOpen }: { defaultOpen: boolean }) {\n useConfigureSuggestions({\n suggestions: [\n { title: \"Nature Haiku\", message: \"Write me a haiku about nature.\" },\n { title: \"Ocean Haiku\", message: \"Create a haiku about the ocean.\" },\n { title: \"Spring Haiku\", message: \"Generate a haiku about spring.\" },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nconst VALID_IMAGE_NAMES = [\n \"Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg\",\n \"Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg\",\n \"Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg\",\n \"Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg\",\n \"Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg\",\n \"Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg\",\n \"Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg\",\n \"Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg\",\n \"Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg\",\n \"Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg\",\n];\n\nfunction HaikuDisplay() {\n const [activeIndex, setActiveIndex] = useState(0);\n const [haikus, setHaikus] = useState([\n {\n japanese: [\"仮の句よ\", \"まっさらながら\", \"花を呼ぶ\"],\n english: [\"A placeholder verse—\", \"even in a blank canvas,\", \"it beckons flowers.\"],\n image_name: null,\n gradient: \"\",\n },\n ]);\n\n useFrontendTool(\n {\n agentId: \"tool_based_generative_ui\",\n name: \"generate_haiku\",\n parameters: z.object({\n japanese: z.array(z.string()).describe(\"3 lines of haiku in Japanese\"),\n english: z.array(z.string()).describe(\"3 lines of haiku translated to English\"),\n image_name: z.string().describe(`One relevant image name from: ${VALID_IMAGE_NAMES.join(\", \")}`),\n gradient: z.string().describe(\"CSS Gradient color for the background\"),\n }) ,\n followUp: false,\n handler: async ({ japanese, english, image_name, gradient }: { japanese: string[]; english: string[]; image_name: string; gradient: string }) => {\n const newHaiku: Haiku = {\n japanese: japanese || [],\n english: english || [],\n image_name: image_name || null,\n gradient: gradient || \"\",\n };\n setHaikus((prev) => [\n newHaiku,\n ...prev.filter((h) => h.english[0] !== \"A placeholder verse—\"),\n ]);\n setActiveIndex(0);\n return \"Haiku generated!\";\n },\n render: ({ args }: { args: Partial }) => {\n if (!args.japanese) return <>;\n return ;\n },\n },\n [haikus],\n );\n\n const currentHaiku = haikus[activeIndex];\n\n return (\n
\n
\n \n \n {haikus.map((haiku, index) => (\n \n \n \n ))}\n \n {haikus.length > 1 && (\n <>\n \n \n \n )}\n \n
\n
\n );\n}\n\nfunction HaikuCard({ haiku }: { haiku: Partial }) {\n return (\n \n {/* Decorative background elements */}\n
\n
\n\n {/* Haiku Text */}\n
\n {haiku.japanese?.map((line, index) => (\n \n \n {line}\n

\n \n {haiku.english?.[index]}\n

\n
\n ))}\n
\n\n {/* Image */}\n {haiku.image_name && (\n
\n
\n \n
\n
\n
\n )}\n
\n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": ".page-background {\n /* Darker gradient background */\n background: linear-gradient(170deg, #e9ecef 0%, #ced4da 100%);\n}\n\n@keyframes fade-scale-in {\n from {\n opacity: 0;\n transform: translateY(10px) scale(0.98);\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n/* Updated card entry animation */\n@keyframes pop-in {\n 0% {\n opacity: 0;\n transform: translateY(15px) scale(0.95);\n }\n 70% {\n opacity: 1;\n transform: translateY(-2px) scale(1.02);\n }\n 100% {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n/* Animation for subtle background gradient movement */\n@keyframes animated-gradient {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n}\n\n/* Animation for flash effect on apply */\n@keyframes flash-border-glow {\n 0% {\n /* Start slightly intensified */\n border-top-color: #ff5b4a !important;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07),\n inset 0 1px 2px rgba(0, 0, 0, 0.01),\n 0 0 25px rgba(255, 91, 74, 0.5);\n }\n 50% {\n /* Peak intensity */\n border-top-color: #ff4733 !important;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08),\n inset 0 1px 2px rgba(0, 0, 0, 0.01),\n 0 0 35px rgba(255, 71, 51, 0.7);\n }\n 100% {\n /* Return to default state appearance */\n border-top-color: #ff6f61 !important;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07),\n inset 0 1px 2px rgba(0, 0, 0, 0.01),\n 0 0 10px rgba(255, 111, 97, 0.15);\n }\n}\n\n/* Existing animation for haiku lines */\n@keyframes fade-slide-in {\n from {\n opacity: 0;\n transform: translateX(-15px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n}\n\n.animated-fade-in {\n /* Use the new pop-in animation */\n animation: pop-in 0.6s ease-out forwards;\n}\n\n.haiku-card {\n /* Subtle animated gradient background */\n background: linear-gradient(120deg, #ffffff 0%, #fdfdfd 50%, #ffffff 100%);\n background-size: 200% 200%;\n animation: animated-gradient 10s ease infinite;\n\n /* === Explicit Border Override Attempt === */\n /* 1. Set the default grey border for all sides */\n border: 1px solid #dee2e6;\n\n /* 2. Explicitly override the top border immediately after */\n border-top: 10px solid #ff6f61 !important; /* Orange top - Added !important */\n /* === End Explicit Border Override Attempt === */\n\n padding: 2.5rem 3rem;\n border-radius: 20px;\n\n /* Default glow intensity */\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07),\n inset 0 1px 2px rgba(0, 0, 0, 0.01),\n 0 0 15px rgba(255, 111, 97, 0.25);\n text-align: left;\n max-width: 745px;\n margin: 3rem auto;\n min-width: 600px;\n\n /* Transition */\n transition: transform 0.35s ease, box-shadow 0.35s ease, border-top-width 0.35s ease, border-top-color 0.35s ease;\n}\n\n.haiku-card:hover {\n transform: translateY(-8px) scale(1.03);\n /* Enhanced shadow + Glow */\n box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1),\n inset 0 1px 2px rgba(0, 0, 0, 0.01),\n 0 0 25px rgba(255, 91, 74, 0.5);\n /* Modify only top border properties */\n border-top-width: 14px !important; /* Added !important */\n border-top-color: #ff5b4a !important; /* Added !important */\n}\n\n.haiku-card .flex {\n margin-bottom: 1.5rem;\n}\n\n.haiku-card .flex.haiku-line { /* Target the lines specifically */\n margin-bottom: 1.5rem;\n opacity: 0; /* Start hidden for animation */\n animation: fade-slide-in 0.5s ease-out forwards;\n /* animation-delay is set inline in page.tsx */\n}\n\n/* Remove previous explicit color overrides - rely on Tailwind */\n/* .haiku-card p.text-4xl {\n color: #212529;\n}\n\n.haiku-card p.text-base {\n color: #495057;\n} */\n\n.haiku-card.applied-flash {\n /* Apply the flash animation once */\n /* Note: animation itself has !important on border-top-color */\n animation: flash-border-glow 0.6s ease-out forwards;\n}\n\n/* Styling for images within the main haiku card */\n.haiku-card-image {\n width: 9.5rem; /* Increased size (approx w-48) */\n height: 9.5rem; /* Increased size (approx h-48) */\n object-fit: cover;\n border-radius: 1.5rem; /* rounded-xl */\n border: 1px solid #e5e7eb;\n /* Enhanced shadow with subtle orange hint */\n box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1),\n 0 3px 6px rgba(0, 0, 0, 0.08),\n 0 0 10px rgba(255, 111, 97, 0.2);\n /* Inherit animation delay from inline style */\n animation-name: fadeIn;\n animation-duration: 0.5s;\n animation-fill-mode: both;\n}\n\n/* Styling for images within the suggestion card */\n.suggestion-card-image {\n width: 6.5rem; /* Increased slightly (w-20) */\n height: 6.5rem; /* Increased slightly (h-20) */\n object-fit: cover;\n border-radius: 1rem; /* Equivalent to rounded-md */\n border: 1px solid #d1d5db; /* Equivalent to border (using Tailwind gray-300) */\n margin-top: 0.5rem;\n /* Added shadow for suggestion images */\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1),\n 0 2px 4px rgba(0, 0, 0, 0.06);\n transition: all 0.2s ease-in-out; /* Added for smooth deselection */\n}\n\n/* Styling for the focused suggestion card image */\n.suggestion-card-image-focus {\n width: 6.5rem;\n height: 6.5rem;\n object-fit: cover;\n border-radius: 1rem;\n margin-top: 0.5rem;\n /* Highlight styles */\n border: 2px solid #ff6f61; /* Thicker, themed border */\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1), /* Base shadow for depth */\n 0 0 12px rgba(255, 111, 97, 0.6); /* Orange glow */\n transform: scale(1.05); /* Slightly scale up */\n transition: all 0.2s ease-in-out; /* Smooth transition for focus */\n}\n\n/* Styling for the suggestion card container in the sidebar */\n.suggestion-card {\n border: 1px solid #dee2e6; /* Same default border as haiku-card */\n border-top: 10px solid #ff6f61; /* Same orange top border */\n border-radius: 0.375rem; /* Default rounded-md */\n /* Note: background-color is set by Tailwind bg-gray-100 */\n /* Other styles like padding, margin, flex are handled by Tailwind */\n}\n\n.suggestion-image-container {\n display: flex;\n gap: 1rem;\n justify-content: space-between;\n width: 100%;\n height: 6.5rem;\n}\n\n/* Mobile responsive styles - matches useMobileView hook breakpoint */\n@media (max-width: 767px) {\n .haiku-card {\n padding: 1rem 1.5rem; /* Reduced from 2.5rem 3rem */\n min-width: auto; /* Remove min-width constraint */\n max-width: 100%; /* Full width on mobile */\n margin: 1rem auto; /* Reduced margin */\n }\n\n .haiku-card-image {\n width: 5.625rem; /* 90px - smaller on mobile */\n height: 5.625rem; /* 90px - smaller on mobile */\n }\n\n .suggestion-card-image {\n width: 5rem; /* Slightly smaller on mobile */\n height: 5rem; /* Slightly smaller on mobile */\n }\n\n .suggestion-card-image-focus {\n width: 5rem; /* Slightly smaller on mobile */\n height: 5rem; /* Slightly smaller on mobile */\n }\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# 🪶 Tool-Based Generative UI Haiku Creator\n\n## What This Demo Shows\n\nThis demo showcases CopilotKit's **tool-based generative UI** capabilities:\n\n1. **Frontend Rendering of Tool Calls**: Backend tool calls are automatically\n rendered in the UI\n2. **Dynamic UI Generation**: The UI updates in real-time as the agent generates\n content\n3. **Elegant Content Presentation**: Complex structured data (haikus) are\n beautifully displayed\n\n## How to Interact\n\nChat with your Copilot and ask for haikus about different topics:\n\n- \"Create a haiku about nature\"\n- \"Write a haiku about technology\"\n- \"Generate a haiku about the changing seasons\"\n- \"Make a humorous haiku about programming\"\n\nEach request will trigger the agent to generate a haiku and display it in a\nvisually appealing card format in the UI.\n\n## ✨ Tool-Based Generative UI in Action\n\n**What's happening technically:**\n\n- The agent processes your request and determines it should create a haiku\n- It calls a backend tool that returns structured haiku data\n- CopilotKit automatically renders this tool call in the frontend\n- The rendering is handled by the registered tool component in your React app\n- No manual state management is required to display the results\n\n**What you'll see in this demo:**\n\n- As you request a haiku, a beautifully formatted card appears in the UI\n- The haiku follows the traditional 5-7-5 syllable structure\n- Each haiku is presented with consistent styling\n- Multiple haikus can be generated in sequence\n- The UI adapts to display each new piece of content\n\nThis pattern of tool-based generative UI can be extended to create any kind of\ndynamic content - from data visualizations to interactive components, all driven\nby your Copilot's tool calls!\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "tool_based_generative_ui.py", + "content": "\"\"\"\nAn example demonstrating tool-based generative UI.\n\nThe ``generate_haiku`` tool is defined on the FRONTEND (via ``useFrontendTool``):\nits handler renders the haiku onto the main canvas and picks the background\nimage and gradient. So the flow binds the frontend actions and lets the model\ncall that tool, rather than defining a backend tool of the same name (which would\nrender the chat card but never run the frontend handler that updates the canvas).\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\nfrom ..sdk import copilotkit_stream, CopilotKitState\n\n\nclass ToolBasedGenerativeUIFlow(Flow[CopilotKitState]):\n \"\"\"\n A flow that demonstrates tool-based generative UI.\n \"\"\"\n\n @start()\n async def chat(self):\n system_prompt = (\n \"Help the user write haikus. When the user asks for a haiku, call the \"\n \"generate_haiku tool to display it. Choose a fitting background image \"\n \"and gradient for the haiku's theme.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=\"openai/gpt-5.4\",\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n *self.state.messages,\n ],\n # Bind the frontend-provided tools (generate_haiku lives on the\n # frontend, so its handler updates the canvas when called).\n tools=[\n *self.state.copilotkit.actions,\n ],\n parallel_tool_calls=False,\n stream=True,\n )\n )\n\n self.state.messages.append(response.choices[0].message)\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::subgraphs": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n useAgent,\n UseAgentUpdate,\n useConfigureSuggestions,\n CopilotSidebar,\n CopilotChatConfigurationProvider,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit,\nuseLangGraphInterrupt } from \"@copilotkit/react-core\";\nimport { useMobileView } from \"@/utils/use-mobile-view\";\nimport { useMobileChat } from \"@/utils/use-mobile-chat\";\nimport { useURLParams } from \"@/contexts/url-params-context\";\n\ninterface SubgraphsProps {\n params: Promise<{\n integrationId: string;\n }>;\n}\n\n// Travel planning data types\ninterface Flight {\n airline: string;\n arrival: string;\n departure: string;\n duration: string;\n price: string;\n}\n\ninterface Hotel {\n location: string;\n name: string;\n price_per_night: string;\n rating: string;\n}\n\ninterface Experience {\n name: string;\n description: string;\n location: string;\n type: string;\n}\n\ninterface Itinerary {\n hotel?: Hotel;\n flight?: Flight;\n experiences?: Experience[];\n}\n\ntype AvailableAgents = 'flights' | 'hotels' | 'experiences' | 'supervisor'\n\ninterface TravelAgentState {\n experiences: Experience[],\n flights: Flight[],\n hotels: Hotel[],\n itinerary: Itinerary\n planning_step: string\n active_agent: AvailableAgents\n}\n\nconst INITIAL_STATE: TravelAgentState = {\n itinerary: {},\n experiences: [],\n flights: [],\n hotels: [],\n planning_step: \"start\",\n active_agent: 'supervisor'\n};\n\ninterface InterruptEvent {\n message: string;\n options: TAgent extends 'flights' ? Flight[] : TAgent extends 'hotels' ? Hotel[] : never,\n recommendation: TAgent extends 'flights' ? Flight : TAgent extends 'hotels' ? Hotel : never,\n agent: TAgent\n}\n\nfunction InterruptHumanInTheLoop({\n event,\n resolve,\n}: {\n event: { value: InterruptEvent };\n resolve: (value: string) => void;\n}) {\n const { message, options, agent, recommendation } = event.value;\n\n // Format agent name with emoji\n const formatAgentName = (agent: string) => {\n switch (agent) {\n case 'flights': return 'Flights Agent';\n case 'hotels': return 'Hotels Agent';\n case 'experiences': return 'Experiences Agent';\n default: return `${agent} Agent`;\n }\n };\n\n const handleOptionSelect = (option: any) => {\n resolve(JSON.stringify(option));\n };\n\n return (\n
\n

{formatAgentName(agent)}: {message}

\n\n
\n {options.map((opt, idx) => {\n if ('airline' in opt) {\n const isRecommended = (recommendation as Flight).airline === opt.airline;\n // Flight options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.airline}\n {opt.price}\n
\n
\n {opt.departure} → {opt.arrival}\n
\n
\n {opt.duration}\n
\n \n );\n }\n const isRecommended = (recommendation as Hotel).name === opt.name;\n\n // Hotel options\n return (\n handleOptionSelect(opt)}\n >\n {isRecommended && ⭐ Recommended}\n
\n {opt.name}\n {opt.rating}\n
\n
\n 📍 {opt.location}\n
\n
\n {opt.price_per_night}\n
\n \n );\n })}\n
\n
\n )\n}\n\nexport default function Subgraphs({ params }: SubgraphsProps) {\n const { integrationId } = React.use(params);\n const { isMobile } = useMobileView();\n const { chatDefaultOpen } = useURLParams();\n const defaultChatHeight = 50;\n const {\n isChatOpen,\n setChatHeight,\n setIsChatOpen,\n isDragging,\n chatHeight,\n handleDragStart\n } = useMobileChat(defaultChatHeight);\n\n const chatTitle = 'Travel Planning Assistant';\n const chatDescription = 'Plan your perfect trip with AI specialists';\n\n return (\n \n \n
\n \n {isMobile ? (\n <>\n {/* Chat Toggle Button */}\n
\n
\n {\n if (!isChatOpen) {\n setChatHeight(defaultChatHeight);\n }\n setIsChatOpen(!isChatOpen);\n }}\n >\n
\n
\n
{chatTitle}
\n
{chatDescription}
\n
\n
\n
\n \n \n \n
\n
\n
\n\n {/* Pull-Up Chat Container */}\n \n {/* Drag Handle Bar */}\n \n
\n
\n\n {/* Chat Header */}\n
\n
\n
\n

{chatTitle}

\n
\n setIsChatOpen(false)}\n className=\"p-2 hover:bg-gray-100 rounded-full transition-colors\"\n >\n \n \n \n \n
\n
\n\n {/* Chat Content */}\n
\n \n
\n \n\n {/* Backdrop */}\n {isChatOpen && (\n setIsChatOpen(false)}\n />\n )}\n \n ) : (\n \n )}\n \n \n \n );\n}\n\nfunction TravelPlanner() {\n const { isMobile } = useMobileView();\n const { agent } = useAgent({\n agentId: \"subgraphs\",\n updates: [UseAgentUpdate.OnStateChanged],\n });\n\n const agentState = agent.state as TravelAgentState | undefined;\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Plan a trip\",\n message: \"Plan a trip to Paris for 5 days.\",\n },\n {\n title: \"Find flights\",\n message: \"Find me flights to Tokyo.\",\n },\n {\n title: \"Explore experiences\",\n message: \"What are the best experiences in Barcelona?\",\n },\n ],\n available: \"always\",\n });\n\n // Set initial state on mount\n useEffect(() => {\n if (!agentState) {\n agent.setState(INITIAL_STATE);\n }\n }, []);\n\n useLangGraphInterrupt({\n render: ({ event, resolve }) => {\n // CrewAI suspends the whole flow, so the specialist's payload arrives under\n // metadata.crewai.output; LangGraph puts the raw value on event.value directly.\n const raw = (event.value ?? {}) as any;\n const value = raw?.metadata?.crewai?.output ?? raw;\n return ;\n },\n });\n\n // Current itinerary strip\n const ItineraryStrip = () => {\n const selectedFlight = agentState?.itinerary?.flight;\n const selectedHotel = agentState?.itinerary?.hotel;\n const hasExperiences = (agentState?.experiences?.length ?? 0) > 0;\n\n return (\n
\n
Current Itinerary:
\n
\n
\n 📍\n Amsterdam → San Francisco\n
\n {selectedFlight && (\n
\n ✈️\n {selectedFlight.airline} - {selectedFlight.price}\n
\n )}\n {selectedHotel && (\n
\n 🏨\n {selectedHotel.name}\n
\n )}\n {hasExperiences && (\n
\n 🎯\n {agentState?.experiences?.length ?? 0} experiences planned\n
\n )}\n
\n
\n );\n };\n\n // Compact agent status - read active_agent from state instead of nodeName\n const AgentStatus = () => {\n const activeAgent = agentState?.active_agent || 'supervisor';\n\n return (\n
\n
Active Agent:
\n
\n
\n 👨‍💼\n Supervisor\n
\n
\n ✈️\n Flights\n
\n
\n 🏨\n Hotels\n
\n
\n 🎯\n Experiences\n
\n
\n
\n )\n };\n\n // Travel details component\n const TravelDetails = () => (\n
\n
\n

✈️ Flight Options

\n
\n {(agentState?.flights?.length ?? 0) > 0 ? (\n agentState!.flights.map((flight, index) => (\n
\n {flight.airline}:\n {flight.departure} → {flight.arrival} ({flight.duration}) - {flight.price}\n
\n ))\n ) : (\n

No flights found yet

\n )}\n {agentState?.itinerary?.flight && (\n
\n Selected: {agentState.itinerary.flight.airline} - {agentState.itinerary.flight.price}\n
\n )}\n
\n
\n\n
\n

🏨 Hotel Options

\n
\n {(agentState?.hotels?.length ?? 0) > 0 ? (\n agentState!.hotels.map((hotel, index) => (\n
\n {hotel.name}:\n {hotel.location} - {hotel.price_per_night} ({hotel.rating})\n
\n ))\n ) : (\n

No hotels found yet

\n )}\n {agentState?.itinerary?.hotel && (\n
\n Selected: {agentState.itinerary.hotel.name} - {agentState.itinerary.hotel.price_per_night}\n
\n )}\n
\n
\n\n
\n

🎯 Experiences

\n
\n {(agentState?.experiences?.length ?? 0) > 0 ? (\n agentState!.experiences.map((experience, index) => (\n
\n
{experience.name}
\n
{experience.type}
\n
{experience.description}
\n
Location: {experience.location}
\n
\n ))\n ) : (\n

No experiences planned yet

\n )}\n
\n
\n
\n );\n\n return (\n
\n \n \n \n
\n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "/* Travel Planning Subgraphs Demo Styles */\n/* Essential styles that cannot be achieved with Tailwind classes */\n\n/* Main container with CopilotSidebar layout */\n.travel-planner-container {\n min-height: 100vh;\n padding: 2rem;\n background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);\n}\n\n/* Travel content area styles */\n.travel-content {\n max-width: 1200px;\n margin: 0 auto;\n padding: 0 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n/* Itinerary strip */\n.itinerary-strip {\n background: white;\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid #e5e7eb;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n}\n\n.itinerary-label {\n font-size: 0.875rem;\n font-weight: 600;\n color: #6b7280;\n margin-bottom: 0.5rem;\n}\n\n.itinerary-items {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n}\n\n.itinerary-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n background: #f9fafb;\n border-radius: 0.375rem;\n font-size: 0.875rem;\n}\n\n.item-icon {\n font-size: 1rem;\n}\n\n/* Agent status */\n.agent-status {\n background: white;\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid #e5e7eb;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n}\n\n.status-label {\n font-size: 0.875rem;\n font-weight: 600;\n color: #6b7280;\n margin-bottom: 0.5rem;\n}\n\n.agent-indicators {\n display: flex;\n gap: 0.75rem;\n}\n\n.agent-indicator {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-radius: 0.375rem;\n font-size: 0.875rem;\n background: #f9fafb;\n border: 1px solid #e5e7eb;\n transition: all 0.2s ease;\n}\n\n.agent-indicator.active {\n background: #dbeafe;\n border-color: #3b82f6;\n color: #1d4ed8;\n box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);\n}\n\n/* Travel details sections */\n.travel-details {\n background: white;\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid #e5e7eb;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n display: grid;\n gap: 1rem;\n}\n\n.details-section h4 {\n font-size: 1rem;\n font-weight: 600;\n color: #1f2937;\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.detail-items {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.detail-item {\n padding: 0.5rem;\n background: #f9fafb;\n border-radius: 0.25rem;\n font-size: 0.875rem;\n display: flex;\n justify-content: space-between;\n}\n\n.detail-item strong {\n color: #6b7280;\n font-weight: 500;\n}\n\n.detail-tips {\n padding: 0.5rem;\n background: #eff6ff;\n border-radius: 0.25rem;\n font-size: 0.75rem;\n color: #1d4ed8;\n}\n\n.activity-item {\n padding: 0.75rem;\n background: #f0f9ff;\n border-radius: 0.25rem;\n border-left: 2px solid #0ea5e9;\n}\n\n.activity-name {\n font-weight: 600;\n color: #1f2937;\n font-size: 0.875rem;\n margin-bottom: 0.25rem;\n}\n\n.activity-category {\n font-size: 0.75rem;\n color: #0ea5e9;\n margin-bottom: 0.25rem;\n}\n\n.activity-description {\n color: #4b5563;\n font-size: 0.75rem;\n margin-bottom: 0.25rem;\n}\n\n.activity-meta {\n font-size: 0.75rem;\n color: #6b7280;\n}\n\n.no-activities {\n text-align: center;\n color: #9ca3af;\n font-style: italic;\n padding: 1rem;\n font-size: 0.875rem;\n}\n\n/* Interrupt UI for Chat Sidebar (Generative UI) */\n.interrupt-container {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n max-width: 100%;\n padding-top: 34px;\n}\n\n.interrupt-header {\n margin-bottom: 0.5rem;\n}\n\n.agent-name {\n font-size: 0.875rem;\n font-weight: 600;\n color: #1f2937;\n margin: 0 0 0.25rem 0;\n}\n\n.agent-message {\n font-size: 0.75rem;\n color: #6b7280;\n margin: 0;\n line-height: 1.4;\n}\n\n.interrupt-options {\n padding: 0.75rem;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n max-height: 300px;\n overflow-y: auto;\n}\n\n.option-card {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.75rem;\n background: #f9fafb;\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n cursor: pointer;\n transition: all 0.2s ease;\n text-align: left;\n position: relative;\n min-height: auto;\n}\n\n.option-card:hover {\n background: #f3f4f6;\n border-color: #d1d5db;\n}\n\n.option-card:active {\n background: #e5e7eb;\n}\n\n.option-card.recommended {\n background: #eff6ff;\n border-color: #3b82f6;\n box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.1);\n}\n\n.option-card.recommended:hover {\n background: #dbeafe;\n}\n\n.recommendation-badge {\n position: absolute;\n top: -2px;\n right: -2px;\n background: #3b82f6;\n color: white;\n font-size: 0.625rem;\n padding: 0.125rem 0.375rem;\n border-radius: 0.75rem;\n font-weight: 500;\n}\n\n.option-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 0.125rem;\n}\n\n.airline-name, .hotel-name {\n font-weight: 600;\n font-size: 0.8rem;\n color: #1f2937;\n}\n\n.price, .rating {\n font-weight: 600;\n font-size: 0.75rem;\n color: #059669;\n}\n\n.route-info, .location-info {\n font-size: 0.7rem;\n color: #6b7280;\n margin-bottom: 0.125rem;\n}\n\n.duration-info, .price-info {\n font-size: 0.7rem;\n color: #9ca3af;\n}\n\n/* Mobile responsive adjustments */\n@media (max-width: 768px) {\n .travel-planner-container {\n padding: 0.5rem;\n padding-bottom: 120px; /* Space for mobile chat */\n }\n \n .travel-content {\n padding: 0;\n gap: 0.75rem;\n }\n \n .itinerary-items {\n flex-direction: column;\n gap: 0.5rem;\n }\n \n .agent-indicators {\n flex-direction: column;\n gap: 0.5rem;\n }\n \n .agent-indicator {\n padding: 0.75rem;\n }\n \n .travel-details {\n padding: 0.75rem;\n }\n\n .interrupt-container {\n padding: 0.5rem;\n }\n\n .option-card {\n padding: 0.625rem;\n }\n\n .interrupt-options {\n max-height: 250px;\n }\n}", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# LangGraph Subgraphs Demo: Travel Planning Assistant ✈️\n\nThis demo showcases **LangGraph subgraphs** through an interactive travel planning assistant. Watch as specialized AI agents collaborate to plan your perfect trip!\n\n## What are LangGraph Subgraphs? 🤖\n\n**Subgraphs** are the key to building modular, scalable AI systems in LangGraph. A subgraph is essentially \"a graph that is used as a node in another graph\" - enabling powerful encapsulation and reusability.\nFor more info, check out the [LangGraph docs](https://langchain-ai.github.io/langgraph/concepts/subgraphs/).\n\n### Key Concepts\n\n- **Encapsulation**: Each subgraph handles a specific domain with its own expertise\n- **Modularity**: Subgraphs can be developed, tested, and maintained independently \n- **Reusability**: The same subgraph can be used across multiple parent graphs\n- **State Communication**: Subgraphs can share state or use different schemas with transformations\n\n## Demo Architecture 🗺️\n\nThis travel planner demonstrates **supervisor-coordinated subgraphs** with **human-in-the-loop** decision making:\n\n### Parent Graph: Travel Supervisor\n- **Role**: Coordinates the travel planning process and routes to specialized agents\n- **State Management**: Maintains a shared itinerary object across all subgraphs\n- **Intelligence**: Determines what's needed and when each agent should be called\n\n### Subgraph 1: ✈️ Flights Agent\n- **Specialization**: Finding and booking flight options\n- **Process**: Presents flight options from Amsterdam to San Francisco with recommendations\n- **Interaction**: Uses interrupts to let users choose their preferred flight\n- **Data**: Static flight options (KLM, United) with pricing and duration\n\n### Subgraph 2: 🏨 Hotels Agent \n- **Specialization**: Finding and booking accommodation\n- **Process**: Shows hotel options in San Francisco with different price points\n- **Interaction**: Uses interrupts for user to select their preferred hotel\n- **Data**: Static hotel options (Hotel Zephyr, Ritz-Carlton, Hotel Zoe)\n\n### Subgraph 3: 🎯 Experiences Agent\n- **Specialization**: Curating restaurants and activities\n- **Process**: AI-powered recommendations based on selected flights and hotels\n- **Features**: Combines 2 restaurants and 2 activities with location-aware suggestions\n- **Data**: Static experiences (Pier 39, Golden Gate Bridge, Swan Oyster Depot, Tartine Bakery)\n\n## How It Works 🔄\n\n1. **User Request**: \"Help me plan a trip to San Francisco\"\n2. **Supervisor Analysis**: Determines what travel components are needed\n3. **Sequential Routing**: Routes to each agent in logical order:\n - First: Flights Agent (get transportation sorted)\n - Then: Hotels Agent (book accommodation) \n - Finally: Experiences Agent (plan activities)\n4. **Human Decisions**: Each agent presents options and waits for user choice via interrupts\n5. **State Building**: Selected choices are stored in the shared itinerary object\n6. **Completion**: All agents report back to supervisor for final coordination\n\n## State Communication Patterns 📊\n\n### Shared State Schema\nAll subgraph agents share and contribute to a common state object. When any agent updates the shared state, these changes are immediately reflected in the frontend through real-time syncing. This ensures that:\n\n- **Flight selections** from the Flights Agent are visible to subsequent agents\n- **Hotel choices** influence the Experiences Agent's recommendations \n- **All updates** are synchronized with the frontend UI in real-time\n- **State persistence** maintains the travel itinerary throughout the workflow\n\n### Human-in-the-Loop Pattern\nTwo of the specialist agents use **interrupts** to pause execution and gather user preferences:\n\n- **Flights Agent**: Presents options → interrupt → waits for selection → continues\n- **Hotels Agent**: Shows hotels → interrupt → waits for choice → continues\n\n## Try These Examples! 💡\n\n### Getting Started\n- \"Help me plan a trip to San Francisco\"\n- \"I want to visit San Francisco from Amsterdam\"\n- \"Plan my travel itinerary\"\n\n### During the Process\nWhen the Flights Agent presents options:\n- Choose between KLM ($650, 11h 30m) or United ($720, 12h 15m)\n\nWhen the Hotels Agent shows accommodations:\n- Select from Hotel Zephyr, The Ritz-Carlton, or Hotel Zoe\n\nThe Experiences Agent will then provide tailored recommendations based on your choices!\n\n## Frontend Capabilities 👁️\n\n- **Human-in-the-loop with interrupts** from subgraphs for user decision making\n- **Subgraphs detection and streaming** to show which agent is currently active\n- **Real-time state updates** as the shared itinerary is built across agents\n", + "language": "markdown", + "type": "file" + }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, + { + "name": "subgraphs.py", + "content": "\"\"\"\nA travel-planner demo showcasing a multi-agent flow with human-in-the-loop.\n\nA supervisor coordinates three specialists (flights, hotels, experiences). The\nflights and hotels steps pause the flow so the user picks an option; the\nexperiences step narrates recommendations. ``active_agent`` tracks who is\nworking so the UI can light up the current specialist, and each pick lands in a\nshared ``itinerary``.\n\"\"\"\n\nimport json\nimport uuid\nfrom typing import Any, Dict, List\n\nfrom crewai.flow.flow import Flow, listen, start\nfrom crewai.flow import human_feedback\nfrom litellm import acompletion\n\nfrom ..sdk import CopilotKitState, copilotkit_stream\nfrom .._hitl import agui_feedback_provider\n\nMODEL = \"openai/gpt-5.4\"\n\nSTATIC_FLIGHTS: List[Dict[str, str]] = [\n {\n \"airline\": \"KLM\",\n \"departure\": \"Amsterdam (AMS)\",\n \"arrival\": \"San Francisco (SFO)\",\n \"price\": \"$650\",\n \"duration\": \"11h 30m\",\n },\n {\n \"airline\": \"United\",\n \"departure\": \"Amsterdam (AMS)\",\n \"arrival\": \"San Francisco (SFO)\",\n \"price\": \"$720\",\n \"duration\": \"12h 15m\",\n },\n]\n\nSTATIC_HOTELS: List[Dict[str, str]] = [\n {\n \"name\": \"Hotel Zephyr\",\n \"location\": \"Fisherman's Wharf\",\n \"price_per_night\": \"$280/night\",\n \"rating\": \"4.2 stars\",\n },\n {\n \"name\": \"The Ritz-Carlton\",\n \"location\": \"Nob Hill\",\n \"price_per_night\": \"$550/night\",\n \"rating\": \"4.8 stars\",\n },\n {\n \"name\": \"Hotel Zoe\",\n \"location\": \"Union Square\",\n \"price_per_night\": \"$320/night\",\n \"rating\": \"4.4 stars\",\n },\n]\n\nSTATIC_EXPERIENCES: List[Dict[str, str]] = [\n {\n \"name\": \"Pier 39\",\n \"type\": \"activity\",\n \"description\": \"Iconic waterfront destination with shops and sea lions\",\n \"location\": \"Fisherman's Wharf\",\n },\n {\n \"name\": \"Golden Gate Bridge\",\n \"type\": \"activity\",\n \"description\": \"World-famous suspension bridge with stunning views\",\n \"location\": \"Golden Gate\",\n },\n {\n \"name\": \"Swan Oyster Depot\",\n \"type\": \"restaurant\",\n \"description\": \"Historic seafood counter serving fresh oysters\",\n \"location\": \"Polk Street\",\n },\n {\n \"name\": \"Tartine Bakery\",\n \"type\": \"restaurant\",\n \"description\": \"Artisanal bakery famous for bread and pastries\",\n \"location\": \"Mission District\",\n },\n]\n\n\nclass TravelAgentState(CopilotKitState):\n \"\"\"Shared state for the travel-planner, read by the UI.\"\"\"\n\n origin: str = \"Amsterdam\"\n destination: str = \"San Francisco\"\n flights: List[Dict[str, Any]] = []\n hotels: List[Dict[str, Any]] = []\n experiences: List[Dict[str, Any]] = []\n itinerary: Dict[str, Any] = {}\n active_agent: str = \"supervisor\"\n planning_step: str = \"start\"\n\n\ndef _parse_selection(raw: Any) -> Dict[str, Any]:\n \"\"\"Best-effort parse of the resume payload (a JSON-encoded option) to a dict.\"\"\"\n if isinstance(raw, dict):\n return raw\n if not isinstance(raw, str):\n return {}\n text = raw.strip()\n if text.startswith(\"```\"):\n text = text.strip(\"`\")\n if \"{\" in text:\n text = text[text.index(\"{\"):]\n try:\n parsed = json.loads(text)\n except (ValueError, TypeError):\n return {}\n return parsed if isinstance(parsed, dict) else {}\n\n\nclass SubgraphsFlow(Flow[TravelAgentState]):\n \"\"\"Supervisor-coordinated travel planner with two HITL selection steps.\"\"\"\n\n @start()\n async def supervisor(self):\n \"\"\"Kick off planning: greet and hand over to the flights specialist.\"\"\"\n self.state.active_agent = \"supervisor\"\n self.state.planning_step = \"flights\"\n\n @listen(supervisor)\n async def prepare_flights(self):\n \"\"\"Flights specialist takes over.\n\n A step of its own so the state (active agent + found flights) is\n snapshotted for the UI before the next step suspends the flow.\n \"\"\"\n self.state.active_agent = \"flights\"\n self.state.flights = STATIC_FLIGHTS\n\n @listen(prepare_flights)\n @human_feedback(\n message=\"Select a flight option.\",\n provider=agui_feedback_provider,\n )\n def find_flights(self):\n \"\"\"Present the flight options and pause for the user's choice.\"\"\"\n return {\n \"message\": (\n f\"Found {len(STATIC_FLIGHTS)} flights from {self.state.origin} to \"\n f\"{self.state.destination}. I recommend {STATIC_FLIGHTS[0]['airline']} \"\n \"since it is on time and cheaper.\"\n ),\n \"options\": STATIC_FLIGHTS,\n \"recommendation\": STATIC_FLIGHTS[0],\n \"agent\": \"flights\",\n }\n\n @listen(find_flights)\n async def select_flight(self, feedback):\n \"\"\"Resumed with the flight pick: record it and hand over to hotels.\"\"\"\n answer = getattr(feedback, \"feedback\", feedback)\n selected = _parse_selection(answer) or STATIC_FLIGHTS[0]\n self.state.itinerary = {**self.state.itinerary, \"flight\": selected}\n self.state.messages.append({\n \"id\": str(uuid.uuid4()),\n \"role\": \"assistant\",\n \"content\": (\n f\"Flights Agent: Booked the {selected.get('airline')} flight from \"\n f\"{selected.get('departure')} to {selected.get('arrival')}.\"\n ),\n })\n self.state.planning_step = \"hotels\"\n\n @listen(select_flight)\n async def prepare_hotels(self):\n \"\"\"Hotels specialist takes over; snapshot state before the next suspend.\"\"\"\n self.state.active_agent = \"hotels\"\n self.state.hotels = STATIC_HOTELS\n\n @listen(prepare_hotels)\n @human_feedback(\n message=\"Select a hotel option.\",\n provider=agui_feedback_provider,\n )\n def find_hotels(self):\n \"\"\"Present the hotel options and pause for the user's choice.\"\"\"\n return {\n \"message\": (\n f\"Found {len(STATIC_HOTELS)} hotels in {self.state.destination}. I \"\n f\"recommend {STATIC_HOTELS[2]['name']} for its balance of rating, \"\n \"price, and location.\"\n ),\n \"options\": STATIC_HOTELS,\n \"recommendation\": STATIC_HOTELS[2],\n \"agent\": \"hotels\",\n }\n\n @listen(find_hotels)\n async def select_hotel(self, feedback):\n \"\"\"Resumed with the hotel pick: record it and hand over to experiences.\"\"\"\n answer = getattr(feedback, \"feedback\", feedback)\n selected = _parse_selection(answer) or STATIC_HOTELS[2]\n self.state.itinerary = {**self.state.itinerary, \"hotel\": selected}\n self.state.messages.append({\n \"id\": str(uuid.uuid4()),\n \"role\": \"assistant\",\n \"content\": f\"Hotels Agent: Great choice, you'll love {selected.get('name')}.\",\n })\n self.state.planning_step = \"experiences\"\n\n @listen(select_hotel)\n async def prepare_experiences(self):\n \"\"\"Experiences specialist takes over; snapshot state before narrating.\"\"\"\n self.state.active_agent = \"experiences\"\n self.state.experiences = STATIC_EXPERIENCES\n\n @listen(prepare_experiences)\n async def find_experiences(self):\n \"\"\"Narrate the experiences the specialist found.\"\"\"\n itinerary = self.state.itinerary\n system_prompt = (\n \"You are the experiences agent for a trip to \"\n f\"{self.state.destination}. The traveller has chosen the \"\n f\"{itinerary.get('flight', {}).get('airline', 'selected')} flight and \"\n f\"the {itinerary.get('hotel', {}).get('name', 'selected')} hotel. You \"\n \"already found these experiences: \"\n f\"{json.dumps(STATIC_EXPERIENCES)}. In two or three friendly sentences, \"\n \"let the traveller know what you found. Do not ask questions.\"\n )\n\n response = await copilotkit_stream(\n await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n *self.state.messages,\n ],\n stream=True,\n )\n )\n self.state.messages.append(response.choices[0].message)\n self.state.planning_step = \"complete\"\n", + "language": "python", + "type": "file" + } + ], + "crewai-conversational-flows::a2ui_dynamic_schema": [ { "name": "page.tsx", "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", @@ -3846,6 +4330,12 @@ "language": "markdown", "type": "file" }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, { "name": "a2ui_dynamic_schema.py", "content": "\"\"\"A2UI dynamic-schema demo.\n\nA plain agentic-chat flow with no A2UI tool wired: the frontend a2ui middleware\nforwards ``injectA2UITool`` and the adapter auto-injects ``generate_a2ui``,\nwhich designs a surface from the conversation against the dojo's dynamic catalog\n(pillars 1-4). See ``_a2ui_subagent`` for the shared turn.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\n\nfrom ._a2ui_subagent import run_a2ui_subagent_turn\n\n\nclass A2UIDynamicSchemaFlow(Flow):\n \"\"\"Dynamic A2UI surfaces generated on the fly via the auto-injected tool.\"\"\"\n\n @start()\n async def chat(self):\n await run_a2ui_subagent_turn(self.state)\n", @@ -3853,7 +4343,7 @@ "type": "file" } ], - "crewai::a2ui_recovery": [ + "crewai-conversational-flows::a2ui_recovery": [ { "name": "page.tsx", "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Recover from an error\",\n message: \"Compare 3 luxury hotels with ratings and prices.\",\n },\n {\n title: \"Hard failure\",\n message: \"Compare 3 broken hotels with ratings and prices.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", @@ -3872,6 +4362,12 @@ "language": "markdown", "type": "file" }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, { "name": "a2ui_recovery.py", "content": "\"\"\"A2UI error-recovery demo.\n\nSame subagent path as the dynamic-schema demo: the adapter auto-injects\n``generate_a2ui``, which validates each generated surface and retries on failure\n(up to 3 attempts) before a tasteful hard-failure. Recovery is inherent to the\ntoolkit loop, so this shares the dynamic-schema turn.\n\"\"\"\n\nfrom crewai.flow.flow import Flow, start\n\nfrom ._a2ui_subagent import run_a2ui_subagent_turn\n\n\nclass A2UIRecoveryFlow(Flow):\n \"\"\"Dynamic A2UI with automatic validate/retry recovery.\"\"\"\n\n @start()\n async def chat(self):\n await run_a2ui_subagent_turn(self.state)\n", @@ -3879,7 +4375,7 @@ "type": "file" } ], - "crewai::a2ui_fixed_schema": [ + "crewai-conversational-flows::a2ui_fixed_schema": [ { "name": "page.tsx", "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { fixedSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Search flights\",\n message: \"Find flights from SFO to JFK for next Tuesday.\",\n },\n {\n title: \"Search hotels\",\n message: \"Find hotels in downtown Manhattan for next weekend.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", @@ -3898,9 +4394,15 @@ "language": "markdown", "type": "file" }, + { + "name": "conversational.py", + "content": "\"\"\"Conversational variants of the regular CrewAI dojo Flows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterator, Mapping\nfrom typing import Any, TypeVar\n\nfrom crewai.experimental.conversational import (\n ConversationConfig,\n message_to_llm_dict,\n)\nfrom crewai.flow.flow import listen\nfrom pydantic import BaseModel, ConfigDict\n\nfrom ..sdk import CopilotKitState\nfrom .a2ui_dynamic_schema import A2UIDynamicSchemaFlow\nfrom .a2ui_fixed_schema import A2UIFixedSchemaFlow\nfrom .a2ui_recovery import A2UIRecoveryFlow\nfrom .agentic_chat import AgenticChatFlow\nfrom .agentic_chat_multimodal import AgenticChatMultimodalFlow\nfrom .agentic_chat_reasoning import AgenticChatReasoningFlow\nfrom .agentic_generative_ui import AgenticGenerativeUIFlow\nfrom .backend_tool_rendering import BackendToolRenderingFlow\nfrom .human_in_the_loop import HumanInTheLoopFlow\nfrom .interrupt_flow import InterruptFlow\nfrom .predictive_state_updates import PredictiveStateUpdatesFlow\nfrom .shared_state import SharedStateFlow\nfrom .subgraphs import SubgraphsFlow\nfrom .tool_based_generative_ui import ToolBasedGenerativeUIFlow\n\n\nclass _AGUIMappingState(CopilotKitState, Mapping[str, Any]):\n \"\"\"Typed conversational fields with the dict API used by untyped Flows.\"\"\"\n\n model_config = ConfigDict(extra=\"allow\")\n\n def get(self, key: str, default: Any = None) -> Any:\n value = getattr(self, key, default)\n return value.model_dump() if isinstance(value, BaseModel) else value\n\n def __getitem__(self, key: str) -> Any:\n return getattr(self, key)\n\n def __setitem__(self, key: str, value: Any) -> None:\n setattr(self, key, value)\n\n def __iter__(self) -> Iterator[str]:\n return iter(self.model_dump())\n\n def __len__(self) -> int:\n return len(self.model_dump())\n\n\nclass _AGUIConversationalBehavior:\n \"\"\"Route each public turn through the regular Flow's existing starts.\"\"\"\n\n def receive_user_message(self, *args: Any, **kwargs: Any) -> Any:\n result = super().receive_user_message(*args, **kwargs)\n messages = getattr(self.state, \"messages\", None)\n if messages and isinstance(messages[-1], BaseModel):\n messages[-1] = message_to_llm_dict(messages[-1])\n return result\n\n def route_turn(self, _context: Any) -> str:\n return \"ag_ui_complete\"\n\n @listen(\"__ag_ui_disable_builtin_end__\")\n def end_conversation(self) -> None:\n \"\"\"Keep a regular method named ``end`` from firing CrewAI's terminator.\"\"\"\n return None\n\n @listen(\"ag_ui_complete\")\n def finish_ag_ui_turn(self) -> None:\n return None\n\n\ndef _conversational_type(base: type[Any]) -> type[Any]:\n flow_methods = {\n name: value\n for owner in (base, _AGUIConversationalBehavior)\n for name, value in owner.__dict__.items()\n if not name.startswith(\"_\") and hasattr(value, \"__flow_method_definition__\")\n }\n initial_state_type = getattr(base, \"_initial_state_t\", None)\n flow_type = type(\n f\"Conversational{base.__name__}\",\n (_AGUIConversationalBehavior, base),\n {\n **flow_methods,\n \"__module__\": __name__,\n \"conversational\": True,\n \"conversational_config\": ConversationConfig(defer_trace_finalization=False),\n },\n )\n if isinstance(initial_state_type, TypeVar):\n flow_type._initial_state_t = _AGUIMappingState\n return flow_type\n\n\nCONVERSATIONAL_FLOW_TYPES = {\n \"agentic_chat\": _conversational_type(AgenticChatFlow),\n \"agentic_chat_reasoning\": _conversational_type(AgenticChatReasoningFlow),\n \"agentic_chat_multimodal\": _conversational_type(AgenticChatMultimodalFlow),\n \"backend_tool_rendering\": _conversational_type(BackendToolRenderingFlow),\n \"interrupt\": _conversational_type(InterruptFlow),\n \"human_in_the_loop\": _conversational_type(HumanInTheLoopFlow),\n \"agentic_generative_ui\": _conversational_type(AgenticGenerativeUIFlow),\n \"predictive_state_updates\": _conversational_type(PredictiveStateUpdatesFlow),\n \"shared_state\": _conversational_type(SharedStateFlow),\n \"tool_based_generative_ui\": _conversational_type(ToolBasedGenerativeUIFlow),\n \"subgraphs\": _conversational_type(SubgraphsFlow),\n \"a2ui_dynamic_schema\": _conversational_type(A2UIDynamicSchemaFlow),\n \"a2ui_recovery\": _conversational_type(A2UIRecoveryFlow),\n \"a2ui_fixed_schema\": _conversational_type(A2UIFixedSchemaFlow),\n}\n", + "language": "python", + "type": "file" + }, { "name": "a2ui_fixed_schema.py", - "content": "\"\"\"A2UI fixed-schema flow.\n\nUnlike the dynamic demo (which auto-injects generate_a2ui to GENERATE a\nsurface), the fixed-schema demo wires two backend tools, ``search_flights`` and\n``search_hotels``. The component layout is pre-authored JSON loaded at import;\nonly the data changes per call. Each tool returns the ``a2ui_operations``\nenvelope (createSurface -> updateComponents -> updateDataModel) as a tool\nresult, which the frontend A2UIMiddleware detects and paints. No sub-agent, no\ngeneration, no recovery.\n\"\"\"\n\nimport json\nimport logging\nfrom pathlib import Path\nfrom typing import Any\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\n\nfrom ag_ui_a2ui_toolkit import (\n A2UI_OPERATIONS_KEY,\n create_surface,\n update_components,\n update_data_model,\n)\n\nfrom ..sdk import copilotkit_emit_tool_result, copilotkit_stream\n\nlogger = logging.getLogger(\"ag_ui_crewai\")\n\nMODEL = \"openai/gpt-4o\"\n\n# Both surfaces render against the dojo's fixed catalog (Row / FlightCard /\n# HotelCard / StarRating); the dojo page supplies the catalog components, we\n# only reference its id in createSurface.\nFIXED_CATALOG_ID = \"https://a2ui.org/demos/dojo/fixed_catalog.json\"\n\n_SCHEMAS_DIR = Path(__file__).parent / \"a2ui_fixed_schema_schemas\"\n\n\ndef _load_schema(name: str) -> list[dict[str, Any]]:\n with open(_SCHEMAS_DIR / name, encoding=\"utf-8\") as f:\n return json.load(f)\n\n\nFLIGHT_SURFACE_ID = \"flight-search-results\"\nFLIGHT_SCHEMA = _load_schema(\"flight_schema.json\")\nHOTEL_SURFACE_ID = \"hotel-search-results\"\nHOTEL_SCHEMA = _load_schema(\"hotel_schema.json\")\n\n\ndef _envelope(surface_id: str, schema: list[dict[str, Any]], data: dict[str, Any]) -> str:\n \"\"\"Build the A2UI operations envelope JSON for a fixed-schema surface.\"\"\"\n return json.dumps(\n {\n A2UI_OPERATIONS_KEY: [\n create_surface(surface_id, catalog_id=FIXED_CATALOG_ID),\n update_components(surface_id, schema),\n update_data_model(surface_id, data),\n ]\n }\n )\n\n\nSEARCH_FLIGHTS_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"search_flights\",\n \"description\": \"Search for flights and display the results as rich cards.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"flights\": {\n \"type\": \"array\",\n \"description\": (\n \"Flight objects, each with: id, airline, airlineLogo \"\n \"(Google favicon API: \"\n \"https://www.google.com/s2/favicons?domain={airline_domain}&sz=128), \"\n \"flightNumber, origin, destination, date (short readable, \"\n \"near-future), departureTime, arrivalTime, duration, \"\n \"status, price.\"\n ),\n \"items\": {\"type\": \"object\"},\n }\n },\n \"required\": [\"flights\"],\n },\n },\n}\n\nSEARCH_HOTELS_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"search_hotels\",\n \"description\": \"Search for hotels and display the results as rich cards with star ratings.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"hotels\": {\n \"type\": \"array\",\n \"description\": (\n \"Hotel objects, each with: id, name, location, rating \"\n \"(float 0-5), price (per night). Generate 3-4 realistic \"\n \"results.\"\n ),\n \"items\": {\"type\": \"object\"},\n }\n },\n \"required\": [\"hotels\"],\n },\n },\n}\n\nSYSTEM_PROMPT = (\n \"You are a helpful travel assistant that can search for flights and hotels. \"\n \"When the user asks about flights, use the search_flights tool; for hotels, \"\n \"use search_hotels. After calling a tool, do NOT repeat or summarize the \"\n \"data in your text response; the tool renders a rich UI automatically. Just \"\n \"say something brief like 'Here are your results'. Generate 3-5 realistic \"\n \"results.\"\n)\n\n_TOOL_ENVELOPE = {\n \"search_flights\": lambda args: _envelope(\n FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {\"flights\": args.get(\"flights\", [])}\n ),\n \"search_hotels\": lambda args: _envelope(\n HOTEL_SURFACE_ID, HOTEL_SCHEMA, {\"hotels\": args.get(\"hotels\", [])}\n ),\n}\n\n\nclass A2UIFixedSchemaFlow(Flow):\n \"\"\"A2UI surfaces from fixed, pre-authored schemas via direct backend tools.\"\"\"\n\n @start()\n async def chat(self):\n state = self.state\n actions = (state.get(\"copilotkit\") or {}).get(\"actions\") or []\n tools = [*actions, SEARCH_FLIGHTS_TOOL, SEARCH_HOTELS_TOOL]\n\n response = await copilotkit_stream(\n await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n *state[\"messages\"],\n ],\n tools=tools,\n parallel_tool_calls=False,\n stream=True,\n )\n )\n message = response.choices[0].message\n # Preserve the streamed message id so the terminal MESSAGES_SNAPSHOT\n # updates the assistant message in place rather than re-appending it\n # after the already-streamed surface (which would drop the tool-call\n # chip to the end).\n assistant = message.model_dump()\n stream_id = getattr(response, \"id\", None)\n if stream_id:\n assistant[\"id\"] = stream_id\n state[\"messages\"].append(assistant)\n\n if not message.tool_calls:\n return\n\n for tool_call in message.tool_calls:\n build = _TOOL_ENVELOPE.get(tool_call.function.name)\n if build is None:\n continue\n try:\n args = json.loads(tool_call.function.arguments or \"{}\")\n except (json.JSONDecodeError, TypeError):\n logger.warning(\n \"%s tool-call args were not valid JSON; rendering an empty \"\n \"surface: %r\",\n tool_call.function.name,\n tool_call.function.arguments,\n )\n args = {}\n envelope = build(args)\n state[\"messages\"].append(\n {\n \"role\": \"tool\",\n \"content\": envelope,\n \"tool_call_id\": tool_call.id,\n }\n )\n # The A2UI middleware paints the fixed surface from the tool RESULT\n # (a2ui_operations envelope), which the bridge otherwise surfaces\n # only via MESSAGES_SNAPSHOT. Emit it as a TOOL_CALL_RESULT so the\n # middleware detects and renders it.\n await copilotkit_emit_tool_result(tool_call.id, envelope)\n", + "content": "\"\"\"A2UI fixed-schema flow.\n\nUnlike the dynamic demo (which auto-injects generate_a2ui to GENERATE a\nsurface), the fixed-schema demo wires two backend tools, ``search_flights`` and\n``search_hotels``. The component layout is pre-authored JSON loaded at import;\nonly the data changes per call. Each tool returns the ``a2ui_operations``\nenvelope (createSurface -> updateComponents -> updateDataModel) as a tool\nresult, which the frontend A2UIMiddleware detects and paints. No sub-agent, no\ngeneration, no recovery.\n\"\"\"\n\nimport json\nimport logging\nimport uuid\nfrom pathlib import Path\nfrom typing import Any\n\nfrom crewai.flow.flow import Flow, start\nfrom litellm import acompletion\n\nfrom ag_ui_a2ui_toolkit import (\n A2UI_OPERATIONS_KEY,\n create_surface,\n update_components,\n update_data_model,\n)\n\nfrom ..sdk import copilotkit_emit_tool_result, copilotkit_stream\nfrom ._model_turn import (\n append_assistant_message,\n resolve_client_tools,\n sort_tool_calls,\n)\n\nlogger = logging.getLogger(\"ag_ui_crewai\")\n\nMODEL = \"openai/gpt-5.4\"\n\n# Model turns per run: one search plus its closing reply, with headroom for a\n# flight-and-hotel request. Bounded so a model that keeps calling tools cannot\n# spin the run.\nMAX_MODEL_TURNS = 4\n\n# Both surfaces render against the dojo's fixed catalog (Row / FlightCard /\n# HotelCard / StarRating); the dojo page supplies the catalog components, we\n# only reference its id in createSurface.\nFIXED_CATALOG_ID = \"https://a2ui.org/demos/dojo/fixed_catalog.json\"\n\n_SCHEMAS_DIR = Path(__file__).parent / \"a2ui_fixed_schema_schemas\"\n\n\ndef _load_schema(name: str) -> list[dict[str, Any]]:\n with open(_SCHEMAS_DIR / name, encoding=\"utf-8\") as f:\n return json.load(f)\n\n\nFLIGHT_SURFACE_ID = \"flight-search-results\"\nFLIGHT_SCHEMA = _load_schema(\"flight_schema.json\")\nHOTEL_SURFACE_ID = \"hotel-search-results\"\nHOTEL_SCHEMA = _load_schema(\"hotel_schema.json\")\n\n\ndef _envelope(surface_id: str, schema: list[dict[str, Any]], data: dict[str, Any]) -> str:\n \"\"\"Build the A2UI operations envelope JSON for a fixed-schema surface.\"\"\"\n return json.dumps(\n {\n A2UI_OPERATIONS_KEY: [\n create_surface(surface_id, catalog_id=FIXED_CATALOG_ID),\n update_components(surface_id, schema),\n update_data_model(surface_id, data),\n ]\n }\n )\n\n\nSEARCH_FLIGHTS_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"search_flights\",\n \"description\": \"Search for flights and display the results as rich cards.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"flights\": {\n \"type\": \"array\",\n \"description\": (\n \"Flight objects, each with: id, airline, airlineLogo \"\n \"(Google favicon API: \"\n \"https://www.google.com/s2/favicons?domain={airline_domain}&sz=128), \"\n \"flightNumber, origin, destination, date (short readable, \"\n \"near-future), departureTime, arrivalTime, duration, \"\n \"status, price.\"\n ),\n \"items\": {\"type\": \"object\"},\n }\n },\n \"required\": [\"flights\"],\n },\n },\n}\n\nSEARCH_HOTELS_TOOL = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"search_hotels\",\n \"description\": \"Search for hotels and display the results as rich cards with star ratings.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"hotels\": {\n \"type\": \"array\",\n \"description\": (\n \"Hotel objects, each with: id, name, location, rating \"\n \"(float 0-5), price (per night). Generate 3-4 realistic \"\n \"results.\"\n ),\n \"items\": {\"type\": \"object\"},\n }\n },\n \"required\": [\"hotels\"],\n },\n },\n}\n\nSYSTEM_PROMPT = (\n \"You are a helpful travel assistant that can search for flights and hotels. \"\n \"When the user asks about flights, use the search_flights tool; for hotels, \"\n \"use search_hotels. After calling a tool, do NOT repeat or summarize the \"\n \"data in your text response; the tool renders a rich UI automatically. Just \"\n \"say something brief like 'Here are your results'. Generate 3-5 realistic \"\n \"results.\\n\\n\"\n \"The conversation may already contain a report that the user interacted with \"\n \"results you rendered earlier (booked a hotel or selected a flight, for \"\n \"example). That report is history, not a new request: do NOT run another \"\n \"search and do NOT call any tool. Reply in text, naming the specific item the \"\n \"user chose and what happens next.\"\n)\n\n\ndef _results(args: dict[str, Any], key: str) -> list:\n \"\"\"The results list for a search call. A missing OR explicitly-null argument\n becomes an empty list: ``updateDataModel {\"hotels\": null}`` paints nothing at\n all, where an empty surface is what a no-results search means.\"\"\"\n value = args.get(key)\n return value if isinstance(value, list) else []\n\n\n_TOOL_ENVELOPE = {\n \"search_flights\": lambda args: _envelope(\n FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {\"flights\": _results(args, \"flights\")}\n ),\n \"search_hotels\": lambda args: _envelope(\n HOTEL_SURFACE_ID, HOTEL_SCHEMA, {\"hotels\": _results(args, \"hotels\")}\n ),\n}\n\n\nclass A2UIFixedSchemaFlow(Flow):\n \"\"\"A2UI surfaces from fixed, pre-authored schemas via direct backend tools.\n\n Loops the model over its own tool results (bounded by ``MAX_MODEL_TURNS``)\n so a turn that ends in a search still gets a closing model reply.\n\n What the loop does for a user action on a rendered surface, precisely: the\n middleware appends the action and its report to the NEXT run's input, so the\n report is already in history on the first turn and a model that answers it in\n text needs no loop at all. The loop saves the case the live model actually\n takes: it tool-calls FIRST (running another search), which without a loop\n would end the run on that call and leave the user's choice unacknowledged.\n \"\"\"\n\n @start()\n async def chat(self):\n state = self.state\n actions = (state.get(\"copilotkit\") or {}).get(\"actions\") or []\n # A frontend action sharing a search tool's name is dropped in favour of\n # the backend tool (and logged), so the model is offered one tool per name\n # rather than two definitions of the same one.\n offered, client_names = resolve_client_tools(\n actions, backend_names=set(_TOOL_ENVELOPE)\n )\n tools = [*offered, SEARCH_FLIGHTS_TOOL, SEARCH_HOTELS_TOOL]\n\n for _ in range(MAX_MODEL_TURNS):\n response = await copilotkit_stream(\n await acompletion(\n model=MODEL,\n messages=[\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n *state[\"messages\"],\n ],\n tools=tools,\n parallel_tool_calls=False,\n stream=True,\n )\n )\n message = response.choices[0].message\n tool_calls = message.tool_calls or []\n # An orphan call (a name neither this flow's searches nor a frontend\n # tool) is answered by nobody, so it is dropped instead of persisted:\n # an assistant tool_calls entry with no matching tool result 400s\n # every later run on this thread.\n backend, client, orphan = sort_tool_calls(\n tool_calls,\n backend_names=set(_TOOL_ENVELOPE),\n client_names=client_names,\n )\n append_assistant_message(\n state, response, message, drop_indexes={i for i, _ in orphan}\n )\n\n if not tool_calls:\n return\n\n for _, tool_call in backend:\n build = _TOOL_ENVELOPE[tool_call.function.name]\n try:\n args = json.loads(tool_call.function.arguments or \"{}\")\n except (json.JSONDecodeError, TypeError):\n logger.warning(\n \"%s tool-call args were not valid JSON; rendering an \"\n \"empty surface: %r\",\n tool_call.function.name,\n tool_call.function.arguments,\n )\n args = {}\n envelope = build(args)\n # One id for the streamed result and the persisted message: the\n # terminal MESSAGES_SNAPSHOT then updates that message in place.\n # Left unstamped, the snapshot mints a second id and the client\n # remounts the surface card it just painted.\n result_id = str(uuid.uuid4())\n state[\"messages\"].append(\n {\n \"id\": result_id,\n \"role\": \"tool\",\n \"content\": envelope,\n \"tool_call_id\": tool_call.id,\n }\n )\n # The A2UI middleware paints the fixed surface from the tool\n # RESULT (a2ui_operations envelope), which the bridge otherwise\n # surfaces only via MESSAGES_SNAPSHOT. Emit it as a\n # TOOL_CALL_RESULT so the middleware detects and renders it.\n await copilotkit_emit_tool_result(\n tool_call.id, envelope, message_id=result_id\n )\n\n # A frontend call ends the run so the client can run it and send the\n # result back on the next one; feeding the model again here would\n # leave that call unanswered. An orphan call does NOT end the run: it\n # was dropped, so the history is well-formed, and ending here would\n # cost the user a reply. The model gets another turn to answer in text\n # instead, bounded by MAX_MODEL_TURNS.\n if client:\n return\n\n logger.warning(\n \"Fixed-schema turn hit the %d-model-turn cap with the model still \"\n \"calling tools; ending the run without a closing reply\",\n MAX_MODEL_TURNS,\n )\n", "language": "python", "type": "file" } diff --git a/apps/dojo/src/menu.ts b/apps/dojo/src/menu.ts index 55c700e0e8..c02309c4bc 100644 --- a/apps/dojo/src/menu.ts +++ b/apps/dojo/src/menu.ts @@ -1,4 +1,5 @@ import type { MenuIntegrationConfig } from "./types/integration"; +import { CREWAI_CONVERSATIONAL_FEATURES, CREWAI_FLOW_FEATURES } from "./crewai"; export * from "./types/integration"; /** @@ -270,23 +271,13 @@ export const menuIntegrations = [ }, { id: "crewai", - name: "CrewAI", - features: [ - "agentic_chat", - "v1_agentic_chat", - "backend_tool_rendering", - "interrupt", - "human_in_the_loop", - "agentic_generative_ui", - "predictive_state_updates", - "shared_state", - "tool_based_generative_ui", - "crew_chat", - "error_flow", - "a2ui_dynamic_schema", - "a2ui_recovery", - "a2ui_fixed_schema", - ], + name: "CrewAI Flows", + features: [...CREWAI_FLOW_FEATURES], + }, + { + id: "crewai-conversational-flows", + name: "CrewAI Conversational Flows", + features: [...CREWAI_CONVERSATIONAL_FEATURES], }, // { // id: "builtin", diff --git a/apps/dojo/src/types/integration.ts b/apps/dojo/src/types/integration.ts index 6be810e9d6..7217013920 100644 --- a/apps/dojo/src/types/integration.ts +++ b/apps/dojo/src/types/integration.ts @@ -20,7 +20,6 @@ export type Feature = | "a2ui_advanced" | "a2ui_recovery" | "crew_chat" - | "error_flow" | "background_agents" | "observational_memory"; diff --git a/integrations/crew-ai/python/README.md b/integrations/crew-ai/python/README.md index e588b26a0c..b8cf2abbf3 100644 --- a/integrations/crew-ai/python/README.md +++ b/integrations/crew-ai/python/README.md @@ -43,6 +43,53 @@ app = FastAPI() add_crewai_flow_fastapi_endpoint(app, MyFlow(), "/flow") ``` +### Conversational Flows + +CrewAI 1.15.11's Conversational Flows use the same AG-UI event translation, +state synchronization, tools, reasoning, multimodal content, interrupts, and +generative UI support as regular Flows. Opt the Flow into CrewAI's public +conversation API and register the endpoint with `conversational=True`: + +```python +from crewai.experimental.conversational import ConversationConfig + +class MyConversationalFlow(MyFlow): + conversational = True + conversational_config = ConversationConfig( + defer_trace_finalization=False, + ) + +add_crewai_flow_fastapi_endpoint( + app, + MyConversationalFlow(), + "/conversational-flow", + conversational=True, +) +``` + +The bridge invokes `flow.stream_turn(message, session_id=thread_id)`: AG-UI's +`threadId` is the CrewAI conversation session ID. It hydrates prior messages into +the Flow state before the current turn, passes only the latest user's text to +`stream_turn`, and preserves media blocks on that current message. Each HTTP +request finalizes its own CrewAI trace even if the Flow's conversation config +would normally defer finalization across turns. + +Conversational mode requires CrewAI's ordered `StreamFrame` transport and a Flow +that both sets `conversational=True` and exposes `stream_turn`. If any part of +that contract is unavailable, the endpoint emits a correlated `RUN_ERROR` with +code `AGUI_CREWAI_CONVERSATIONAL_FLOW_UNSUPPORTED`; it never silently falls back +to a regular Flow kickoff. `get_capabilities()["conversationalFlows"]` declares +whether the installed runtime exposes both the required transport and public +turn API. + +The AG-UI dojo presents these as two separate framework choices: + +- `crewai`: **CrewAI Flows**, preserving the existing `/crewai/...` URLs and + including the legacy `crew_chat` example. +- `crewai-conversational-flows`: **CrewAI Conversational Flows**, with the same + Flow feature matrix under `/crewai-conversational-flows/...`; `crew_chat` is + intentionally excluded because it is not a Flow. + ## Features - **Native CrewAI integration** – Direct support for CrewAI flows, crews, and multi-agent systems @@ -111,12 +158,12 @@ saturated buffer degrades mirroring (logged) rather than the run. ### Memory is isolated per `threadId` (default ON) A crew served with `Crew(memory=True)` keeps its memories in one on-disk store, -namespaced by the *crew name*. Nothing in that namespace derives from the AG-UI +namespaced by the _crew name_. Nothing in that namespace derives from the AG-UI `threadId`, so without help every chat served by an endpoint reads and writes the same namespace and one user's remembered facts surface in another user's chat. (Setting `inputs["id"] = thread_id` does not help: that scopes crewai's flow-state persistence, a different subsystem.) `Agent(memory=True)` has the same shape one -level down: the agent builds its *own* memory, which crewai prefers over the +level down: the agent builds its _own_ memory, which crewai prefers over the crew's. The bridge closes that by giving each request a `MemoryScope` view of the crew's @@ -127,7 +174,7 @@ sprawl. Because crewai picks the executing agent off `task.agent` (or `manager_agent` under the hierarchical process) and reaches the crew's memory through -`agent.crew`, the request gets shallow *views* of the crew, its agents and its +`agent.crew`, the request gets shallow _views_ of the crew, its agents and its tasks, wired to each other. Nothing shared between concurrent requests is mutated, and everything below the views (tools, LLMs, knowledge, the store itself) stays shared. @@ -144,7 +191,7 @@ Limitations, in order of how likely you are to hit them: - **Only crews and agents the bridge can reach are scoped.** That means the crew you passed to `add_crewai_crew_fastapi_endpoint`, plus any crew or standalone agent your `Flow` holds as an attribute (a crew's own agents and tasks come - with it). A crew or agent *constructed inside* a flow method is created after + with it). A crew or agent _constructed inside_ a flow method is created after this point and is not scoped; construct it as a flow attribute, or pass it a `Memory` you scope yourself. - **Per-request views are shallow.** Each request runs against copies of the @@ -170,11 +217,11 @@ from ag_ui_crewai import get_capabilities get_capabilities(llm=my_agent.llm, emit_raw_events=True) ``` -`transport`, `rawEvents`, `reasoning` and `crewChat` come from runtime probes; -`humanInTheLoop` and `state` are static declarations of what the bridge implements -today. `emit_raw_events` defaults to re-reading the environment, so pass the same -value your endpoint was registered with if you want the declaration to describe -*that* endpoint. +`transport`, `rawEvents`, `reasoning`, `conversationalFlows`, and `crewChat` come +from runtime probes; `humanInTheLoop` and `state` are static declarations of what +the bridge implements today. `emit_raw_events` defaults to re-reading the +environment, so pass the same value your endpoint was registered with if you want +the declaration to describe _that_ endpoint. Reasoning surfaces as first-class `REASONING_*` events (`REASONING_START` / `REASONING_MESSAGE_START` / `REASONING_MESSAGE_CONTENT` / `REASONING_MESSAGE_END` / diff --git a/integrations/crew-ai/python/ag_ui_crewai/__init__.py b/integrations/crew-ai/python/ag_ui_crewai/__init__.py index 485f319ac5..6eb174cbf7 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/__init__.py +++ b/integrations/crew-ai/python/ag_ui_crewai/__init__.py @@ -14,6 +14,7 @@ copilotkit_stream, copilotkit_exit, ) +from ._responses import copilotkit_responses, responses_channel_available from .a2ui_tool import ( A2UITool, get_a2ui_tools, @@ -46,6 +47,8 @@ "copilotkit_emit_state", "copilotkit_emit_tool_result", "copilotkit_stream", + "copilotkit_responses", + "responses_channel_available", "copilotkit_exit", "A2UITool", "get_a2ui_tools", diff --git a/integrations/crew-ai/python/ag_ui_crewai/_capabilities.py b/integrations/crew-ai/python/ag_ui_crewai/_capabilities.py index 224543aeea..cdfa5ee6f9 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/_capabilities.py +++ b/integrations/crew-ai/python/ag_ui_crewai/_capabilities.py @@ -12,9 +12,10 @@ Posture: "we support that feature; for this specific one you need crewai >= X." -This module is a LEAF: it imports only ``crewai`` / ``litellm`` and the stdlib, -so ``events`` / ``sdk`` / ``endpoint`` / ``crews`` can all import from it at -module-load time without a circular dependency (mirrors ``_env``). +This module is a LEAF: it imports only ``crewai`` / ``litellm``, the stdlib and +the stdlib-only ``_responses_events`` vocabulary, so ``events`` / ``sdk`` / +``endpoint`` / ``crews`` can all import from it at module-load time without a +circular dependency (mirrors ``_env``). """ from __future__ import annotations @@ -24,10 +25,16 @@ import inspect import logging from dataclasses import dataclass, field -from typing import Any +from typing import Any, Dict, Mapping, Tuple from ag_ui.core import EventType +from ._responses_events import ( + EVENT_ROLES, + REQUIRED_ROLES, + role_severity, +) + _LOGGER = logging.getLogger(__name__) @@ -210,6 +217,15 @@ def flow_supports_stream_frames(flow: Any) -> bool: return _stream_frame_available and hasattr(flow, "astream") +def flow_supports_conversational_stream(flow: Any) -> bool: + """Return whether ``flow`` exposes CrewAI's public turn stream API.""" + return ( + _stream_frame_available + and _safe_getattr(flow, "conversational") is True + and callable(_safe_getattr(flow, "stream_turn")) + ) + + # -------------------------------------------------------------------------- # crew-chat helper resolution # -------------------------------------------------------------------------- @@ -259,10 +275,11 @@ def flow_supports_stream_frames(flow: Any) -> bool: # -------------------------------------------------------------------------- # Reasoning resolution # -------------------------------------------------------------------------- -# Two channels carry model reasoning: the litellm streaming delta -# (``reasoning_content`` / ``thinking_blocks`` -- provider-agnostic, always -# available since litellm is a direct dep) and crewai's native -# ``LLMThinkingChunkEvent`` (its Gemini provider, crewai >= 1.10.1). The event +# Three channels carry model reasoning: the litellm chat-completions streaming +# delta (``reasoning_content`` / ``thinking_blocks`` -- provider-agnostic, always +# available since litellm is a direct dep), crewai's native +# ``LLMThinkingChunkEvent`` (its Gemini provider, crewai >= 1.10.1), and the +# OpenAI Responses API (resolved further down). The thinking event # lives at ``crewai.events.types.llm_events`` (1.x) / ``crewai.utilities.events. # llm_events`` (0.x) and is NOT re-exported at the events-package root. Resolved # here (before ``_detect``) so both the capability snapshot and the frame-path @@ -276,10 +293,299 @@ def flow_supports_stream_frames(flow: Any) -> bool: else None ) _thinking_event_available = LLMThinkingChunkEvent is not None -_native_reasoning_event_available = _thinking_event_available -# Reasoning surfacing is available whenever ANY channel is live -- not gated to -# a single provider. The litellm delta channel is effectively always live. -_reasoning_available = _litellm_available or _thinking_event_available + +# Third channel: the OpenAI Responses API. OpenAI's reasoning models expose their +# reasoning SUMMARIES only there -- chat-completions carries none, for any of +# them -- so surfacing an OpenAI trace needs a separate streaming path. +# Availability rests on TWO capability probes (never a litellm version and never a +# model name): the ``aresponses`` entrypoint below, and the event-modelling probe +# further down. A build failing either one reports the channel unsupported and +# callers stay on chat-completions. +# +# ``ResponsesAPIStreamingIteratorBase`` below is resolved INDEPENDENTLY and is +# deliberately NOT part of that decision: ``_responses.is_responses_stream`` +# prefers it for an isinstance check and duck-types the iterator when it is +# absent, so a litellm that relocates the class still streams. It does share the +# ``_litellm_available`` guard, because resolving it imports a litellm SUBMODULE +# (see ``_resolve_responses_iterator_base`` for why that matters). Note the base +# is shared by litellm's SYNC and async iterators, so that predicate gates on +# async-iterability first (the bridge's drivers are async-only). +_RESPONSES_ENTRYPOINT = ( + getattr(litellm, "aresponses", None) if _litellm_available else None +) + + +def _resolve_responses_iterator_base() -> Any: + """Resolve litellm's Responses-API streaming-iterator base class, or ``None``. + + The only probe in this module that imports a litellm SUBMODULE, so it is the + only one that can undo the tolerated litellm failure above. Two guards: + + * Skipped entirely when litellm did not import. Importing + ``litellm.responses.streaming_iterator`` re-executes litellm's top level, + so probing it after ``_litellm_available`` went False would re-raise + whatever broke it and turn the degraded mode the probe above deliberately + allows into a hard failure of ``import ag_ui_crewai``. + * Any other failure is caught HERE rather than by loosening + ``_first_module``, whose narrow ``except (ImportError, ModuleNotFoundError)`` + is load-bearing for the crewai probes: a genuinely broken + ``crewai.events`` must surface instead of reading as "install + crewai>=1.0". Nothing is lost by tolerating it, because a ``None`` base is + an already-supported state -- ``_responses.is_responses_stream`` duck-types + the iterator when the class is absent -- and losing an isinstance + shortcut is never worth failing an import over. Logged, not swallowed. + """ + if not _litellm_available: + return None + try: + module, _ = _first_module(["litellm.responses.streaming_iterator"]) + except Exception: # noqa: BLE001 - an optional probe must not fail the import + _LOGGER.warning( + "ag-ui-crewai could not probe litellm.responses.streaming_iterator; " + "the Responses-API stream check falls back to duck-typing.", + exc_info=True, + ) + return None + if module is None: + return None + return getattr(module, "BaseResponsesAPIStreamingIterator", None) + + +ResponsesAPIStreamingIteratorBase = _resolve_responses_iterator_base() + + +def responses_entrypoint(): + """Return litellm's async Responses-API entrypoint, or ``None``. + + Resolved once at import; callers probe the RETURN VALUE rather than a + version, so an older litellm degrades to the chat-completions channel. + """ + return _RESPONSES_ENTRYPOINT + + +# -------------------------------------------------------------------------- +# Responses event-modelling resolution +# -------------------------------------------------------------------------- +# litellm builds every Responses stream event by looking its ``type`` up in its +# own event-type -> pydantic-model registry. What a build does with a type it has +# NO model for is the difference that decides whether this channel is usable: +# +# * litellm 1.63-1.67 (inside this package's declared ``litellm>=1.60.2`` floor) +# RAISE ``ValueError("Unknown event type: ")`` out of the stream +# iterator, and on those builds the reasoning-summary deltas and the answer +# text delta this channel exists to read are exactly the unknown types. The +# bridge cannot read such a stream at all, so the honest declaration is +# "channel unavailable" and the honest behaviour is for callers to degrade to +# chat-completions -- not to die once per turn on a channel we advertised. +# * Newer builds return their extras-allowing catch-all model instead, so an +# unknown type still arrives with its payload intact and the channel works. +# +# Which one the installed build does is a RUNTIME PROBE: ask the registry for a +# type nothing can possibly have a model for and see whether it answers or +# raises. The registry is also what attributes a parse failure back to an event +# type (``model_roles`` below), which is what keeps that decision off a +# hand-maintained list of model names. +_RESPONSES_EVENT_MODEL_RESOLVERS = [ + # (module, holder class or None, attribute) + ("litellm.llms.openai.responses.transformation", "OpenAIResponsesAPIConfig", + "get_event_model_class"), + ("litellm.llms.base_llm.responses.transformation", "BaseResponsesAPIConfig", + "get_event_model_class"), +] + +#: A type no registry can have a model for, used to ask the installed litellm what +#: it does with an unknown one. Namespaced so it cannot collide with a real event +#: type a future Responses API adds. +_UNKNOWN_EVENT_TYPE_PROBE = "response.__ag_ui_crewai_capability_probe__" + + +def _resolve_responses_event_model_resolver() -> Any: + """Resolve litellm's Responses event-type -> model lookup, or ``None``. + + Resolved by trying each known home in turn, exactly like every other symbol + here: a litellm that re-homes it degrades to ``None`` (see + ``probe_responses_event_modelling`` for what that costs) rather than raising. + """ + # Every candidate home is a litellm SUBMODULE, so importing one re-executes + # litellm's top level. Skipped entirely when litellm did not import, and any + # other failure tolerated HERE, for the same reason as + # ``_resolve_responses_iterator_base``: turning the degraded mode the litellm + # probe deliberately allows into a hard import failure is never worth an + # optional lookup. A ``None`` resolver is an already-supported state. + if not _litellm_available: + return None + for module_name, holder_name, attr in _RESPONSES_EVENT_MODEL_RESOLVERS: + try: + module, _ = _first_module([module_name]) + except Exception: # noqa: BLE001 - an optional probe must not fail the import + _LOGGER.warning( + "ag-ui-crewai could not probe %s for the Responses event-model " + "lookup; the Responses channel reports unavailable.", + module_name, + exc_info=True, + ) + return None + if module is None: + continue + holder = getattr(module, holder_name, None) if holder_name else module + if holder is None: + continue + resolver = _safe_getattr(holder, attr) + if callable(resolver): + return resolver + return None + + +def _resolve_event_model(resolver: Any, event_type: str) -> Any: + """The model class ``resolver`` gives for ``event_type``, or ``None``. + + ``None`` means this build cannot model the type: either the registry raised + (the 1.63-1.67 behaviour) or it answered with nothing usable. + """ + try: + model = resolver(event_type=event_type) + except TypeError: + # A build whose lookup takes the type positionally. + try: + model = resolver(event_type) + except Exception: # noqa: BLE001 - any failure means "cannot model it" + return None + except Exception: # noqa: BLE001 - ValueError on 1.63-1.67, and anything else + return None + return model if isinstance(model, type) else None + + +@dataclass(frozen=True) +class ResponsesEventModelling: + """What the installed litellm can MODEL of the Responses event vocabulary. + + ``tolerates_unknown_types`` + The registry answers for a type it has no model for (the newer builds' + catch-all) instead of raising. + ``unmodellable_event_types`` + The types in ``REQUIRED_ROLES`` this build can neither model nor serve + with a catch-all. Non-empty means the channel cannot be read. + ``model_roles`` + Model class NAME -> the role of the event type litellm builds with it. + This is what attributes a ``ValidationError`` (whose only identifying + signal is the model it attempted) back to a role. A class that serves + several read types -- the catch-all -- carries the most severe of their + roles, so a catch-all failure is never treated as cheaper than the + worst event it could have been. + ``resolver_available`` + Whether the registry could be resolved at all. Without it nothing can be + attributed, and an unattributable parse failure is reported rather than + assumed harmless. + """ + + resolver_available: bool + tolerates_unknown_types: bool + unmodellable_event_types: Tuple[str, ...] + model_roles: Mapping[str, str] + + @property + def usable(self) -> bool: + """Whether every event type the channel needs can be modelled.""" + return not self.unmodellable_event_types + + +def probe_responses_event_modelling(resolver: Any) -> ResponsesEventModelling: + """Probe what ``resolver``'s litellm can model of the event vocabulary. + + Pure: it only performs registry lookups (no network, no model construction), + and everything it reports is derived from ``_responses_events.EVENT_ROLES`` + plus litellm's own answers. A missing resolver reports the channel usable: + losing an internal lookup symbol is not evidence that the public streaming + behaviour changed, and refusing the channel over it would break the feature + on a future build for no reason. + """ + if resolver is None: + return ResponsesEventModelling( + resolver_available=False, + tolerates_unknown_types=False, + unmodellable_event_types=(), + model_roles={}, + ) + + catch_all = _resolve_event_model(resolver, _UNKNOWN_EVENT_TYPE_PROBE) + model_roles: Dict[str, str] = {} + unmodellable: list[str] = [] + for event_type, role in EVENT_ROLES.items(): + model = _resolve_event_model(resolver, event_type) + if model is None: + if role in REQUIRED_ROLES: + unmodellable.append(event_type) + continue + name = getattr(model, "__name__", None) + if not name: + continue + if role_severity(role) > role_severity(model_roles.get(name)): + model_roles[name] = role + return ResponsesEventModelling( + resolver_available=True, + tolerates_unknown_types=catch_all is not None, + unmodellable_event_types=tuple(sorted(unmodellable)), + model_roles=model_roles, + ) + + +def _responses_channel_usable(modelling: ResponsesEventModelling) -> bool: + """The channel-availability rule, in ONE place. + + Both probes must pass: litellm has to expose the entrypoint that opens the + stream, and it has to be able to model the events the stream carries. + """ + return callable(_RESPONSES_ENTRYPOINT) and modelling.usable + + +_RESPONSES_EVENT_MODEL_RESOLVER = _resolve_responses_event_model_resolver() +_RESPONSES_EVENT_MODELLING = probe_responses_event_modelling( + _RESPONSES_EVENT_MODEL_RESOLVER +) +_responses_api_available = _responses_channel_usable(_RESPONSES_EVENT_MODELLING) + + +def responses_event_modelling() -> ResponsesEventModelling: + """The current Responses event-modelling probe result.""" + return _RESPONSES_EVENT_MODELLING + + +def refresh_responses_channel_probe() -> None: + """Re-run the Responses probes from the currently resolved litellm symbols. + + The import-time run is the production path. This exists so a caller that + substitutes the resolver (a test standing in a litellm build that raises for + unknown event types) re-derives availability through the SAME rule the import + path uses, instead of restating it. + """ + global _RESPONSES_EVENT_MODELLING, _responses_api_available + _RESPONSES_EVENT_MODELLING = probe_responses_event_modelling( + _RESPONSES_EVENT_MODEL_RESOLVER + ) + _responses_api_available = _responses_channel_usable(_RESPONSES_EVENT_MODELLING) + + +def any_reasoning_channel( + *, + litellm_available: bool, + thinking_event_available: bool, + responses_api_available: bool, +) -> bool: + """Whether reasoning can surface at all, given which channels resolved. + + Reasoning is available whenever ANY channel is live, never gated to one + provider or one transport: a build with only the native thinking event, or + only the Responses API, still surfaces REASONING_*. Kept as one predicate so + the declaration cannot drift back to a single-channel gate. + """ + return litellm_available or thinking_event_available or responses_api_available + + +#: ``reasoning.reason`` when the capability is unavailable. Reasoning drops out +#: only when ALL THREE channels are absent (no litellm delta, no native thinking +#: event, no Responses API), so the reason names that condition rather than +#: blaming any single channel. +NO_REASONING_CHANNEL = "no_reasoning_channel_available" # -------------------------------------------------------------------------- @@ -302,6 +608,11 @@ def flow_supports_stream_frames(flow: Any) -> bool: _CREWAI_MODULE, _ = _first_module(["crewai"]) _Flow = getattr(_CREWAI_MODULE, "Flow", None) if _CREWAI_MODULE else None _Crew = getattr(_CREWAI_MODULE, "Crew", None) if _CREWAI_MODULE else None +_conversational_stream_available = bool( + _stream_frame_available + and _Flow is not None + and callable(_safe_getattr(_Flow, "stream_turn")) +) # ``BaseAgent`` is the base every crewai agent derives from, including a user's # own subclass, so it is the wider net for "this attribute is an agent". @@ -625,11 +936,17 @@ class _Capabilities: crew_chat_module: str | None crew_chat_available: bool litellm_available: bool - # Reasoning: available whenever ANY channel is live (litellm delta or the - # native thinking event), never gated to a single provider. Surfaced for - # the protocol capability table. + # Reasoning: available whenever ANY channel is live (litellm delta, the + # native thinking event, or the Responses API), never gated to a single + # provider. Surfaced for the protocol capability table. reasoning_available: bool = False native_reasoning_event_available: bool = False + # ``responses_api_available`` is the CHANNEL's availability: litellm exposes + # the entrypoint AND can model every event type the channel needs. + # ``responses_unmodellable_event_types`` names the types a build cannot model + # (empty on every build that can), so the INFO note below can say which ones. + responses_api_available: bool = False + responses_unmodellable_event_types: tuple[str, ...] = () stream_frame_available: bool = False # Checkpointing: informational; the wiring keys off the resolved # symbols / ``flow_supports_checkpointing`` per-flow probe, not these fields. @@ -688,6 +1005,20 @@ def warn_on_gaps(self) -> None: "or crewai[litellm].", self.crewai_version, ) + if self.responses_unmodellable_event_types: + # NOT a hard gap: reasoning still surfaces on the chat-completions + # channel for every provider that carries it there, and the flow + # examples degrade on the probe. Named at INFO so an operator who + # wanted an OpenAI trace learns WHY the channel reports unavailable. + _LOGGER.info( + "ag-ui-crewai: the installed litellm cannot model these OpenAI " + "Responses stream event types (%s), so the Responses channel " + "reports unavailable and callers stay on chat-completions " + "(which carries no OpenAI reasoning summaries). Upgrade litellm " + "to a build that maps an unknown event type onto its generic " + "event model instead of raising.", + ", ".join(self.responses_unmodellable_event_types), + ) if not self.stream_frame_available: # NOT a hard gap — the legacy bus-listener path still works. Emit # an INFO-level note (not a WARNING) so operators on 1.0-1.5 know @@ -741,8 +1072,18 @@ def _detect() -> _Capabilities: crew_chat_module=_CREW_CHAT_MODULE_NAME, crew_chat_available=_crew_chat_available, litellm_available=_litellm_available, - reasoning_available=_reasoning_available, - native_reasoning_event_available=_native_reasoning_event_available, + # Recomputed from the live probes (not the import-time constant) so the + # snapshot always reflects every channel that actually resolved. + reasoning_available=any_reasoning_channel( + litellm_available=_litellm_available, + thinking_event_available=_thinking_event_available, + responses_api_available=_responses_api_available, + ), + native_reasoning_event_available=_thinking_event_available, + responses_api_available=_responses_api_available, + responses_unmodellable_event_types=( + _RESPONSES_EVENT_MODELLING.unmodellable_event_types + ), stream_frame_available=_stream_frame_available, checkpoint_config_available=_checkpoint_config_available, checkpointing_available=_checkpointing_available, @@ -771,9 +1112,9 @@ def _detect() -> _Capabilities: # -------------------------------------------------------------------------- # Native-Gemini resolution (informational reasoning fields) # -------------------------------------------------------------------------- -# The thinking-chunk event class + availability flags are resolved ONCE above -# (before ``_detect``). Reasoning is now surfaced provider-agnostically via the -# litellm channel and the native event, so ``get_capabilities`` no longer gates +# The thinking-chunk event class + its single availability flag are resolved ONCE +# above (before ``_detect``). Reasoning is now surfaced provider-agnostically via +# the litellm channel and the native event, so ``get_capabilities`` no longer gates # reasoning on a native-Gemini LLM. The resolver below stays only to populate # the informational ``nativeGeminiProvider`` / ``resolvedProvider`` fields: the # native ``LLMThinkingChunkEvent`` (verified on the 1.15.7 wheel, emitted only by @@ -901,34 +1242,62 @@ def _is_native_gemini(llm: Any) -> bool: def _reasoning_capability(llm: Any = None) -> dict: """Build the ``reasoning`` block of the capability declaration. - Reasoning surfaces as first-class ``REASONING_*`` events, provider-agnostic - and on BOTH transports: ``copilotkit_stream`` reads the litellm delta's - ``reasoning_content`` / ``thinking_blocks`` for any reasoning-capable model - (deepseek-reasoner, Anthropic extended thinking, Bedrock, xAI, - gemini-via-litellm, ...), and crewai's native Gemini provider additionally - emits ``LLMThinkingChunkEvent`` on the StreamFrame path. It needs NEITHER - ``emit_raw_events`` NOR the StreamFrame transport. + Reasoning surfaces as first-class ``REASONING_*`` events, provider-agnostic, + over three channels. Transport reality differs PER CHANNEL: + + * litellm chat-completions delta (``copilotkit_stream`` reads + ``reasoning_content`` / ``thinking_blocks`` for any reasoning-capable model: + deepseek-reasoner, Anthropic extended thinking, Bedrock, xAI, + gemini-via-litellm, ...) and the OpenAI Responses API + (``copilotkit_responses``, the ONLY place OpenAI's reasoning models expose + their reasoning summaries): both emit Bridged reasoning events on the event + bus, which BOTH transports handle -- the StreamFrame path and the legacy + bus-listener path. + * crewai's native Gemini ``LLMThinkingChunkEvent``: StreamFrame-ONLY. The + only thing that turns it into ``REASONING_*`` is the frame-path scoped sink + gate plus the frame translator; the legacy bus-listener path has no handler + for it. + + No channel needs ``emit_raw_events``: reasoning is a mapped channel, never RAW + passthrough. ``supported`` describes the bridge capability, not whether a given model will actually reason: a non-reasoning model simply emits nothing (graceful - no-op). It is therefore True whenever a reasoning channel is live -- the - litellm channel is effectively always live (a direct dep). + no-op). It is True whenever ANY channel is live -- the litellm channel is + effectively always live (a direct dep). + + Every channel field is read from the ONE frozen ``CAPABILITIES`` snapshot, and + ``supported`` / ``reason`` are DERIVED from the three fields the block itself + publishes, so the block cannot advertise a channel it also reports absent (or + claim support with every channel dark). ``nativeGeminiProvider`` / ``resolvedProvider`` are informational: the native event is an EXTRA source, not a requirement. """ resolved = _resolve_llm(llm) + # Provider-agnostic path, on both transports (always live when litellm is + # installed, which it is as a direct dependency). + litellm_channel = CAPABILITIES.litellm_available + # crewai's native Gemini thinking event: an extra, StreamFrame-only source. + thinking_event = CAPABILITIES.native_reasoning_event_available + # OpenAI Responses API: the only channel that carries OpenAI reasoning + # summaries. Capability-probed, not version- or model-name-gated. + responses_channel = CAPABILITIES.responses_api_available + supported = any_reasoning_channel( + litellm_available=litellm_channel, + thinking_event_available=thinking_event, + responses_api_available=responses_channel, + ) return { - "supported": CAPABILITIES.reasoning_available, - # Provider-agnostic path (always live when litellm is installed). - "litellmChannel": CAPABILITIES.litellm_available, - # crewai's native Gemini thinking event: an extra frame-path source. - "thinkingEventAvailable": _thinking_event_available, + "supported": supported, + "litellmChannel": litellm_channel, + "thinkingEventAvailable": thinking_event, + "responsesApiChannel": responses_channel, "nativeGeminiProvider": _is_native_gemini(resolved), # A caller object: a raising property here would escape the whole query. "resolvedProvider": _safe_getattr(resolved, "provider"), # First-class REASONING_* mapping: reasoning does NOT ride RAW passthrough. "requiresEmitRawEvents": False, - "reason": None if CAPABILITIES.reasoning_available else "litellm_unavailable", + "reason": None if supported else NO_REASONING_CHANNEL, } @@ -943,13 +1312,14 @@ def get_capabilities( Mirrors the shape of ``ag_ui_langgraph.LangGraphAgent.get_capabilities`` (``identity`` / ``humanInTheLoop`` / ``state`` / ``transport``) and adds the CrewAI-specific blocks the parity lane needs: the resolved wire shape, RAW - passthrough, and reasoning. + passthrough, reasoning, and Conversational Flow transport. No field is derived from ``crewai.__version__`` - the version string appears only as informational ``crewaiVersion`` metadata (same rule as the rest of this - module). Within that, ``transport`` / ``rawEvents`` / ``reasoning`` / ``crewChat`` - come from runtime probes, while ``humanInTheLoop`` and ``state`` are static - declarations of what the bridge implements today. + module). Within that, ``transport`` / ``rawEvents`` / ``reasoning`` / + ``conversationalFlows`` / ``crewChat`` come from runtime probes, while + ``humanInTheLoop`` and ``state`` are static declarations of what the bridge + implements today. ``emission_shape`` / ``emit_raw_events`` default to re-reading the environment, so a declaration fetched without arguments can disagree with an endpoint that @@ -1054,5 +1424,10 @@ def get_capabilities( "default": DEFAULT_EMIT_RAW_EVENTS, }, "reasoning": _reasoning_capability(llm), + "conversationalFlows": { + "supported": _conversational_stream_available, + "entrypoint": "stream_turn", + "sessionId": "threadId", + }, "crewChat": {"supported": CAPABILITIES.crew_chat_available}, } diff --git a/integrations/crew-ai/python/ag_ui_crewai/_conversation.py b/integrations/crew-ai/python/ag_ui_crewai/_conversation.py new file mode 100644 index 0000000000..212b3fae11 --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/_conversation.py @@ -0,0 +1,242 @@ +"""CrewAI Conversational Flow turn and stream adaptation helpers.""" + +from __future__ import annotations + +import asyncio +import contextvars +from dataclasses import dataclass +import logging +import threading +from typing import Any, Sequence + +from pydantic import BaseModel + +from .utils import dump_agui_message + + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ConversationalTurn: + """One textual turn plus the history that must precede it.""" + + message: str + history: list[dict[str, Any]] + current_media: list[dict[str, Any]] + + +def prepare_conversational_turn(messages: Sequence[Any]) -> ConversationalTurn: + """Prepare one public ``stream_turn`` invocation from AG-UI history.""" + dumped = [dump_agui_message(message) for message in messages] + current_index = ( + len(dumped) - 1 if dumped and dumped[-1].get("role") == "user" else None + ) + + if current_index is None: + history = [message for message in dumped if message.get("role") != "system"] + return ConversationalTurn(message="", history=history, current_media=[]) + + history = [ + message for message in dumped[:current_index] if message.get("role") != "system" + ] + content = dumped[current_index].get("content") + if isinstance(content, str): + return ConversationalTurn( + message=content, + history=history, + current_media=[], + ) + + text_parts: list[str] = [] + media_parts: list[dict[str, Any]] = [] + if isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") == "text": + text = part.get("text") + if isinstance(text, str): + text_parts.append(text) + else: + media_parts.append(part) + + return ConversationalTurn( + message="\n".join(text_parts), + history=history, + current_media=media_parts, + ) + + +def hydrate_conversational_flow( + flow: Any, + inputs: dict[str, Any], + turn: ConversationalTurn, +) -> dict[str, Any]: + """Seed regular AG-UI inputs before ``stream_turn`` adds current text.""" + seeded_messages = list(turn.history) + if turn.current_media: + seeded_messages.append({"role": "user", "content": list(turn.current_media)}) + hydrated = {**inputs, "messages": seeded_messages} + + state = getattr(flow, "_state", None) + if isinstance(state, dict): + state.update(hydrated) + return hydrated + if isinstance(state, BaseModel): + current = state.model_dump() + object.__setattr__( + flow, + "_state", + type(state).model_validate({**current, **hydrated}), + ) + return hydrated + raise TypeError("Conversational Flow state must be a mapping or Pydantic model") + + +class _InputOverlayPersistence: + """Overlay AG-UI request state onto a CrewAI persistence restore.""" + + def __init__(self, persistence: Any, inputs: dict[str, Any]): + self._persistence = persistence + self._inputs = {key: value for key, value in inputs.items() if key != "id"} + + def load_state(self, flow_uuid: str) -> dict[str, Any] | None: + stored = self._persistence.load_state(flow_uuid) + if stored is None: + return None + return {**stored, **self._inputs} + + def __getattr__(self, name: str) -> Any: + return getattr(self._persistence, name) + + +def overlay_conversational_persistence( + flow: Any, + inputs: dict[str, Any], +) -> None: + """Make incoming AG-UI state win after CrewAI restores a session.""" + persistence = getattr(flow, "persistence", None) + if persistence is None: + return + object.__setattr__( + flow, + "persistence", + _InputOverlayPersistence(persistence, inputs), + ) + + +def force_per_turn_trace_finalization(flow: Any) -> None: + """Make each AG-UI request own a complete CrewAI flow trace lifecycle.""" + object.__setattr__(flow, "defer_trace_finalization", False) + definition_factory = getattr(type(flow), "flow_definition", None) + definition = definition_factory() if callable(definition_factory) else None + conversational = getattr(definition, "conversational", None) + if conversational is not None and hasattr( + conversational, + "defer_trace_finalization", + ): + conversational.defer_trace_finalization = False + + config = getattr(type(flow), "conversational_config", None) + if config is not None and hasattr(config, "defer_trace_finalization"): + config.defer_trace_finalization = False + + +class SyncStreamSessionAdapter: + """Expose CrewAI's synchronous ``StreamSession`` as an async iterator.""" + + def __init__(self, session: Any): + self._session = session + self._queue: asyncio.Queue[tuple[str, Any]] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._stop = threading.Event() + self._cooperative_stop_logged = False + + def __aiter__(self): + return self._iterate() + + def _start(self) -> None: + if self._thread is not None: + return + self._loop = asyncio.get_running_loop() + self._queue = asyncio.Queue() + context = contextvars.copy_context() + + def publish(kind: str, value: Any = None) -> None: + if self._loop is None or self._queue is None: + return + try: + self._loop.call_soon_threadsafe( + self._queue.put_nowait, + (kind, value), + ) + except RuntimeError: + # The request loop already closed; no consumer remains to notify. + return + + def produce() -> None: + try: + for frame in self._session: + if self._stop.is_set(): + break + publish("item", frame) + except Exception as exc: # noqa: BLE001 - cross thread boundary + publish("error", exc) + finally: + close = getattr(self._session, "close", None) + if callable(close): + try: + close() + except Exception: # noqa: BLE001 - teardown boundary + _LOGGER.exception( + "ag-ui-crewai failed to close a conversational " + "StreamSession after its worker stopped" + ) + publish("done") + + self._thread = threading.Thread( + target=context.run, + args=(produce,), + daemon=True, + name="ag-ui-crewai-conversation-stream", + ) + self._thread.start() + + async def _iterate(self): + self._start() + assert self._queue is not None + try: + while True: + kind, value = await self._queue.get() + if kind == "item": + yield value + elif kind == "error": + raise value + else: + return + finally: + await self.aclose() + + async def aclose(self) -> None: + """Request a cooperative stop without blocking the request loop. + + CrewAI's synchronous generator cannot be closed safely from this event- + loop thread while its worker is executing (Python raises ``ValueError: + generator already executing``). A blocked provider turn may therefore + continue until it emits or returns; make that limitation observable. + """ + self._stop.set() + if self._thread is None: + close = getattr(self._session, "close", None) + if callable(close): + close() + elif self._thread.is_alive() and not self._cooperative_stop_logged: + _LOGGER.warning( + "ag-ui-crewai requested cooperative cancellation of a " + "conversational StreamSession; the CrewAI sync worker remains " + "active until its current upstream operation emits or returns", + extra={"worker_thread": self._thread.name}, + ) + self._cooperative_stop_logged = True diff --git a/integrations/crew-ai/python/ag_ui_crewai/_reasoning.py b/integrations/crew-ai/python/ag_ui_crewai/_reasoning.py index 8d88fb3a33..407a0aa835 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/_reasoning.py +++ b/integrations/crew-ai/python/ag_ui_crewai/_reasoning.py @@ -1,21 +1,28 @@ """Provider-agnostic reasoning extraction for the CrewAI AG-UI bridge. -Two channels carry model reasoning to the bridge and both funnel through the +Three channels carry model reasoning to the bridge and all funnel through the helpers here: -* the litellm streaming delta (``copilotkit_stream``): ``delta.reasoning_content`` - (a string; o1/o3, deepseek-reasoner, and most reasoning models normalised by - litellm) and ``delta.thinking_blocks`` (Anthropic extended thinking: ``thinking`` - text + ``signature``, and ``redacted_thinking`` blocks carrying encrypted - ``data``). ``reasoning_from_delta`` projects one delta onto text + encrypted - blobs; ``reasoning_content`` wins as the text source per delta, since litellm - mirrors the same text into a thinking block for Anthropic. +* the litellm chat-completions streaming delta (``copilotkit_stream``): + ``delta.reasoning_content`` (a string; o1/o3, deepseek-reasoner, and most + reasoning models normalised by litellm) and ``delta.thinking_blocks`` + (Anthropic extended thinking: ``thinking`` text + ``signature``, and + ``redacted_thinking`` blocks carrying encrypted ``data``). + ``reasoning_from_delta`` projects one delta onto text + encrypted blobs; + ``reasoning_content`` wins as the text source per delta, since litellm mirrors + the same text into a thinking block for Anthropic. * crewai's native ``LLMThinkingChunkEvent`` (its Gemini provider, crewai >= 1.10.1), whose text rides on the ``chunk`` attribute. ``is_thinking_event`` / ``thinking_event_text`` read it by ``type`` string so the frame translator stays decoupled from importing crewai. - -This module is a LEAF: it imports only the stdlib, so ``sdk`` / ``_frames`` can +* the OpenAI Responses-API stream (``copilotkit_responses``), whose reasoning + summaries never appear on the chat-completions delta at all. + ``reasoning_from_responses_event`` projects one Responses stream event onto + the same ``DeltaReasoning``, so all three channels share one shape and one + emission lifecycle. + +This module is a LEAF: it imports only the stdlib and the stdlib-only +``_responses_events`` vocabulary, so ``sdk`` / ``_frames`` / ``_responses`` can import it at module-load time without a circular dependency. """ @@ -24,6 +31,11 @@ from dataclasses import dataclass, field from typing import Any +from ._responses_events import ( + RESPONSES_OUTPUT_ITEM_DONE, + RESPONSES_REASONING_TEXT_DELTAS, +) + # crewai's native thinking-chunk event ``type`` discriminator (its Gemini # provider emits it via ``BaseLLM._emit_thinking_chunk_event``, crewai # >= 1.10.1). Matched by string so this stays importable without crewai. @@ -105,3 +117,64 @@ def reasoning_from_delta(delta: Any) -> DeltaReasoning: encrypted.append(str(signature)) return DeltaReasoning(text="".join(text_parts), encrypted=tuple(encrypted)) + + +# -------------------------------------------------------------------------- +# OpenAI Responses-API channel +# -------------------------------------------------------------------------- +# OpenAI streams reasoning SUMMARIES only over the Responses API; the +# chat-completions delta above carries none for its reasoning models. The event +# ``type`` discriminators below are matched as STRINGS rather than against +# litellm's ``ResponsesAPIStreamEvents`` enum: a litellm build that predates an +# event type maps it onto its extras-allowing ``GenericEvent``, so the payload +# still arrives on ``.delta`` / ``.item`` and reading the string keeps the +# projection working on old and new builds alike. +# +# The two type strings this projection reads are imported from +# ``_responses_events``, which keeps every Responses type next to the ROLE it +# plays for this bridge: ``RESPONSES_REASONING_TEXT_DELTAS`` are the +# reasoning-summary deltas (``summary_text`` is what ``reasoning.summary`` +# produces, ``reasoning_text`` the raw variant some models emit), and +# ``RESPONSES_OUTPUT_ITEM_DONE`` is the completed output item carrying the +# encrypted reasoning blob when the caller asked for +# ``include=["reasoning.encrypted_content"]``. + + +def responses_event_type(event: Any) -> str | None: + """Return a Responses stream event's ``type`` as a plain string. + + litellm types the field as a ``str``-mixin enum on the events it knows and + as a plain string on ``GenericEvent``; normalise both to the wire string. + """ + raw = getattr(event, "type", None) + if raw is None: + return None + return str(getattr(raw, "value", raw)) + + +def reasoning_from_responses_event(event: Any) -> DeltaReasoning: + """Project one OpenAI Responses-API stream event onto its reasoning content. + + A summary/reasoning text delta yields ``text``; a finished ``reasoning`` + output item yields its ``encrypted_content`` as an encrypted blob. Every + other event is a no-op, so a non-reasoning model simply emits nothing. + """ + event_type = responses_event_type(event) + if event_type is None: + return DeltaReasoning() + + if event_type in RESPONSES_REASONING_TEXT_DELTAS: + delta = getattr(event, "delta", None) + if isinstance(delta, str) and delta: + return DeltaReasoning(text=delta) + return DeltaReasoning() + + if event_type == RESPONSES_OUTPUT_ITEM_DONE: + item = getattr(event, "item", None) + if not isinstance(item, dict) or item.get("type") != "reasoning": + return DeltaReasoning() + encrypted = item.get("encrypted_content") + if encrypted: + return DeltaReasoning(encrypted=(str(encrypted),)) + + return DeltaReasoning() diff --git a/integrations/crew-ai/python/ag_ui_crewai/_responses.py b/integrations/crew-ai/python/ag_ui_crewai/_responses.py new file mode 100644 index 0000000000..6a37da3cc2 --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/_responses.py @@ -0,0 +1,728 @@ +"""OpenAI Responses-API streaming channel for the CrewAI AG-UI bridge. + +``copilotkit_stream`` streams litellm chat-completions, which carries no +reasoning content for OpenAI's reasoning models: OpenAI exposes reasoning +summaries ONLY through the Responses API. ``copilotkit_responses`` opens that +stream instead, and ``copilotkit_stream`` consumes it through the same +``Bridged*`` emission path, so REASONING_* / TEXT_MESSAGE_CHUNK / +TOOL_CALL_CHUNK reach BOTH transports (legacy bus listener and StreamFrame) +unchanged. + +This module owns the two pure conversions the channel needs, plus the +entrypoint: + +* ``chat_messages_to_responses_input`` -- flow-state messages (chat-completions + shape: ``role`` / ``content`` / ``tool_calls`` / ``tool_call_id``) onto + Responses ``input`` items (``function_call`` / ``function_call_output``). +* ``chat_tools_to_responses_tools`` -- nested ``{"type": "function", + "function": {...}}`` tool specs onto the Responses flat shape. + +Availability is a pair of RUNTIME CAPABILITY PROBES, never a litellm version or +model-name comparison: ``responses_channel_available()`` answers whether the +channel can be used at all (see its docstring for the two probes), and callers +fall back to chat-completions when it cannot. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, AsyncIterator, Dict, Iterable, List, Optional, Set, Tuple + +from pydantic import ValidationError + +from ._capabilities import ( + CAPABILITIES, + ResponsesAPIStreamingIteratorBase, + responses_entrypoint, + responses_event_modelling, +) +from ._responses_events import event_role, is_load_bearing + +_LOGGER = logging.getLogger(__name__) + +# Cap on events skipped for being unparseable in one stream. Past this the stream +# is not "one odd event that costs nothing" but something systemically wrong, and +# reporting it beats silently returning an empty message. +_MAX_SKIPPED_EVENTS = 50 + +#: Roles a Responses ``input`` message item accepts verbatim. +_INPUT_MESSAGE_ROLES = frozenset({"user", "assistant", "system", "developer"}) + +#: litellm 1.63-1.67 raise ``ValueError("Unknown event type: ")`` from their +#: event-type lookup for a type they have no model for (newer builds answer with +#: their extras-allowing catch-all model instead). Matched case-insensitively on +#: the ORIGINAL message so the captured type is the one litellm wrote, and +#: anchored on the colon so a message that names no type does not read as one. +_UNKNOWN_EVENT_TYPE_RE = re.compile(r"unknown event type\s*:\s*(\S+)", re.IGNORECASE) +_UNKNOWN_EVENT_TYPE_MARKER_RE = re.compile(r"unknown event type", re.IGNORECASE) + +# What to do with a per-event parse failure. +#: This event costs nothing this bridge maps: log it and keep reading. +_SKIP = "skip" +#: This event carried content or the stream's outcome: report it. +_SURFACE = "surface" +#: The event cannot be identified at all, so it cannot be shown harmless. +_UNATTRIBUTABLE = "unattributable" +#: This litellm build has no model for an event type the bridge must read, which +#: is a property of the BUILD rather than of this event. +_UNREADABLE_TYPE = "unreadable_type" +#: Not litellm's event parsing at all: leave it alone. +_PROPAGATE = "propagate" + + +def _classify_parse_failure(exc: ValueError) -> Tuple[str, str]: + """Decide the fate of one event that failed to parse, and name what failed. + + Parsing is what failed, so there is no event object to read a ``type`` off. + Two signals remain, and each is turned into that event's ROLE for this bridge + (``_responses_events.EVENT_ROLES``) instead of being judged against a list of + types or model names maintained here: + + * ``pydantic.ValidationError`` -- litellm had a model for the type and could + not build it. ``title`` is that model's name, and litellm's OWN event-type + to model registry maps it back onto a role + (``ResponsesEventModelling.model_roles``). A load-bearing role surfaces; a + role whose loss costs nothing this bridge maps is skipped; a model that + registry does not attribute to any type this bridge reads means nothing + this bridge maps was lost, so it is skipped too. When the registry could + not be resolved at all, nothing can be attributed and the failure is + reported rather than assumed harmless. + * a plain ``ValueError`` carrying litellm's "Unknown event type: " + lookup failure -- the type is in the message. A type this bridge never + reads costs nothing to skip. A type it reads means THIS BUILD cannot read + this channel: reported as the build fact it is, not as a corrupt event. + A message that names no type cannot be judged either way. + + Anything else that merely happens to be a ``ValueError`` is not litellm's + event parsing (``json.JSONDecodeError`` from a truncated SSE frame is a + ``ValueError`` too) and propagates untouched. + + Returns ``(disposition, subject)``, where ``subject`` names the model or the + event type for the log line or the error message. + """ + if isinstance(exc, ValidationError): + modelling = responses_event_modelling() + role = modelling.model_roles.get(exc.title) + if role is None: + if not modelling.resolver_available: + return _UNATTRIBUTABLE, exc.title + return _SKIP, exc.title + return (_SURFACE if is_load_bearing(role) else _SKIP), exc.title + + message = str(exc) + match = _UNKNOWN_EVENT_TYPE_RE.search(message) + if match is None: + if _UNKNOWN_EVENT_TYPE_MARKER_RE.search(message): + # litellm's lookup failure with no type in it: nothing identifies + # the event, so it cannot be shown harmless to drop. + return _UNATTRIBUTABLE, message + return _PROPAGATE, message + event_type = match.group(1) + role = event_role(event_type) + if role is None: + return _SKIP, event_type + if is_load_bearing(role): + return _UNREADABLE_TYPE, event_type + # A reasoning delta or the optional encrypted-blob item: this build cannot + # read it, and the answer plus the outcome are unaffected. The channel is + # declared UNAVAILABLE on such a build (see ``responses_channel_available``), + # so a caller reaching here opened the stream without probing. + return _SKIP, event_type + + +#: Roles whose content rides ``input_text`` / ``input_image`` parts. ``assistant`` +#: is deliberately absent: see ``_assistant_content_text``. +_INPUT_PART_ROLES = frozenset({"user", "system", "developer"}) + + +def responses_channel_available() -> bool: + """Whether the OpenAI Responses streaming channel can be used. + + Two runtime capability probes, never a litellm version compare and never a + model-name branch: + + * litellm exposes a callable ``aresponses`` entrypoint to open the stream, and + * litellm can MODEL every Responses event type this bridge must read. litellm + 1.63-1.67 (inside this package's declared ``litellm>=1.60.2`` floor) raise + ``ValueError("Unknown event type: ")`` for a type they have no model + for, and on those builds the reasoning-summary deltas and the answer text + delta this channel exists to read are exactly those types. Such a stream + cannot be read at all, so the channel reports unavailable there rather than + failing once per turn on a channel we advertised as working. + + False means callers must stay on chat-completions. + """ + return CAPABILITIES.responses_api_available + + +def is_responses_stream(response: Any) -> bool: + """Whether ``response`` is an ASYNC litellm Responses-API streaming iterator. + + Async-iterability is checked FIRST, for both branches. litellm's + ``SyncResponsesAPIStreamingIterator`` subclasses the very same + ``BaseResponsesAPIStreamingIterator`` the async iterator does, so an + isinstance-only branch would accept a sync stream that the async driver + (``iter_responses_events`` calls ``__aiter__``) cannot consume. Requiring + ``__aiter__`` up front also keeps this branch and the duck-typed one from + disagreeing about what qualifies. + + Past that gate: an isinstance check against the resolved base class, falling + back to duck-typing (the iterator's own ``_process_chunk``) so a litellm that + relocates the class still works. + """ + if not hasattr(response, "__aiter__"): + return False + base = ResponsesAPIStreamingIteratorBase + if base is not None and isinstance(response, base): + return True + return hasattr(response, "_process_chunk") + + +def is_sync_responses_stream(response: Any) -> bool: + """Whether ``response`` is a SYNCHRONOUS Responses-API streaming iterator. + + The exact complement of ``is_responses_stream`` over Responses iterators: + something recognisably a Responses stream that has no ``__aiter__``. Kept as + its own predicate so a caller who reached for the sync entrypoint gets told + which entrypoint to use instead of a generic "unsupported type". + + Same two branches, same async-iterability gate first, so the two predicates + cannot both answer True. + """ + if hasattr(response, "__aiter__"): + return False + base = ResponsesAPIStreamingIteratorBase + if base is not None and isinstance(response, base): + return True + return hasattr(response, "__iter__") and hasattr(response, "_process_chunk") + + +async def iter_responses_events(response: Any) -> AsyncIterator[Any]: + """Yield Responses stream events, skipping one that costs nothing to lose. + + litellm validates each event against its own typed models, so a single event + can fail to parse and raise straight out of ``__anext__``, which would void + the whole turn: no reasoning, no answer, just a RUN_ERROR. Whether that is + the right outcome depends entirely on WHICH event it was, so each failure is + attributed to the role that event plays for this bridge + (``_responses_events``) and: + + * an event whose loss costs nothing this bridge maps is SKIPPED with a + warning: stream bookkeeping (``response.created`` / + ``response.in_progress``, whose fields all have fallbacks), one + reasoning-summary delta (a gap in a trace, with the answer and the outcome + intact), the optional encrypted-reasoning item, and any event of a type this + bridge never reads at all. + * an event carrying answer text, a tool call's identity or arguments, or the + stream's outcome is REPORTED as a ``RuntimeError`` the drivers' exception + taxonomy surfaces. Dropping one loses content or turns a failed stream into + an empty assistant message with no failure recorded, which is precisely the + bug this attribution exists to prevent. + * a failure that cannot be attributed to any event is reported too: it cannot + be shown harmless, so it is not assumed to be. + * litellm having NO model for a type this bridge must read is reported as the + BUILD fact it is (the channel cannot be read on this build), not as a + corrupt event. ``responses_channel_available()`` reports such a build + unavailable, so callers that probe it degrade to chat-completions instead + of reaching here at all. + + Transport failures, cancellation and any other non-parse error propagate + untouched. + + EVERY skip counts against ``_MAX_SKIPPED_EVENTS``, so a stream that is + unreadable end to end raises rather than quietly yielding an empty turn. + """ + iterator = response.__aiter__() + skipped = 0 + while True: + try: + event = await iterator.__anext__() + except StopAsyncIteration: + return + except ValueError as exc: + # Both litellm parse failures are ValueErrors: pydantic's + # ValidationError subclasses it, and the unknown-event-type lookup + # raises it directly. Transport errors (httpx, ConnectionError) and + # asyncio.CancelledError are not ValueErrors and never land here. + disposition, subject = _classify_parse_failure(exc) + if disposition == _PROPAGATE: + raise + if disposition == _UNREADABLE_TYPE: + raise RuntimeError( + "The installed litellm has no model for the OpenAI Responses " + f"stream event type {subject!r}, which this bridge reads for " + "answer text, tool-call arguments or the stream's outcome, so " + "this stream cannot be read on this build. Probe " + "responses_channel_available() and stream over " + "chat-completions instead, or upgrade litellm." + ) from exc + if disposition == _SURFACE: + raise RuntimeError( + "An OpenAI Responses stream event this bridge reads for answer " + "text, tool-call arguments or the stream's outcome failed to " + f"parse ({subject}); skipping it would drop content or the " + "stream's outcome in silence" + ) from exc + if disposition == _UNATTRIBUTABLE: + raise RuntimeError( + f"An OpenAI Responses stream event failed to parse ({subject}) " + "and nothing identifies which event it was, so it cannot be " + "shown harmless to skip: dropping one that carried answer " + "text, tool-call arguments or the stream's outcome would lose " + "it in silence" + ) from exc + skipped += 1 + if skipped > _MAX_SKIPPED_EVENTS: + raise RuntimeError( + f"OpenAI Responses stream is unreadable: more than " + f"{_MAX_SKIPPED_EVENTS} events failed to parse" + ) from exc + _LOGGER.warning( + "Skipping an unparseable Responses stream event (%s, %d so far): " + "it carries nothing this bridge maps. %s", + subject, + skipped, + exc, + ) + continue + if event is not None: + yield event + + +def chat_tools_to_responses_tools(tools: Optional[Iterable[Any]]) -> Optional[List[dict]]: + """Flatten chat-completions tool specs onto the Responses tool shape. + + Chat-completions nests the schema under ``function``; Responses puts + ``name`` / ``description`` / ``parameters`` at the top level. A spec already + in the flat shape passes through, so a caller may mix both. + """ + if not tools: + return None + + flattened: List[dict] = [] + for tool in tools: + if not isinstance(tool, dict): + _LOGGER.warning("Skipping non-dict tool spec of type %r", type(tool).__name__) + continue + function = tool.get("function") + if not isinstance(function, dict): + if function is not None: + _LOGGER.warning( + "Tool spec has a non-dict 'function' (%s); passing it through " + "unflattened: %r", + type(function).__name__, + tool, + ) + # Already flat (or a built-in Responses tool such as web_search). + flattened.append(tool) + continue + name = function.get("name") + if not name: + _LOGGER.warning("Skipping tool spec with no function name: %r", tool) + continue + flattened.append( + { + "type": "function", + "name": name, + "description": function.get("description") or "", + "parameters": function.get("parameters") + or {"type": "object", "properties": {}}, + # Responses defaults ``strict`` to True, which rejects schemas + # the chat-completions shape happily accepts (no + # additionalProperties:false, optional keys). Opt out so a tool + # spec written for chat-completions keeps working verbatim. + "strict": False, + } + ) + return flattened or None + + +def _message_field(message: Any, key: str) -> Any: + """Read ``key`` off a message that may be a dict or an object.""" + if isinstance(message, dict): + return message.get(key) + return getattr(message, key, None) + + +def _as_json_text(value: Any, *, what: str) -> str: + """Serialise a non-string value as the JSON text a Responses field carries. + + ``str()`` would hand the model a Python repr (single quotes, ``None`` / + ``True``) that no JSON parser accepts, the hazard the + ``backend_tool_rendering`` example documents for crewai tool returns. Values + JSON cannot express fall back to ``str()``, and that fallback is logged where + it happens rather than travelling silently. + """ + try: + return json.dumps(value, default=str) + except (TypeError, ValueError) as exc: + _LOGGER.warning( + "Falling back to str() for %s: it is not JSON-serialisable (%s)", what, exc + ) + return str(value) + + +def _content_parts_to_responses(content: List[Any]) -> List[dict]: + """Convert multimodal content blocks onto Responses input parts. + + Only for the roles in ``_INPUT_PART_ROLES``: an assistant message takes + ``_assistant_content_text`` instead. + """ + parts: List[dict] = [] + for item in content: + if not isinstance(item, dict): + _LOGGER.warning( + "Dropping non-dict content part of type %r", type(item).__name__ + ) + continue + item_type = item.get("type") + if item_type == "text": + text = item.get("text", "") + if not isinstance(text, str): + _LOGGER.warning( + "Serialising non-string text content part (%s)", type(text).__name__ + ) + text = _as_json_text(text, what="a text content part") + parts.append({"type": "input_text", "text": text}) + elif item_type == "image_url": + image_url = item.get("image_url") + url = image_url.get("url") if isinstance(image_url, dict) else image_url + if isinstance(url, str) and url: + # ``detail`` is required on a Responses input-image part; "auto" + # is the value the API defaults to, so carrying it changes + # nothing about what the model sees. + parts.append( + {"type": "input_image", "image_url": url, "detail": "auto"} + ) + else: + _LOGGER.warning("Dropping image_url part with no url") + else: + _LOGGER.warning( + "Dropping content part the Responses input does not carry: %r", + item_type, + ) + return parts + + +def _assistant_content_text(content: List[Any]) -> str: + """Collapse assistant content blocks onto the string content an item takes. + + An assistant message in the Responses ``input`` cannot carry ``input_*`` + parts: the only assistant content parts that exist are ``output_text`` and + ``refusal``, and those live on an output-message item keyed by a real + message id this bridge does not have. Its string content is accepted + verbatim, so text parts are joined and anything with no assistant + representation (an image, a file) is dropped with a log. + """ + texts: List[str] = [] + for item in content: + if not isinstance(item, dict): + _LOGGER.warning( + "Dropping non-dict assistant content part of type %r", + type(item).__name__, + ) + continue + if item.get("type") == "text": + text = item.get("text", "") + if not isinstance(text, str): + _LOGGER.warning( + "Serialising non-string assistant text content part (%s)", + type(text).__name__, + ) + text = _as_json_text(text, what="an assistant text content part") + if text: + texts.append(text) + else: + _LOGGER.warning( + "Dropping assistant content part %r: an assistant message in the " + "Responses input carries text only (an image or file part has no " + "assistant shape)", + item.get("type"), + ) + return "\n".join(texts) + + +def _tool_call_identity(tool_call: Any) -> Tuple[Optional[str], Optional[str]]: + """Project one chat-completions tool call onto ``(id, name)``, logging nothing. + + Pairing calls with outputs needs the identity of every call before anything + is emitted, and the emission pass logs what it drops; keeping this pass + quiet stops every decision being reported twice. + """ + call_id = _message_field(tool_call, "id") + function = _message_field(tool_call, "function") + name = _message_field(function, "name") if function is not None else None + return call_id, name + + +def _tool_call_fields(tool_call: Any) -> Tuple[Optional[str], Optional[str], str]: + """Project one chat-completions tool call onto ``(id, name, arguments)``. + + Responses takes ``arguments`` as a JSON STRING. Some providers hand back a + dict instead, so a non-string is serialised: emptying it would leave a call + the model is told it made with no arguments at all. + """ + call_id, name = _tool_call_identity(tool_call) + function = _message_field(tool_call, "function") + arguments = _message_field(function, "arguments") if function is not None else None + + if isinstance(arguments, str): + return call_id, name, arguments + if arguments is None: + # No arguments at all: "{}" is the empty JSON object the API expects, + # where "" is not valid JSON. + return call_id, name, "{}" + _LOGGER.warning( + "Serialising non-string arguments (%s) on tool call %r: the Responses API " + "takes arguments as a JSON string", + type(arguments).__name__, + call_id, + ) + return call_id, name, _as_json_text(arguments, what=f"arguments of call {call_id!r}") + + +def _tool_calls_of(message: Any, *, warn: bool = True) -> List[Any]: + """The tool calls on ``message`` as a list, logging a shape that is not one. + + ``warn=False`` for the pairing pass, which walks the same messages the + emission pass does and would otherwise report every drop twice. + """ + tool_calls = _message_field(message, "tool_calls") + if not tool_calls: + return [] + if isinstance(tool_calls, (list, tuple)): + return list(tool_calls) + if warn: + _LOGGER.warning( + "Dropping tool_calls of unexpected type %r", type(tool_calls).__name__ + ) + return [] + + +def _paired_call_ids(messages: List[Any]) -> Set[str]: + """Call ids that have BOTH an emittable ``function_call`` and an output. + + Emittable means the call carries an id and a name and sits on a message + whose role survives conversion: a call dropped for any of those reasons + takes its output with it, so the drop that protects the request cannot + create the very shape it protects against. + """ + called: Set[str] = set() + answered: Set[str] = set() + for message in messages: + role = _message_field(message, "role") + if role == "tool": + call_id = _message_field(message, "tool_call_id") + if call_id: + answered.add(call_id) + continue + if role not in _INPUT_MESSAGE_ROLES: + continue + for tool_call in _tool_calls_of(message, warn=False): + call_id, name = _tool_call_identity(tool_call) + if call_id and name: + called.add(call_id) + return called & answered + + +def chat_messages_to_responses_input(messages: Iterable[Any]) -> List[dict]: + """Convert chat-completions messages onto Responses ``input`` items. + + Message content rides an input message item; an assistant tool call becomes + a ``function_call`` item keyed by ``call_id``, and a ``tool`` message + becomes the matching ``function_call_output``. + + Calls and outputs are only emitted IN PAIRS. The Responses API rejects the + whole request over a call with no output AND over an output with no call, so + an unmatched item of either kind is dropped: a conversation the user + abandoned mid-tool-call, or a tool result whose call never made it into + state, would otherwise hard-fail every later turn. A duplicate of either + kind is dropped for the same reason. + """ + materialised = list(messages or []) + paired = _paired_call_ids(materialised) + + emitted_calls: Set[str] = set() + emitted_outputs: Set[str] = set() + items: List[dict] = [] + for message in materialised: + role = _message_field(message, "role") + content = _message_field(message, "content") + + if role == "tool": + call_id = _message_field(message, "tool_call_id") + if not call_id: + _LOGGER.warning("Dropping tool message with no tool_call_id") + continue + if call_id not in paired: + _LOGGER.warning( + "Dropping unpaired function_call_output %r: the Responses API " + "rejects an output with no matching call", + call_id, + ) + continue + if call_id in emitted_outputs: + _LOGGER.warning( + "Dropping a second function_call_output for call %r", call_id + ) + continue + emitted_outputs.add(call_id) + if isinstance(content, str): + output = content + elif content is None: + output = "" + else: + output = _as_json_text(content, what=f"output of call {call_id!r}") + items.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": output, + } + ) + continue + + if role not in _INPUT_MESSAGE_ROLES: + _LOGGER.warning("Dropping message with unsupported role %r", role) + continue + + if isinstance(content, list): + if role in _INPUT_PART_ROLES: + parts = _content_parts_to_responses(content) + if parts: + items.append({"role": role, "content": parts}) + elif content: + _LOGGER.warning( + "Dropping %s message: no content part survived conversion", role + ) + else: + text = _assistant_content_text(content) + if text: + items.append({"role": role, "content": text}) + elif content: + _LOGGER.warning( + "Dropping %s message: no content part survived conversion", role + ) + elif isinstance(content, str): + if content: + items.append({"role": role, "content": content}) + elif content is not None: + _LOGGER.warning( + "Serialising %s content of unexpected type %r", + role, + type(content).__name__, + ) + items.append( + { + "role": role, + "content": _as_json_text(content, what=f"{role} message content"), + } + ) + + for tool_call in _tool_calls_of(message): + call_id, name, arguments = _tool_call_fields(tool_call) + if not call_id or not name: + _LOGGER.warning("Dropping tool call with no id or name: %r", tool_call) + continue + if call_id not in paired: + _LOGGER.warning( + "Dropping unresolved function_call %r (%s): the Responses API " + "rejects a call with no matching output", + call_id, + name, + ) + continue + if call_id in emitted_calls: + _LOGGER.warning("Dropping a second function_call for call %r", call_id) + continue + emitted_calls.add(call_id) + items.append( + { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments, + } + ) + + return items + + +async def copilotkit_responses( + *, + model: str, + messages: Iterable[Any], + tools: Optional[Iterable[Any]] = None, + reasoning: Optional[Dict[str, Any]] = None, + **kwargs: Any, +): + """Open a streaming OpenAI Responses-API call, ready for ``copilotkit_stream``. + + Takes the SAME message and tool shapes a flow already passes to + ``litellm.acompletion`` and converts them, so switching a node onto this + channel is a one-line change: + + ```python + response = await copilotkit_stream( + await copilotkit_responses( + model="openai/gpt-5.4", + messages=[{"role": "system", "content": prompt}, *state.messages], + tools=tools, + reasoning={"effort": "medium", "summary": "auto"}, + ) + ) + ``` + + ``reasoning`` is passed through untouched. OpenAI streams reasoning summaries + only when it carries a ``summary`` (``"auto"`` / ``"concise"`` / + ``"detailed"``); without one the run succeeds but has no trace to surface. + + Raises ``RuntimeError`` when the channel is unavailable, naming which of the + two probes failed: litellm exposes no ``aresponses`` entrypoint, or it cannot + model event types this bridge must read. Probe + ``responses_channel_available()`` first when the caller wants to degrade to + chat-completions instead; refusing here is what keeps such a build from + failing mid-turn, after the client has already seen part of an answer. + """ + entrypoint = responses_entrypoint() + if entrypoint is None: + raise RuntimeError( + "The OpenAI Responses channel is unavailable: the installed litellm " + "exposes no 'aresponses' entrypoint. Upgrade litellm, or call " + "litellm.acompletion instead (chat-completions carries no OpenAI " + "reasoning summaries)." + ) + + unmodellable = responses_event_modelling().unmodellable_event_types + if unmodellable: + raise RuntimeError( + "The OpenAI Responses channel is unavailable: the installed litellm " + "has no model for these stream event types (" + f"{', '.join(unmodellable)}) and raises on them, so the reasoning " + "summaries and answer deltas this channel exists to read cannot be " + "parsed at all. Upgrade litellm, or call litellm.acompletion instead " + "(chat-completions carries no OpenAI reasoning summaries)." + ) + + if reasoning is not None and not reasoning.get("summary"): + _LOGGER.warning( + "reasoning=%r has no 'summary': OpenAI streams reasoning summaries " + "only when one is requested, so no REASONING_* events will surface.", + reasoning, + ) + + call_kwargs: Dict[str, Any] = { + "model": model, + "input": chat_messages_to_responses_input(messages), + "stream": True, + } + responses_tools = chat_tools_to_responses_tools(tools) + if responses_tools: + call_kwargs["tools"] = responses_tools + if reasoning is not None: + call_kwargs["reasoning"] = reasoning + call_kwargs.update(kwargs) + + return await entrypoint(**call_kwargs) diff --git a/integrations/crew-ai/python/ag_ui_crewai/_responses_events.py b/integrations/crew-ai/python/ag_ui_crewai/_responses_events.py new file mode 100644 index 0000000000..aa09193333 --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/_responses_events.py @@ -0,0 +1,138 @@ +"""The OpenAI Responses stream-event vocabulary this bridge reads. + +One home for two facts that must never disagree: WHICH Responses stream event +types the bridge consumes, and WHAT it consumes each one for. The second fact is +what decides the cost of losing an event, so both decisions that depend on it +derive from the roles here rather than from a list kept next to the decision: + +* ``_responses.iter_responses_events`` decides from the role whether an event + that failed to parse can be skipped or has to be reported. +* ``_capabilities`` decides from the role which types the installed litellm MUST + be able to model for the channel to be declared available at all. + +``tests/test_reasoning.py`` walks the driver's own source and asserts that every +event type it branches on has a role here (and that every non-envelope role here +is branched on), so this map cannot drift away from the code it describes. + +The roles, and what losing one event of each costs: + +``ENVELOPE`` + Stream bookkeeping: ``response.created`` (the turn's id / model / timestamp, + each of which has a fallback -- the assistant message id falls back to the + output item's own id) and ``response.in_progress`` (which the driver does not + read at all). Losing one costs nothing this bridge maps. +``REASONING`` + A reasoning-summary text delta. Losing ONE leaves a gap in a trace while the + answer and the outcome stay intact, so it is not fatal. A litellm that cannot + model them AT ALL is different: it defeats the only reason this channel + exists, so these types are required for the channel to be available. +``ENRICHMENT`` + ``response.output_item.done``, read for the OPTIONAL encrypted-reasoning blob + (present only when the caller asked for + ``include=["reasoning.encrypted_content"]``). Nothing else rides it. +``PAYLOAD`` + Answer text, a tool call's identity, a tool call's argument deltas. Losing + one drops answer text or truncates arguments to invalid JSON while the turn + still reports success, so nothing downstream can tell content went missing. +``TERMINAL`` + The stream's outcome. Losing one turns a failed stream into an empty + assistant message with no failure recorded and no RUN_ERROR. + +This module is a LEAF: it imports only the stdlib, so ``_capabilities`` / +``_reasoning`` / ``_responses`` can all import it at module-load time without a +circular dependency. +""" + +from __future__ import annotations + +from typing import Dict, FrozenSet, Optional + +# Event ``type`` discriminators, matched as STRINGS throughout. A litellm build +# that predates an event type still delivers the payload on its extras-allowing +# catch-all model, so reading the string keeps the projection working on old and +# new builds alike. +RESPONSES_CREATED = "response.created" +RESPONSES_IN_PROGRESS = "response.in_progress" +RESPONSES_OUTPUT_ITEM_ADDED = "response.output_item.added" +RESPONSES_OUTPUT_ITEM_DONE = "response.output_item.done" +RESPONSES_OUTPUT_TEXT_DELTA = "response.output_text.delta" +RESPONSES_FUNCTION_CALL_ARGS_DELTA = "response.function_call_arguments.delta" +RESPONSES_COMPLETED = "response.completed" +RESPONSES_INCOMPLETE = "response.incomplete" +RESPONSES_FAILED = "response.failed" +RESPONSES_ERROR = "error" + +#: Reasoning-summary text deltas. ``summary_text`` is what ``reasoning.summary`` +#: produces; ``reasoning_text`` is the raw-reasoning variant some models emit. +RESPONSES_REASONING_SUMMARY_TEXT_DELTA = "response.reasoning_summary_text.delta" +RESPONSES_REASONING_TEXT_DELTA = "response.reasoning_text.delta" +RESPONSES_REASONING_TEXT_DELTAS: FrozenSet[str] = frozenset( + {RESPONSES_REASONING_SUMMARY_TEXT_DELTA, RESPONSES_REASONING_TEXT_DELTA} +) + +#: Terminal event types: the stream carries nothing more after one of these. +RESPONSES_TERMINAL: FrozenSet[str] = frozenset( + {RESPONSES_COMPLETED, RESPONSES_INCOMPLETE, RESPONSES_FAILED, RESPONSES_ERROR} +) + +# Roles. See the module docstring for what losing one event of each role costs. +ENVELOPE = "envelope" +REASONING = "reasoning" +ENRICHMENT = "enrichment" +PAYLOAD = "payload" +TERMINAL = "terminal" + +#: Every Responses event type this bridge reads, and what it reads it for. +EVENT_ROLES: Dict[str, str] = { + RESPONSES_CREATED: ENVELOPE, + # Not read by the driver at all. Listed so an unparseable one is provably + # skippable rather than unattributable. + RESPONSES_IN_PROGRESS: ENVELOPE, + RESPONSES_REASONING_SUMMARY_TEXT_DELTA: REASONING, + RESPONSES_REASONING_TEXT_DELTA: REASONING, + RESPONSES_OUTPUT_ITEM_DONE: ENRICHMENT, + RESPONSES_OUTPUT_ITEM_ADDED: PAYLOAD, + RESPONSES_OUTPUT_TEXT_DELTA: PAYLOAD, + RESPONSES_FUNCTION_CALL_ARGS_DELTA: PAYLOAD, + **{event_type: TERMINAL for event_type in RESPONSES_TERMINAL}, +} + +#: Roles whose loss costs answer content or the stream's outcome. An event of one +#: of these roles that fails to parse is REPORTED, never skipped: dropping it +#: would lose content or turn a failure into an empty message in silence. +LOAD_BEARING_ROLES: FrozenSet[str] = frozenset({PAYLOAD, TERMINAL}) + +#: Roles the channel cannot do its job without, so the installed litellm must be +#: able to model every type carrying one for the channel to be declared +#: available. Reasoning is in here and ``ENRICHMENT`` is not: this channel exists +#: because OpenAI streams reasoning summaries nowhere else, while the encrypted +#: blob is optional even when the build can model its event. +REQUIRED_ROLES: FrozenSet[str] = frozenset({REASONING, PAYLOAD, TERMINAL}) + +#: How bad it is to lose an event of each role, for the one case where a single +#: litellm model class serves several types (its catch-all model): the most +#: severe role any of those types carries is the one that must win. +_ROLE_SEVERITY: Dict[str, int] = { + ENVELOPE: 0, + ENRICHMENT: 1, + REASONING: 2, + PAYLOAD: 3, + TERMINAL: 3, +} + + +def event_role(event_type: Optional[str]) -> Optional[str]: + """The role ``event_type`` plays for this bridge, or ``None`` if unread.""" + if not event_type: + return None + return EVENT_ROLES.get(event_type) + + +def is_load_bearing(role: Optional[str]) -> bool: + """Whether losing one event of ``role`` loses content or the outcome.""" + return role in LOAD_BEARING_ROLES + + +def role_severity(role: Optional[str]) -> int: + """Order roles by the cost of losing one event, for the catch-all case.""" + return _ROLE_SEVERITY.get(role or "", -1) diff --git a/integrations/crew-ai/python/ag_ui_crewai/a2ui_tool.py b/integrations/crew-ai/python/ag_ui_crewai/a2ui_tool.py index 75f16f09b5..a720840d58 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/a2ui_tool.py +++ b/integrations/crew-ai/python/ag_ui_crewai/a2ui_tool.py @@ -29,6 +29,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import json import logging import threading @@ -429,9 +430,7 @@ def schema(self) -> dict: }, } - async def _emit_chunk( - self, flow: Any, payload: dict, name_state: dict - ) -> None: + async def _emit_chunk(self, flow: Any, payload: dict, name_state: dict) -> None: """Translate one sub-agent stream payload into a bridged TOOL_CALL_CHUNK. ``start`` stashes the render tool name/id; the first following ``args`` @@ -459,7 +458,13 @@ async def _emit_chunk( ) await yield_control() - def _emit_tool_result(self, flow: Any, tool_call_id: Optional[str], envelope: str) -> None: + def _emit_tool_result( + self, + flow: Any, + tool_call_id: Optional[str], + envelope: str, + message_id: Optional[str] = None, + ) -> None: """Emit the TOOL_CALL_RESULT for this generate_a2ui call so the a2ui middleware closes the outer call and can commit / hard-fail from the envelope. Emitted here (not left to the caller) so a flow that forgets @@ -470,7 +475,7 @@ def _emit_tool_result(self, flow: Any, tool_call_id: Optional[str], envelope: st flow, BridgedToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, - message_id=str(uuid.uuid4()), + message_id=message_id or str(uuid.uuid4()), tool_call_id=tool_call_id, content=envelope, role="tool", @@ -482,6 +487,7 @@ async def run( args: Optional[dict], *, tool_call_id: Optional[str] = None, + result_message_id: Optional[str] = None, flow: Any = None, ) -> str: """Generate (or update) an A2UI surface and return the operations @@ -493,6 +499,12 @@ async def run( ``render_a2ui`` progress to the wire; on validation failure the toolkit recovery loop retries, each attempt re-streaming render so the middleware shows building -> retrying -> paint. + + ``result_message_id`` is the id to stream that result under. Pass the id + the caller stamps onto the tool message it persists, so the terminal + MESSAGES_SNAPSHOT updates that message in place; left unset, the streamed + result and the persisted one carry different ids and the client remounts + the surface card from the snapshot. """ flow = flow if flow is not None else flow_context.get(None) if flow is None: @@ -520,14 +532,14 @@ async def run( target_surface_id=target_surface_id, changes=changes, messages=agui_messages, - state=glue_state if isinstance(glue_state, dict) else {}, + state=dict(glue_state) if isinstance(glue_state, Mapping) else {}, guidelines=cfg["guidelines"], ) if prep.get("error"): logger.warning("A2UI request prep failed: %s", prep["error"]) envelope = wrap_error_envelope(prep["error"]) - self._emit_tool_result(flow, tool_call_id, envelope) + self._emit_tool_result(flow, tool_call_id, envelope, result_message_id) return envelope if cfg["model_kwargs"] is None: @@ -645,7 +657,7 @@ def _build_envelope(render_args: dict) -> str: raise envelope = future.result()["envelope"] - self._emit_tool_result(flow, tool_call_id, envelope) + self._emit_tool_result(flow, tool_call_id, envelope, result_message_id) return envelope @@ -731,7 +743,7 @@ def plan_a2ui_injection( """ log = log or logger config = config or {} - ag_ui = state.get("ag-ui") if isinstance(state, dict) else None + ag_ui = state.get("ag-ui") if isinstance(state, Mapping) else None ag_ui = ag_ui if isinstance(ag_ui, dict) else {} flag = ag_ui.get("inject_a2ui_tool") @@ -755,7 +767,7 @@ def plan_a2ui_injection( render_tool_name = flag if isinstance(flag, str) else RENDER_A2UI_TOOL_NAME - resolved = resolve_a2ui_catalog(state) if isinstance(state, dict) else None + resolved = resolve_a2ui_catalog(state) if isinstance(state, Mapping) else None runtime_schema, runtime_catalog_id = resolved if resolved else (None, None) catalog = config.get("catalog") @@ -778,9 +790,9 @@ def plan_a2ui_injection( }, glue={ "messages": list(state.get("messages") or []) - if isinstance(state, dict) + if isinstance(state, Mapping) else [], - "state": state if isinstance(state, dict) else {}, + "state": dict(state) if isinstance(state, Mapping) else {}, }, ) setattr(tool, _A2UI_AUTOINJECT_ATTR, True) diff --git a/integrations/crew-ai/python/ag_ui_crewai/dojo.py b/integrations/crew-ai/python/ag_ui_crewai/dojo.py index c319295fdb..90887339a2 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/dojo.py +++ b/integrations/crew-ai/python/ag_ui_crewai/dojo.py @@ -11,11 +11,14 @@ from .examples.agentic_generative_ui import AgenticGenerativeUIFlow from .examples.shared_state import SharedStateFlow from .examples.predictive_state_updates import PredictiveStateUpdatesFlow -from .examples.error_flow import ErrorFlow from .examples.interrupt_flow import InterruptFlow from .examples.a2ui_dynamic_schema import A2UIDynamicSchemaFlow from .examples.a2ui_recovery import A2UIRecoveryFlow from .examples.a2ui_fixed_schema import A2UIFixedSchemaFlow +from .examples.agentic_chat_multimodal import AgenticChatMultimodalFlow +from .examples.agentic_chat_reasoning import AgenticChatReasoningFlow +from .examples.subgraphs import SubgraphsFlow +from .examples.conversational import CONVERSATIONAL_FLOW_TYPES app = FastAPI(title="CrewAI Dojo Example Server") @@ -67,12 +70,6 @@ path="/crew_chat", ) -add_crewai_flow_fastapi_endpoint( - app=app, - flow=ErrorFlow(), - path="/error_flow", -) - # emit_interrupt_outcome=True: CopilotKit v2 `useInterrupt` (>=1.61.2) resumes # from the standard RUN_FINISHED.outcome. With the default (legacy on_interrupt # only) its resolve() does not round-trip a RunAgentInput.resume[], so the run @@ -102,6 +99,37 @@ path="/a2ui_fixed_schema", ) +add_crewai_flow_fastapi_endpoint( + app=app, + flow=AgenticChatMultimodalFlow(), + path="/agentic_chat_multimodal", +) + +add_crewai_flow_fastapi_endpoint( + app=app, + flow=AgenticChatReasoningFlow(), + path="/agentic_chat_reasoning", +) + +# emit_interrupt_outcome=True: the flights/hotels steps suspend the flow for the +# user's pick; modern CopilotKit resumes from the RUN_FINISHED.outcome. See the +# interrupt endpoint above for the full rationale. +add_crewai_flow_fastapi_endpoint( + app=app, + flow=SubgraphsFlow(), + path="/subgraphs", + emit_interrupt_outcome=True, +) + +for feature, flow_type in CONVERSATIONAL_FLOW_TYPES.items(): + add_crewai_flow_fastapi_endpoint( + app=app, + flow=flow_type(), + path=f"/conversational_flows/{feature}", + conversational=True, + emit_interrupt_outcome=feature in {"interrupt", "subgraphs"}, + ) + def main(): """Run the uvicorn server.""" port = int(os.getenv("PORT", "8000")) diff --git a/integrations/crew-ai/python/ag_ui_crewai/endpoint.py b/integrations/crew-ai/python/ag_ui_crewai/endpoint.py index 8ac8c4d2ad..32c3969fd0 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/endpoint.py +++ b/integrations/crew-ai/python/ag_ui_crewai/endpoint.py @@ -27,6 +27,7 @@ BaseEventListener, crewai_event_bus, flow_supports_stream_frames, + flow_supports_conversational_stream, flow_supports_human_feedback, supported_checkpoint_kwargs, add_stream_sink, @@ -113,6 +114,14 @@ reset_node_snapshot_suppression, ) from .crews import ChatWithCrewFlow, CrewBaseInstance +from ._conversation import ( + ConversationalTurn, + SyncStreamSessionAdapter, + hydrate_conversational_flow, + force_per_turn_trace_finalization, + overlay_conversational_persistence, + prepare_conversational_turn, +) _LOGGER = logging.getLogger(__name__) @@ -1877,9 +1886,9 @@ async def _aclose_stream_session( """Best-effort ``aclose()`` teardown for a crewai ``AsyncStreamSession``. ``aclose()`` replaces the legacy ``_cancel_and_join`` machinery on the - StreamFrame path — it cancels the background kickoff task crewai spawns - inside ``astream`` and closes the frame iterator. The OBSERVABLE behavior - (client-disconnect tears the run down, no leaked kickoff) must not regress. + StreamFrame path. CrewAI's async session cancels its kickoff task; the + conversational sync adapter requests cooperative cancellation and logs + when a blocked upstream operation must return before its worker can stop. Mirrors the ``_cancel_and_join`` uncancel dance: on Python 3.11+ a bare ``await session.aclose()`` in a ``finally`` reached via outer cancellation @@ -1978,6 +1987,7 @@ async def _run_flow_frame_stream( hitl_options: HITLOptions | None = None, emit_raw_events: bool = False, emission_shape: str = DEFAULT_EMISSION_SHAPE, + conversational_turn: ConversationalTurn | None = None, ): """StreamFrame-path driver: drive ``flow.astream`` and yield encoded AG-UI events. @@ -2031,6 +2041,13 @@ async def _run_flow_frame_stream( hitl_options=hitl_options, emission_shape=emission_shape, ) + # ``stream_turn`` records the current user message inside CrewAI, after the + # run has opened. Its first normal MESSAGES_SNAPSHOT therefore arrives only + # when the first flow method finishes. Reasoning/text can stream before that + # snapshot and would be anchored above the user's prompt in AG-UI. Establish + # the request conversation immediately after RUN_STARTED so all streamed + # activity for this turn is rendered beneath the current user message. + current_turn_snapshot_pending = conversational_turn is not None def _closing_reasoning_frames(): """Encoded REASONING_* END events for any reasoning left open at error. @@ -2062,6 +2079,14 @@ def _closing_reasoning_frames(): # starts, and a RAW first event makes @ag-ui/client's verifyEvents throw # "First event must be 'RUN_STARTED'". pending_raw: list[Any] = [] + conversational_user_id: str | None = None + conversational_user_id_applied = False + if conversational_turn is not None and input_data.messages: + latest_input_message = dump_agui_message(input_data.messages[-1]) + if latest_input_message.get("role") == "user": + candidate_id = latest_input_message.get("id") + if isinstance(candidate_id, str): + conversational_user_id = candidate_id def _hold_pending_raw(raw_mirror: Any) -> None: """Park a RAW mirror until the run has opened, logging an overflow drop.""" @@ -2086,6 +2111,46 @@ def _emit_raw(raw_mirror: Any) -> Any: return encoder.encode(raw_mirror) def _sink(source: Any, event: Any) -> None: + nonlocal conversational_user_id_applied + # CrewAI's conversational runtime reconstructs the pending user turn as + # a ConversationMessage, whose schema has no ``id`` field. Preserve the + # AG-UI request id at the synchronous message-added boundary; otherwise + # every method-finish MESSAGES_SNAPSHOT invents a fresh id and re-anchors + # the user prompt below any reasoning that already streamed. + if ( + conversational_user_id is not None + and not conversational_user_id_applied + and source is flow_copy + and getattr(event, "type", None) == "conversation_message_added" + and getattr(event, "role", None) == "user" + ): + state = getattr(source, "state", None) + messages = ( + getattr(state, "messages", None) + if state is not None and not isinstance(state, dict) + else (state or {}).get("messages") + ) + message_index = getattr(event, "message_index", None) + if ( + isinstance(messages, list) + and isinstance(message_index, int) + and 0 <= message_index < len(messages) + ): + stored_message = messages[message_index] + if isinstance(stored_message, dict): + stabilized_message = dict(stored_message) + else: + dump_message = getattr(stored_message, "model_dump", None) + stabilized_message = ( + dump_message(exclude_none=True) + if callable(dump_message) + else None + ) + if isinstance(stabilized_message, dict): + stabilized_message["id"] = conversational_user_id + messages[message_index] = stabilized_message + conversational_user_id_applied = True + # source is flow_copy isolates the outer run: its own lifecycle/method # events and our Bridged* events carry flow_copy as source, while a # nested crew.kickoff's own flow's lifecycle/method events leak here @@ -2158,22 +2223,39 @@ def _sink(source: Any, event: Any) -> None: # already be in scope to reach the flow's emits. Guarded so a partial # install (no sink API) degrades rather than crashing. sink_token = add_stream_sink(_sink) if callable(add_stream_sink) else None - # ``astream`` returns an AsyncStreamSession; iterating it spawns - # crewai's background kickoff task and streams ordered frames. - # Filter against astream's own signature so an unsupported kwarg - # degrades cleanly instead of raising. - _ckpt = supported_checkpoint_kwargs( - flow_copy.astream, checkpoint_kwargs or {} # type: ignore[attr-defined] - ) - if checkpoint_kwargs and not _ckpt: - # Checkpointing enabled but this flow's astream does not accept - # it: warn so the no-op is visible. - _LOGGER.warning( - "ag-ui-crewai: checkpointing is enabled but flow.astream " - "does not accept from_checkpoint; nothing will be persisted " - "for this run." + if conversational_turn is None: + # ``astream`` returns an AsyncStreamSession; iterating it spawns + # crewai's background kickoff task and streams ordered frames. + # Filter against astream's own signature so an unsupported kwarg + # degrades cleanly instead of raising. + _ckpt = supported_checkpoint_kwargs( + flow_copy.astream, checkpoint_kwargs or {} # type: ignore[attr-defined] + ) + if checkpoint_kwargs and not _ckpt: + # Checkpointing enabled but this flow's astream does not accept + # it: warn so the no-op is visible. + _LOGGER.warning( + "ag-ui-crewai: checkpointing is enabled but flow.astream " + "does not accept from_checkpoint; nothing will be persisted " + "for this run." + ) + session = flow_copy.astream( # type: ignore[attr-defined] + inputs=inputs, + **_ckpt, + ) + else: + force_per_turn_trace_finalization(flow_copy) + hydrated_inputs = hydrate_conversational_flow( + flow_copy, + inputs, + conversational_turn, ) - session = flow_copy.astream(inputs=inputs, **_ckpt) # type: ignore[attr-defined] + overlay_conversational_persistence(flow_copy, hydrated_inputs) + sync_session = flow_copy.stream_turn( # type: ignore[attr-defined] + conversational_turn.message, + session_id=input_data.thread_id, + ) + session = SyncStreamSessionAdapter(sync_session) aiter = session.__aiter__() deadline = ( time.monotonic() + timeout if timeout is not None else None @@ -2182,8 +2264,9 @@ def _sink(source: Any, event: Any) -> None: # Enforce the wall-clock ceiling per frame read via # ``asyncio.wait_for``: on timeout it cancels the in-flight # ``__anext__`` AND awaits its unwind before raising, so crewai's - # scoped stream sink / background kickoff task tear down cleanly; - # ``aclose()`` in the ``finally`` then fully drains the task. + # in-flight read unwinds cleanly; ``aclose()`` in the ``finally`` + # then cancels the async session or requests a cooperative stop + # from the conversational sync adapter. # # Cross-version note (``requires-python`` floor is 3.10): # ``wait_for`` internals differ. On 3.12+ it awaits the @@ -2265,6 +2348,21 @@ def _sink(source: Any, event: Any) -> None: run_id=input_data.run_id, ) yield encoder.encode(event) + if ( + current_turn_snapshot_pending + and event.type == EventType.RUN_STARTED + ): + initial_messages = MessagesSnapshotEvent( + type=EventType.MESSAGES_SNAPSHOT, + messages=input_data.messages, + ) + _stamp_correlation_ids( + initial_messages, + thread_id=input_data.thread_id, + run_id=input_data.run_id, + ) + yield encoder.encode(initial_messages) + current_turn_snapshot_pending = False if pending_raw and translator.run_started: # The run just opened: flush the mirrors held back so they land @@ -2398,9 +2496,10 @@ def _sink(source: Any, event: Any) -> None: ) ) finally: - # aclose() replaces _cancel_and_join on this path; run it - # unconditionally (including under outer cancellation) so the kickoff - # task never leaks, then unregister the sink and reset the context var. + # Run aclose() unconditionally (including under outer cancellation) + # before unregistering the sink and resetting the context var. Async + # sessions cancel their kickoff task; the conversational sync adapter + # makes any still-blocked cooperative shutdown observable in its log. try: await _aclose_stream_session( session, @@ -2426,6 +2525,7 @@ def _run_flow_stream( hitl_options: HITLOptions | None = None, emit_raw_events: bool = False, emission_shape: str = DEFAULT_EMISSION_SHAPE, + conversational_turn: ConversationalTurn | None = None, ): """Select the StreamFrame path (crewai >= 1.6 + a real ``astream`` flow) or the legacy bus-listener path, returning the chosen async generator. @@ -2438,7 +2538,7 @@ def _run_flow_stream( ``checkpoint_kwargs`` is forwarded to whichever driver is chosen; each driver filters it against the exact method it invokes. """ - if flow_supports_stream_frames(flow_copy): + if conversational_turn is not None or flow_supports_stream_frames(flow_copy): return _run_flow_frame_stream( flow_copy=flow_copy, encoder=encoder, @@ -2449,6 +2549,7 @@ def _run_flow_stream( hitl_options=hitl_options, emit_raw_events=emit_raw_events, emission_shape=emission_shape, + conversational_turn=conversational_turn, ) return _run_flow_event_stream( flow_copy=flow_copy, @@ -2495,6 +2596,29 @@ async def _reject_unsupported_resume(input_data: RunAgentInput, encoder: EventEn ) +async def _reject_unsupported_conversational_flow( + input_data: RunAgentInput, + encoder: EventEncoder, +): + """Fail loudly when conversational execution was explicitly requested.""" + _LOGGER.warning( + "CrewAI conversational Flow requested but unavailable thread=%s run=%s", + input_data.thread_id, + input_data.run_id, + ) + yield encoder.encode( + RunErrorEvent( + message=( + f"thread={input_data.thread_id} run={input_data.run_id}: " + "CrewAI conversational Flow execution is unsupported; the flow " + "must set conversational=True and expose stream_turn" + ), + code="AGUI_CREWAI_CONVERSATIONAL_FLOW_UNSUPPORTED", + **_run_error_extras(input_data), + ) + ) + + async def _run_flow_resume_stream( *, flow: object, @@ -2802,6 +2926,7 @@ def add_crewai_flow_fastapi_endpoint( enable_legacy_on_interrupt_event: bool = True, emit_raw_events: bool | None = None, emission_shape: str | None = None, + conversational: bool = False, ): """Adds a CrewAI endpoint to the FastAPI app. @@ -2816,6 +2941,10 @@ def add_crewai_flow_fastapi_endpoint( ``emit_raw_events`` resolve at registration, so a bad value fails once at startup rather than per request. + ``conversational=True`` drives CrewAI's public ``stream_turn`` API and maps + AG-UI ``thread_id`` to CrewAI ``session_id``. It fails loudly when the + supplied flow has not opted into CrewAI conversational mode. + Async human-in-the-loop: when the flow pauses on an ``@human_feedback`` method whose provider raises ``HumanFeedbackPending`` (see :data:`ag_ui_crewai.agui_feedback_provider`), the run terminates with an @@ -2876,6 +3005,12 @@ async def agentic_chat_endpoint(input_data: RunAgentInput, request: Request): timeout = _flow_timeout_seconds() + if conversational and not flow_supports_conversational_stream(flow): + return StreamingResponse( + _reject_unsupported_conversational_flow(input_data, encoder), + media_type=encoder.get_content_type(), + ) + # Resume a paused flow. ``from_pending`` reloads persisted pending state # (not a per-request copy), so the resume driver takes the ORIGINAL flow # (for its class) rather than a fresh ``_copy_flow``. @@ -2916,6 +3051,11 @@ async def agentic_chat_endpoint(input_data: RunAgentInput, request: Request): inputs["id"] = input_data.thread_id checkpoint_kwargs = build_checkpoint_kwargs(flow_copy, input_data) + conversational_turn = ( + prepare_conversational_turn(input_data.messages) + if conversational + else None + ) return StreamingResponse( _run_flow_stream( @@ -2928,6 +3068,7 @@ async def agentic_chat_endpoint(input_data: RunAgentInput, request: Request): hitl_options=hitl_options, emit_raw_events=resolved_emit_raw_events, emission_shape=resolved_emission_shape, + conversational_turn=conversational_turn, ), media_type=encoder.get_content_type(), ) diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/_a2ui_subagent.py b/integrations/crew-ai/python/ag_ui_crewai/examples/_a2ui_subagent.py index 1a88f8c6b9..e37f5c7a24 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/_a2ui_subagent.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/_a2ui_subagent.py @@ -9,15 +9,27 @@ import json import logging +import uuid from litellm import acompletion from ..sdk import copilotkit_stream from ..a2ui_tool import apply_a2ui_plan_to_tools, plan_a2ui_injection +from ._model_turn import ( + append_assistant_message, + frontend_tool_names, + resolve_client_tools, + sort_tool_calls, +) logger = logging.getLogger("ag_ui_crewai") -MODEL = "openai/gpt-4o" +MODEL = "openai/gpt-5.4" + +# Model turns per run: one generation plus its closing reply, with headroom for +# a second surface the user asked for in the same breath. Bounded so a model that +# keeps calling the tool cannot spin the run. +MAX_MODEL_TURNS = 4 # The dojo registers its dynamic component catalog (Row / HotelCard / # ProductCard / TeamMemberCard) under this id; auto-injected surfaces must @@ -41,12 +53,24 @@ ### HotelCard Props: name, location, rating (number 0-5), pricePerNight, action +Example: + {"id":"card","component":"HotelCard","name":{"path":"name"},"location":{"path":"location"}, + "rating":{"path":"rating"},"pricePerNight":{"path":"pricePerNight"}, + "action":{"event":{"name":"book_hotel","context":{"name":{"path":"name"},"pricePerNight":{"path":"pricePerNight"}}}}} ### ProductCard Props: name, price, rating (number 0-5), description (optional), action +Example: + {"id":"card","component":"ProductCard","name":{"path":"name"},"price":{"path":"price"}, + "rating":{"path":"rating"},"description":{"path":"description"}, + "action":{"event":{"name":"select_product","context":{"name":{"path":"name"},"price":{"path":"price"}}}}} ### TeamMemberCard Props: name, role, department (optional), email (optional), action +Example: + {"id":"card","component":"TeamMemberCard","name":{"path":"name"},"role":{"path":"role"}, + "department":{"path":"department"},"email":{"path":"email"}, + "action":{"event":{"name":"contact_member","context":{"name":{"path":"name"},"email":{"path":"email"}}}}} ## RULES - The root component MUST have id "root" and component "Row". Do NOT wrap it in @@ -54,6 +78,14 @@ - Root is ALWAYS: {"id":"root","component":"Row","children":{"componentId":"","path":"/items"}} - ALWAYS include the referenced card component in the components array. - Inside templates use RELATIVE paths (no leading slash): {"path":"name"}. +- Every card MUST carry an "action", ALWAYS as an OBJECT of the form + {"event":{"name":"","context":{...}}}. A bare string, or a missing + action, renders a button that fires nothing. +- The action "context" MUST bind the fields a reply needs to name the chosen + item, as relative paths: at minimum {"name":{"path":"name"}}, plus whatever + else identifies the choice (price, email). The click is forwarded to the model + as the action name and this context and nothing else, so a field left out + cannot be mentioned in the answer. - Always provide data in the "data" argument as {"items":[...]}. - Pick the ONE card type that best matches the request; generate 3-4 realistic items. - The components array contains EXACTLY two entries: the root Row and the card. @@ -65,7 +97,12 @@ "rosters, lists, cards, etc.), use the generate_a2ui tool to create a " "dynamic A2UI surface. After calling the tool, do NOT repeat the data in " "your text response; the tool renders the UI automatically. Just confirm " - "what was rendered." + "what was rendered.\n\n" + "The conversation may already contain a report that the user interacted with " + "a surface you rendered earlier (clicked an action button, for example). That " + "report is history, not a request for another surface: do NOT generate one and " + "do NOT call any tool. Reply in text, naming the specific item the user chose " + "and what happens next." ) # Backend A2UI config: teach the sub-agent the dojo catalog and bind surfaces to @@ -80,66 +117,120 @@ async def run_a2ui_subagent_turn(state) -> None: """One agentic-chat turn with A2UI auto-injection: swap the injected render proxy for generate_a2ui, stream the model, and run generate_a2ui (sub-agent - generation + progressive streaming + recovery) when the model calls it.""" + generation + progressive streaming + recovery) when the model calls it. + + Loops the model over its own tool results (bounded by ``MAX_MODEL_TURNS``) + so a turn that ends in a backend tool call still gets a closing model reply. + + What the loop does for a user action on a rendered surface, precisely: the + middleware appends the action and its report to the NEXT run's input, so the + report is already in history on the first turn and a model that answers it in + text needs no loop at all. The loop saves the case the live model actually + takes: it tool-calls FIRST (generating another surface), which without a loop + would end the run on that call and leave the user's choice unacknowledged. + """ actions = (state.get("copilotkit") or {}).get("actions") or [] - existing_names = [ - a["function"]["name"] - for a in actions - if isinstance(a, dict) - and isinstance(a.get("function"), dict) - and a["function"].get("name") - ] - - plan = plan_a2ui_injection( - model=MODEL, - state=state, - existing_tool_names=existing_names, - config=A2UI_CONFIG, - ) - tools = apply_a2ui_plan_to_tools(actions, plan) - tool_kwargs = {"tools": tools, "parallel_tool_calls": False} if tools else {} - - response = await copilotkit_stream( - await acompletion( + existing_names = frontend_tool_names(actions) + + for _ in range(MAX_MODEL_TURNS): + # Plan per turn, not once before the loop: the plan snapshots the + # conversation it hands the render sub-agent, so a plan reused on turn 2 + # would show the sub-agent the turn-1 history - no assistant message, no + # tool result, no action report. An in-run "update" would then find no + # prior surface (hard failure) and a second create would be designed + # blind to the first. Planning is local (no I/O), so this is cheap; None + # still means "no injection". + plan = plan_a2ui_injection( model=MODEL, - messages=[ - {"role": "system", "content": SYSTEM_PROMPT}, - *state["messages"], - ], - stream=True, - **tool_kwargs, + state=state, + existing_tool_names=existing_names, + config=A2UI_CONFIG, ) - ) - message = response.choices[0].message - # Stamp the streamed message id onto the persisted assistant message so the - # terminal MESSAGES_SNAPSHOT updates it in place instead of re-appending it - # (a fresh id would re-anchor the generate_a2ui tool-call chip AFTER the - # already-streamed surface activity - the tool card would jump to the end). - assistant = message.model_dump() - stream_id = getattr(response, "id", None) - if stream_id: - assistant["id"] = stream_id - state["messages"].append(assistant) - - if not (plan and message.tool_calls): - return - - for tool_call in message.tool_calls: - if tool_call.function.name != plan["tool_name"]: - continue - try: - args = json.loads(tool_call.function.arguments or "{}") - except (json.JSONDecodeError, TypeError): - logger.warning( - "generate_a2ui tool-call args were not valid JSON; " - "generating with defaults: %r", - tool_call.function.arguments, + backend_names = {plan["tool_name"]} if plan else set() + # Which forwarded tools the client may answer comes from the PLAN, not + # from a hardcoded name: the render proxy the plan swaps out is not on the + # model's tool list, so a call to it must not end the run as a frontend + # call (that would hand the render to the client and skip the generate + # tool's validate/retry loop). With no plan, that same proxy is the only + # renderer there is and stays a frontend tool. + offered, client_names = resolve_client_tools( + actions, + backend_names=backend_names, + drop_names=(plan.get("drop_tool_names") or ()) if plan else (), + ) + tools = apply_a2ui_plan_to_tools(offered, plan) + tool_kwargs = {"tools": tools, "parallel_tool_calls": False} if tools else {} + + response = await copilotkit_stream( + await acompletion( + model=MODEL, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + *state["messages"], + ], + stream=True, + **tool_kwargs, ) - args = {} - # run() emits its own TOOL_CALL_RESULT (given the outer call id) so the - # middleware closes the call in render order and, on exhaustion, paints - # the hard-failure - a flow can't leave it stuck at "building". - envelope = await plan["tool"].run(args, tool_call_id=tool_call.id) - state["messages"].append( - {"role": "tool", "content": envelope, "tool_call_id": tool_call.id} ) + message = response.choices[0].message + tool_calls = message.tool_calls or [] + # An orphan call (a name neither generate_a2ui nor a frontend tool) is + # answered by nobody, so it is dropped instead of persisted: an assistant + # tool_calls entry with no matching tool result 400s every later run on + # this thread. + backend, client, orphan = sort_tool_calls( + tool_calls, + backend_names=backend_names, + client_names=client_names, + ) + append_assistant_message( + state, response, message, drop_indexes={i for i, _ in orphan} + ) + + if not tool_calls: + return + + for _, tool_call in backend: + try: + args = json.loads(tool_call.function.arguments or "{}") + except (json.JSONDecodeError, TypeError): + logger.warning( + "generate_a2ui tool-call args were not valid JSON; " + "generating with defaults: %r", + tool_call.function.arguments, + ) + args = {} + # run() emits its own TOOL_CALL_RESULT (given the outer call id) so + # the middleware closes the call in render order and, on exhaustion, + # paints the hard-failure - a flow can't leave it stuck at + # "building". One id for that streamed result and the message + # persisted here: the terminal MESSAGES_SNAPSHOT then updates the + # message in place instead of minting a second id, which would + # remount the surface card the client just painted. + result_id = str(uuid.uuid4()) + envelope = await plan["tool"].run( + args, tool_call_id=tool_call.id, result_message_id=result_id + ) + state["messages"].append( + { + "id": result_id, + "role": "tool", + "content": envelope, + "tool_call_id": tool_call.id, + } + ) + + # A frontend call ends the run so the client can run it and send the + # result back on the next one; feeding the model again here would leave + # that call unanswered. An orphan call does NOT end the run: it was + # dropped, so the history is well-formed, and ending here would cost the + # user a reply. The model gets another turn to answer in text instead, + # bounded by MAX_MODEL_TURNS. + if client: + return + + logger.warning( + "A2UI turn hit the %d-model-turn cap with the model still calling tools; " + "ending the run without a closing reply", + MAX_MODEL_TURNS, + ) diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/_model_turn.py b/integrations/crew-ai/python/ag_ui_crewai/examples/_model_turn.py new file mode 100644 index 0000000000..d1c7802daa --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/_model_turn.py @@ -0,0 +1,144 @@ +"""Shared model-turn bookkeeping for the A2UI demo flows. + +Both A2UI demos loop the model over its own tool results, so both face the same +three questions each turn: which forwarded frontend tools this flow is willing to +let the client answer, which of the turn's tool calls can be answered at all, and +what to persist for the ones that cannot. Kept here so the fixed-schema and +subagent-driven turns cannot drift apart. +""" + +import logging + +logger = logging.getLogger("ag_ui_crewai") + + +def _action_name(action): + """The function name of one forwarded frontend tool, or ``None`` when the + entry is not a well-formed function-tool schema.""" + if not isinstance(action, dict): + return None + function = action.get("function") + if not isinstance(function, dict): + return None + name = function.get("name") + return name if isinstance(name, str) and name else None + + +def frontend_tool_names(actions) -> list[str]: + """Names of the frontend tools forwarded on this run (``copilotkit.actions``). + The client executes these and sends the result back on the next run.""" + return [name for name in (_action_name(a) for a in actions or []) if name] + + +def resolve_client_tools(actions, *, backend_names=(), drop_names=()): + """Split the forwarded frontend tools into ``(offered, client_names)``: the + entries to keep on the model's tool list, and the names the client is allowed + to answer. + + Two forwarded names are NOT the client's to answer, even though the frontend + sent them: + + - one this flow SWAPPED OUT (``drop_names``). The a2ui middleware forwards its + render proxy and auto-injection replaces it with the generate tool, so the + proxy is not on the model's tool list at all. Treating a call to it as + client-owned would end the run with that call intact and let the client + paint the surface directly, skipping the validate/retry recovery loop the + generate tool exists to run. With no injection there is nothing to drop and + the same proxy stays a perfectly ordinary frontend tool. + - one a backend tool of this flow already owns. The model can only be offered + one tool per name, and the backend half is the half that executes it, so the + backend takes precedence and the frontend action is dropped from the tool + list. Logged rather than resolved silently: the forwarded action is dead + either way, which is a wiring bug worth seeing. + + An entry with no readable name is left on the tool list untouched: it can + neither collide nor be answered by name. + """ + drop = set(drop_names or ()) + backend = set(backend_names or ()) + offered, client_names, shadowed = [], set(), set() + for action in actions or []: + name = _action_name(action) + if name is None: + offered.append(action) + continue + if name in drop: + continue + if name in backend: + shadowed.add(name) + continue + offered.append(action) + client_names.add(name) + if shadowed: + logger.warning( + "Frontend tool(s) %s share a name with a backend tool of this flow; " + "the backend tool wins and the frontend action will never be called. " + "Rename one side.", + ", ".join(sorted(shadowed)), + ) + return offered, client_names + + +def sort_tool_calls(tool_calls, *, backend_names, client_names): + """Sort one model turn's tool calls into ``(backend, client, orphan)``. + + ``backend`` this flow executes; ``client`` the frontend answers on the next + run; ``orphan`` names neither side knows (a hallucinated tool, or one this + flow swapped out), so nothing will ever answer them. Each bucket holds + ``(index, call)`` pairs indexed into ``tool_calls``, so a caller can drop the + orphans from the assistant message positionally. + """ + backend, client, orphan = [], [], [] + for index, call in enumerate(tool_calls): + name = call.function.name + if name in backend_names: + backend.append((index, call)) + elif name in client_names: + client.append((index, call)) + else: + orphan.append((index, call)) + if orphan: + logger.warning( + "Model called %s, which neither this flow nor the frontend can run; " + "dropping the call rather than persisting it unanswered", + ", ".join(call.function.name for _, call in orphan), + ) + return backend, client, orphan + + +def append_assistant_message(state, response, message, *, drop_indexes=()): + """Persist the streamed assistant message, minus the tool calls at + ``drop_indexes``. + + Stamps the streamed message id onto the persisted message so the terminal + MESSAGES_SNAPSHOT updates it in place instead of re-appending it (a fresh id + would re-anchor the tool-call chip AFTER the already-streamed surface + activity, dropping the tool card to the end). + + A dropped call is one nothing will answer. Persisting it would leave an + assistant ``tool_calls`` entry with no matching ``role="tool"`` result, and + the chat-completions API rejects that on every later run of the thread (the + Responses channel drops such calls for the same reason). + + Returns the persisted dict, or ``None`` when there was nothing to persist: + every tool call was dropped, or the turn was empty to begin with (a stream + that produced no text and no call at all). ``content`` and ``tool_calls`` are + the only payload the stream helpers put on an assistant message - reasoning is + streamed as its own message - so a turn with neither carries nothing, and + persisting it would replay an empty assistant message to the model on every + later run of the thread. + """ + assistant = message.model_dump() + stream_id = getattr(response, "id", None) + if stream_id: + assistant["id"] = stream_id + if drop_indexes: + assistant["tool_calls"] = [ + call + for index, call in enumerate(assistant.get("tool_calls") or []) + if index not in drop_indexes + ] or None + if not assistant.get("tool_calls") and not assistant.get("content"): + return None + state["messages"].append(assistant) + return assistant diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/a2ui_fixed_schema.py b/integrations/crew-ai/python/ag_ui_crewai/examples/a2ui_fixed_schema.py index 1dc78f90c1..3bd5591399 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/a2ui_fixed_schema.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/a2ui_fixed_schema.py @@ -11,6 +11,7 @@ import json import logging +import uuid from pathlib import Path from typing import Any @@ -25,10 +26,20 @@ ) from ..sdk import copilotkit_emit_tool_result, copilotkit_stream +from ._model_turn import ( + append_assistant_message, + resolve_client_tools, + sort_tool_calls, +) logger = logging.getLogger("ag_ui_crewai") -MODEL = "openai/gpt-4o" +MODEL = "openai/gpt-5.4" + +# Model turns per run: one search plus its closing reply, with headroom for a +# flight-and-hotel request. Bounded so a model that keeps calling tools cannot +# spin the run. +MAX_MODEL_TURNS = 4 # Both surfaces render against the dojo's fixed catalog (Row / FlightCard / # HotelCard / StarRating); the dojo page supplies the catalog components, we @@ -117,78 +128,135 @@ def _envelope(surface_id: str, schema: list[dict[str, Any]], data: dict[str, Any "use search_hotels. After calling a tool, do NOT repeat or summarize the " "data in your text response; the tool renders a rich UI automatically. Just " "say something brief like 'Here are your results'. Generate 3-5 realistic " - "results." + "results.\n\n" + "The conversation may already contain a report that the user interacted with " + "results you rendered earlier (booked a hotel or selected a flight, for " + "example). That report is history, not a new request: do NOT run another " + "search and do NOT call any tool. Reply in text, naming the specific item the " + "user chose and what happens next." ) + +def _results(args: dict[str, Any], key: str) -> list: + """The results list for a search call. A missing OR explicitly-null argument + becomes an empty list: ``updateDataModel {"hotels": null}`` paints nothing at + all, where an empty surface is what a no-results search means.""" + value = args.get(key) + return value if isinstance(value, list) else [] + + _TOOL_ENVELOPE = { "search_flights": lambda args: _envelope( - FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {"flights": args.get("flights", [])} + FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {"flights": _results(args, "flights")} ), "search_hotels": lambda args: _envelope( - HOTEL_SURFACE_ID, HOTEL_SCHEMA, {"hotels": args.get("hotels", [])} + HOTEL_SURFACE_ID, HOTEL_SCHEMA, {"hotels": _results(args, "hotels")} ), } class A2UIFixedSchemaFlow(Flow): - """A2UI surfaces from fixed, pre-authored schemas via direct backend tools.""" + """A2UI surfaces from fixed, pre-authored schemas via direct backend tools. + + Loops the model over its own tool results (bounded by ``MAX_MODEL_TURNS``) + so a turn that ends in a search still gets a closing model reply. + + What the loop does for a user action on a rendered surface, precisely: the + middleware appends the action and its report to the NEXT run's input, so the + report is already in history on the first turn and a model that answers it in + text needs no loop at all. The loop saves the case the live model actually + takes: it tool-calls FIRST (running another search), which without a loop + would end the run on that call and leave the user's choice unacknowledged. + """ @start() async def chat(self): state = self.state actions = (state.get("copilotkit") or {}).get("actions") or [] - tools = [*actions, SEARCH_FLIGHTS_TOOL, SEARCH_HOTELS_TOOL] - - response = await copilotkit_stream( - await acompletion( - model=MODEL, - messages=[ - {"role": "system", "content": SYSTEM_PROMPT}, - *state["messages"], - ], - tools=tools, - parallel_tool_calls=False, - stream=True, - ) + # A frontend action sharing a search tool's name is dropped in favour of + # the backend tool (and logged), so the model is offered one tool per name + # rather than two definitions of the same one. + offered, client_names = resolve_client_tools( + actions, backend_names=set(_TOOL_ENVELOPE) ) - message = response.choices[0].message - # Preserve the streamed message id so the terminal MESSAGES_SNAPSHOT - # updates the assistant message in place rather than re-appending it - # after the already-streamed surface (which would drop the tool-call - # chip to the end). - assistant = message.model_dump() - stream_id = getattr(response, "id", None) - if stream_id: - assistant["id"] = stream_id - state["messages"].append(assistant) - - if not message.tool_calls: - return - - for tool_call in message.tool_calls: - build = _TOOL_ENVELOPE.get(tool_call.function.name) - if build is None: - continue - try: - args = json.loads(tool_call.function.arguments or "{}") - except (json.JSONDecodeError, TypeError): - logger.warning( - "%s tool-call args were not valid JSON; rendering an empty " - "surface: %r", - tool_call.function.name, - tool_call.function.arguments, + tools = [*offered, SEARCH_FLIGHTS_TOOL, SEARCH_HOTELS_TOOL] + + for _ in range(MAX_MODEL_TURNS): + response = await copilotkit_stream( + await acompletion( + model=MODEL, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + *state["messages"], + ], + tools=tools, + parallel_tool_calls=False, + stream=True, ) - args = {} - envelope = build(args) - state["messages"].append( - { - "role": "tool", - "content": envelope, - "tool_call_id": tool_call.id, - } ) - # The A2UI middleware paints the fixed surface from the tool RESULT - # (a2ui_operations envelope), which the bridge otherwise surfaces - # only via MESSAGES_SNAPSHOT. Emit it as a TOOL_CALL_RESULT so the - # middleware detects and renders it. - await copilotkit_emit_tool_result(tool_call.id, envelope) + message = response.choices[0].message + tool_calls = message.tool_calls or [] + # An orphan call (a name neither this flow's searches nor a frontend + # tool) is answered by nobody, so it is dropped instead of persisted: + # an assistant tool_calls entry with no matching tool result 400s + # every later run on this thread. + backend, client, orphan = sort_tool_calls( + tool_calls, + backend_names=set(_TOOL_ENVELOPE), + client_names=client_names, + ) + append_assistant_message( + state, response, message, drop_indexes={i for i, _ in orphan} + ) + + if not tool_calls: + return + + for _, tool_call in backend: + build = _TOOL_ENVELOPE[tool_call.function.name] + try: + args = json.loads(tool_call.function.arguments or "{}") + except (json.JSONDecodeError, TypeError): + logger.warning( + "%s tool-call args were not valid JSON; rendering an " + "empty surface: %r", + tool_call.function.name, + tool_call.function.arguments, + ) + args = {} + envelope = build(args) + # One id for the streamed result and the persisted message: the + # terminal MESSAGES_SNAPSHOT then updates that message in place. + # Left unstamped, the snapshot mints a second id and the client + # remounts the surface card it just painted. + result_id = str(uuid.uuid4()) + state["messages"].append( + { + "id": result_id, + "role": "tool", + "content": envelope, + "tool_call_id": tool_call.id, + } + ) + # The A2UI middleware paints the fixed surface from the tool + # RESULT (a2ui_operations envelope), which the bridge otherwise + # surfaces only via MESSAGES_SNAPSHOT. Emit it as a + # TOOL_CALL_RESULT so the middleware detects and renders it. + await copilotkit_emit_tool_result( + tool_call.id, envelope, message_id=result_id + ) + + # A frontend call ends the run so the client can run it and send the + # result back on the next one; feeding the model again here would + # leave that call unanswered. An orphan call does NOT end the run: it + # was dropped, so the history is well-formed, and ending here would + # cost the user a reply. The model gets another turn to answer in text + # instead, bounded by MAX_MODEL_TURNS. + if client: + return + + logger.warning( + "Fixed-schema turn hit the %d-model-turn cap with the model still " + "calling tools; ending the run without a closing reply", + MAX_MODEL_TURNS, + ) diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat.py b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat.py index f15004f8de..e5f537be56 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat.py @@ -19,7 +19,7 @@ async def chat(self): await acompletion( # 1.1 Specify the model to use - model="openai/gpt-4o", + model="openai/gpt-5.4", messages=[ { "role": "system", diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat_multimodal.py b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat_multimodal.py new file mode 100644 index 0000000000..701be689e0 --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat_multimodal.py @@ -0,0 +1,39 @@ +""" +A multimodal agentic chat flow that can analyze images and other media. + +Images the user attaches are converted to LiteLLM's ``image_url`` shape by the +integration layer before the run, so the flow only has to point a vision-capable +model at the conversation. +""" + +from crewai.flow.flow import Flow, start +from litellm import acompletion +from ..sdk import copilotkit_stream, CopilotKitState + + +class AgenticChatMultimodalFlow(Flow[CopilotKitState]): + + @start() + async def chat(self): + system_prompt = ( + "You are a helpful assistant that can analyze images, documents, and " + "other media. When a user shares an image, describe what you see in " + "detail. When a user shares a document, summarize its contents." + ) + + response = await copilotkit_stream( + await acompletion( + model="openai/gpt-5.4", + messages=[ + {"role": "system", "content": system_prompt}, + *self.state.messages, + ], + tools=[ + *self.state.copilotkit.actions, + ], + parallel_tool_calls=False, + stream=True, + ) + ) + + self.state.messages.append(response.choices[0].message) diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat_reasoning.py b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat_reasoning.py new file mode 100644 index 0000000000..8b708d2f9a --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_chat_reasoning.py @@ -0,0 +1,110 @@ +""" +An agentic chat flow that surfaces the model's reasoning. + +The reasoning cell lets the user pick a provider from the frontend; the choice +arrives on ``state.model``. Each provider is streamed over the channel that +actually carries its reasoning, and the bridge maps both onto REASONING_*: + +* Anthropic (extended thinking) and Gemini reason on the litellm + chat-completions delta, so they stream through ``acompletion``. +* OpenAI's reasoning models emit reasoning summaries ONLY over the Responses + API, so they stream through ``copilotkit_responses``. Over chat-completions + they answer with no thinking trace at all. + +The Responses channel is used only when the bridge probes it as available +(``responses_channel_available``); otherwise the flow degrades to +chat-completions with a warning, and OpenAI answers without a trace. +""" + +import logging +from typing import Any, Dict, List + +from crewai.flow.flow import Flow, start +from litellm import acompletion + +from ..sdk import ( + CopilotKitState, + copilotkit_responses, + copilotkit_stream, + responses_channel_available, +) + +logger = logging.getLogger("ag_ui_crewai") + +SYSTEM_PROMPT = "You are a helpful assistant." + +# The frontend dropdown's choices. This is a USER selection, not a capability +# inference: which transport carries a provider's reasoning is decided by the +# bridge's runtime probe, never by matching on these model strings. +OPENAI_MODEL = "openai/gpt-5.4" +ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-5" +GEMINI_MODEL = "gemini/gemini-2.5-pro" + + +class AgentState(CopilotKitState): + """Chat state plus the frontend-selected reasoning model.""" + + model: str = "OpenAI" + + +def _chat_completion_kwargs(selected_model: str) -> Dict[str, Any]: + """Map a chat-completions provider choice to its model + reasoning config.""" + if selected_model == "Anthropic": + return { + "model": ANTHROPIC_MODEL, + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + if selected_model == "Gemini": + return { + "model": GEMINI_MODEL, + "reasoning_effort": "low", + } + # OpenAI over chat-completions: no reasoning content is returned, and + # reasoning_effort is rejected outright for the gpt-5 family. Reached only + # when the Responses channel is unavailable. + return {"model": OPENAI_MODEL} + + +class AgenticChatReasoningFlow(Flow[AgentState]): + + @start() + async def chat(self): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + *self.state.messages, + ] + tools: List[Any] = [*self.state.copilotkit.actions] + selected_model = self.state.model + + if selected_model == "OpenAI" and responses_channel_available(): + stream = await copilotkit_responses( + model=OPENAI_MODEL, + messages=messages, + tools=tools or None, + # ``summary`` is what makes OpenAI stream the reasoning summary + # deltas at all; without it the run succeeds silently with no + # trace to surface. + reasoning={"effort": "medium", "summary": "auto"}, + # Forwarded through ``**kwargs``. One frontend tool call at a + # time, matching the chat-completions branch and every other demo; + # the OpenAI default is parallel. + **({"parallel_tool_calls": False} if tools else {}), + ) + else: + if selected_model == "OpenAI": + logger.warning( + "The OpenAI Responses channel is unavailable, so this run " + "streams over chat-completions and will surface no thinking " + "trace. Upgrade litellm to a build exposing 'aresponses'." + ) + stream = await acompletion( + messages=messages, + tools=tools or None, + parallel_tool_calls=False if tools else None, + stream=True, + **_chat_completion_kwargs(selected_model), + ) + + response = await copilotkit_stream(stream) + + self.state.messages.append(response.choices[0].message) diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_generative_ui.py b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_generative_ui.py index 9bbb7f9e02..33f7a1f969 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_generative_ui.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/agentic_generative_ui.py @@ -109,7 +109,7 @@ async def chat(self): await acompletion( # 2.1 Specify the model to use - model="openai/gpt-4o", + model="openai/gpt-5.4", messages=[ { "role": "system", diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/backend_tool_rendering.py b/integrations/crew-ai/python/ag_ui_crewai/examples/backend_tool_rendering.py index 3a390fdbcc..815eb75220 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/backend_tool_rendering.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/backend_tool_rendering.py @@ -68,7 +68,7 @@ async def chat(self): "get_weather tool to look up the weather before you answer." ), tools=[get_weather], - llm="openai/gpt-4o", + llm="openai/gpt-5.4", verbose=False, ) task = Task( diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/conversational.py b/integrations/crew-ai/python/ag_ui_crewai/examples/conversational.py new file mode 100644 index 0000000000..abe30fffac --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/conversational.py @@ -0,0 +1,115 @@ +"""Conversational variants of the regular CrewAI dojo Flows.""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from typing import Any, TypeVar + +from crewai.experimental.conversational import ( + ConversationConfig, + message_to_llm_dict, +) +from crewai.flow.flow import listen +from pydantic import BaseModel, ConfigDict + +from ..sdk import CopilotKitState +from .a2ui_dynamic_schema import A2UIDynamicSchemaFlow +from .a2ui_fixed_schema import A2UIFixedSchemaFlow +from .a2ui_recovery import A2UIRecoveryFlow +from .agentic_chat import AgenticChatFlow +from .agentic_chat_multimodal import AgenticChatMultimodalFlow +from .agentic_chat_reasoning import AgenticChatReasoningFlow +from .agentic_generative_ui import AgenticGenerativeUIFlow +from .backend_tool_rendering import BackendToolRenderingFlow +from .human_in_the_loop import HumanInTheLoopFlow +from .interrupt_flow import InterruptFlow +from .predictive_state_updates import PredictiveStateUpdatesFlow +from .shared_state import SharedStateFlow +from .subgraphs import SubgraphsFlow +from .tool_based_generative_ui import ToolBasedGenerativeUIFlow + + +class _AGUIMappingState(CopilotKitState, Mapping[str, Any]): + """Typed conversational fields with the dict API used by untyped Flows.""" + + model_config = ConfigDict(extra="allow") + + def get(self, key: str, default: Any = None) -> Any: + value = getattr(self, key, default) + return value.model_dump() if isinstance(value, BaseModel) else value + + def __getitem__(self, key: str) -> Any: + return getattr(self, key) + + def __setitem__(self, key: str, value: Any) -> None: + setattr(self, key, value) + + def __iter__(self) -> Iterator[str]: + return iter(self.model_dump()) + + def __len__(self) -> int: + return len(self.model_dump()) + + +class _AGUIConversationalBehavior: + """Route each public turn through the regular Flow's existing starts.""" + + def receive_user_message(self, *args: Any, **kwargs: Any) -> Any: + result = super().receive_user_message(*args, **kwargs) + messages = getattr(self.state, "messages", None) + if messages and isinstance(messages[-1], BaseModel): + messages[-1] = message_to_llm_dict(messages[-1]) + return result + + def route_turn(self, _context: Any) -> str: + return "ag_ui_complete" + + @listen("__ag_ui_disable_builtin_end__") + def end_conversation(self) -> None: + """Keep a regular method named ``end`` from firing CrewAI's terminator.""" + return None + + @listen("ag_ui_complete") + def finish_ag_ui_turn(self) -> None: + return None + + +def _conversational_type(base: type[Any]) -> type[Any]: + flow_methods = { + name: value + for owner in (base, _AGUIConversationalBehavior) + for name, value in owner.__dict__.items() + if not name.startswith("_") and hasattr(value, "__flow_method_definition__") + } + initial_state_type = getattr(base, "_initial_state_t", None) + flow_type = type( + f"Conversational{base.__name__}", + (_AGUIConversationalBehavior, base), + { + **flow_methods, + "__module__": __name__, + "conversational": True, + "conversational_config": ConversationConfig(defer_trace_finalization=False), + }, + ) + if isinstance(initial_state_type, TypeVar): + flow_type._initial_state_t = _AGUIMappingState + return flow_type + + +CONVERSATIONAL_FLOW_TYPES = { + "agentic_chat": _conversational_type(AgenticChatFlow), + "agentic_chat_reasoning": _conversational_type(AgenticChatReasoningFlow), + "agentic_chat_multimodal": _conversational_type(AgenticChatMultimodalFlow), + "backend_tool_rendering": _conversational_type(BackendToolRenderingFlow), + "interrupt": _conversational_type(InterruptFlow), + "human_in_the_loop": _conversational_type(HumanInTheLoopFlow), + "agentic_generative_ui": _conversational_type(AgenticGenerativeUIFlow), + "predictive_state_updates": _conversational_type(PredictiveStateUpdatesFlow), + "shared_state": _conversational_type(SharedStateFlow), + "tool_based_generative_ui": _conversational_type(ToolBasedGenerativeUIFlow), + "subgraphs": _conversational_type(SubgraphsFlow), + "a2ui_dynamic_schema": _conversational_type(A2UIDynamicSchemaFlow), + "a2ui_recovery": _conversational_type(A2UIRecoveryFlow), + "a2ui_fixed_schema": _conversational_type(A2UIFixedSchemaFlow), +} diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/crew_chat.py b/integrations/crew-ai/python/ag_ui_crewai/examples/crew_chat.py index eaa7f15618..ecea6f4745 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/crew_chat.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/crew_chat.py @@ -31,5 +31,5 @@ def crew(self) -> Crew: tasks=[assist_task], process=Process.sequential, verbose=False, - chat_llm="openai/gpt-4o", + chat_llm="openai/gpt-5.4", ) diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/error_flow.py b/integrations/crew-ai/python/ag_ui_crewai/examples/error_flow.py deleted file mode 100644 index 0790374161..0000000000 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/error_flow.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Flow that intentionally raises to test the RunErrorEvent error handling path.""" - -from crewai.flow.flow import Flow, start -from ..sdk import CopilotKitState - - -class ErrorFlow(Flow[CopilotKitState]): - """A flow that always raises an exception on kickoff. - Used to test that endpoint.py's except handler emits RunErrorEvent correctly.""" - - @start() - async def chat(self): - raise RuntimeError("Intentional error for testing RunErrorEvent handling") diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/human_in_the_loop.py b/integrations/crew-ai/python/ag_ui_crewai/examples/human_in_the_loop.py index fc0b3b2898..0587e576e2 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/human_in_the_loop.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/human_in_the_loop.py @@ -17,7 +17,7 @@ "type": "function", "function": { "name": "generate_task_steps", - "description": "Make up 10 steps (only a couple of words per step) that are required for a task. The step should be in imperative form (i.e. Dig hole, Open door, ...)", + "description": "Make up the number of task steps requested by the user (only a couple of words per step). If the user does not request a count, make a concise plan. Each step should be in imperative form (i.e. Dig hole, Open door, ...)", "parameters": { "type": "object", "properties": { @@ -38,7 +38,7 @@ }, "required": ["description", "status"] }, - "description": "An array of 10 step objects, each containing text and status" + "description": "An array containing the requested number of step objects, each with text and status" } }, "required": ["steps"] @@ -46,6 +46,20 @@ } } +HITL_SYSTEM_PROMPT = """ +You are a helpful assistant that can perform any task. +CRITICAL: You MUST call the `generate_task_steps` function when the user asks you to perform a task. +CRITICAL: Generate exactly the step count requested by the user. If no count is requested, generate a concise plan. +When the function `generate_task_steps` is called, the user will decide to enable or disable a step and either accept or reject the plan. +CRITICAL: If the tool result has `accepted: false`, the plan was rejected. Do not perform the rejected plan. Wait for revision instructions from the user. +CRITICAL: After a rejection, interpret a terse numeric reply such as `5.` as a revised requested step count, then call `generate_task_steps` again with exactly that many steps. +If the tool result has `accepted: true`, provide a textual description of how you are performing only the accepted, enabled steps. +If the user has disabled a step, you are not allowed to perform that step. +However, you should find a creative workaround to perform the task, and if an essential step is disabled, you can even use +some humor in the description of how you are performing the task. +Don't just repeat a list of steps, come up with a creative but short description (3 sentences max) of how you are performing the task. +""" + class TaskStep(BaseModel): description: str status: Literal["enabled", "disabled"] @@ -78,17 +92,6 @@ async def chat(self): """ Standard chat node. """ - system_prompt = """ - You are a helpful assistant that can perform any task. - You MUST call the `generate_task_steps` function when the user asks you to perform a task. - When the function `generate_task_steps` is called, the user will decide to enable or disable a step. - After the user has decided which steps to perform, provide a textual description of how you are performing the task. - If the user has disabled a step, you are not allowed to perform that step. - However, you should find a creative workaround to perform the task, and if an essential step is disabled, you can even use - some humor in the description of how you are performing the task. - Don't just repeat a list of steps, come up with a creative but short description (3 sentences max) of how you are performing the task. - """ - # 1. Run the model and stream the response # Note: In order to stream the response, wrap the completion call in # copilotkit_stream and set stream=True. @@ -96,11 +99,11 @@ async def chat(self): await acompletion( # 1.1 Specify the model to use - model="openai/gpt-4o", + model="openai/gpt-5.4", messages=[ { "role": "system", - "content": system_prompt + "content": HITL_SYSTEM_PROMPT }, *self.state.messages ], diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/interrupt_flow.py b/integrations/crew-ai/python/ag_ui_crewai/examples/interrupt_flow.py index 4e1d6c92bf..711ab70939 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/interrupt_flow.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/interrupt_flow.py @@ -25,7 +25,7 @@ from ..sdk import CopilotKitState, copilotkit_stream from .._hitl import agui_feedback_provider -MODEL = "openai/gpt-4o" +MODEL = "openai/gpt-5.4" EXTRACT_PROMPT = """You are a scheduling assistant. From the conversation, work out which meeting the user wants to book. diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/predictive_state_updates.py b/integrations/crew-ai/python/ag_ui_crewai/examples/predictive_state_updates.py index af5b337bb3..a7d13fc64c 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/predictive_state_updates.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/predictive_state_updates.py @@ -87,7 +87,7 @@ async def chat(self): await acompletion( # 2.1 Specify the model to use - model="openai/gpt-4o", + model="openai/gpt-5.4", messages=[ { "role": "system", diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/shared_state.py b/integrations/crew-ai/python/ag_ui_crewai/examples/shared_state.py index b7a481b5a1..4b58800bd0 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/shared_state.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/shared_state.py @@ -139,10 +139,24 @@ async def chat(self): Standard chat node. """ - system_prompt = f"""You are a helpful assistant for creating recipes. - This is the current state of the recipe: {self.state.model_dump_json(indent=2)} - You can modify the recipe by calling the generate_recipe tool. - If you have just created or modified the recipe, just answer in one sentence what you did. + recipe_json = ( + self.state.recipe.model_dump_json(indent=2) + if self.state.recipe is not None + else "{}" + ) + system_prompt = f"""You are a helpful assistant for creating recipes. + This is the current state of the recipe: {recipe_json} + You can improve the recipe by calling the generate_recipe tool. + + IMPORTANT: + 1. Create a recipe using the existing ingredients and instructions. Make sure the recipe is complete. + 2. The recipe MUST comply with the selected dietary preferences (special_preferences). If an existing ingredient violates a selected preference (for example butter or Parmesan cheese when "Vegan" is selected), REPLACE it with a compliant alternative (e.g. olive oil, a plant-based butter, nutritional yeast) or remove it, and update the affected instructions to match. + 3. Keep the selected special_preferences in the recipe you return, and keep every existing ingredient and instruction that already complies, appending any new ones. + 4. 'ingredients' is always an array of objects with 'icon', 'name', and 'amount' fields + 5. 'instructions' is always an array of strings + 6. For the 'icon' field in ingredients, ALWAYS use actual Unicode emoji characters (like 🥕 🍅 🧅 🥖 🧈 🥛 🧂 etc.), NEVER use text, ANSI codes, or placeholders + + If you have just created or modified the recipe, just answer in one sentence what you did. dont describe the recipe, just say what you did. """ # 1. Here we specify that we want to stream the tool call to generate_recipe @@ -161,7 +175,7 @@ async def chat(self): await acompletion( # 2.1 Specify the model to use - model="openai/gpt-4o", + model="openai/gpt-5.4", messages=[ { "role": "system", diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/subgraphs.py b/integrations/crew-ai/python/ag_ui_crewai/examples/subgraphs.py new file mode 100644 index 0000000000..793f93a006 --- /dev/null +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/subgraphs.py @@ -0,0 +1,242 @@ +""" +A travel-planner demo showcasing a multi-agent flow with human-in-the-loop. + +A supervisor coordinates three specialists (flights, hotels, experiences). The +flights and hotels steps pause the flow so the user picks an option; the +experiences step narrates recommendations. ``active_agent`` tracks who is +working so the UI can light up the current specialist, and each pick lands in a +shared ``itinerary``. +""" + +import json +import uuid +from typing import Any, Dict, List + +from crewai.flow.flow import Flow, listen, start +from crewai.flow import human_feedback +from litellm import acompletion + +from ..sdk import CopilotKitState, copilotkit_stream +from .._hitl import agui_feedback_provider + +MODEL = "openai/gpt-5.4" + +STATIC_FLIGHTS: List[Dict[str, str]] = [ + { + "airline": "KLM", + "departure": "Amsterdam (AMS)", + "arrival": "San Francisco (SFO)", + "price": "$650", + "duration": "11h 30m", + }, + { + "airline": "United", + "departure": "Amsterdam (AMS)", + "arrival": "San Francisco (SFO)", + "price": "$720", + "duration": "12h 15m", + }, +] + +STATIC_HOTELS: List[Dict[str, str]] = [ + { + "name": "Hotel Zephyr", + "location": "Fisherman's Wharf", + "price_per_night": "$280/night", + "rating": "4.2 stars", + }, + { + "name": "The Ritz-Carlton", + "location": "Nob Hill", + "price_per_night": "$550/night", + "rating": "4.8 stars", + }, + { + "name": "Hotel Zoe", + "location": "Union Square", + "price_per_night": "$320/night", + "rating": "4.4 stars", + }, +] + +STATIC_EXPERIENCES: List[Dict[str, str]] = [ + { + "name": "Pier 39", + "type": "activity", + "description": "Iconic waterfront destination with shops and sea lions", + "location": "Fisherman's Wharf", + }, + { + "name": "Golden Gate Bridge", + "type": "activity", + "description": "World-famous suspension bridge with stunning views", + "location": "Golden Gate", + }, + { + "name": "Swan Oyster Depot", + "type": "restaurant", + "description": "Historic seafood counter serving fresh oysters", + "location": "Polk Street", + }, + { + "name": "Tartine Bakery", + "type": "restaurant", + "description": "Artisanal bakery famous for bread and pastries", + "location": "Mission District", + }, +] + + +class TravelAgentState(CopilotKitState): + """Shared state for the travel-planner, read by the UI.""" + + origin: str = "Amsterdam" + destination: str = "San Francisco" + flights: List[Dict[str, Any]] = [] + hotels: List[Dict[str, Any]] = [] + experiences: List[Dict[str, Any]] = [] + itinerary: Dict[str, Any] = {} + active_agent: str = "supervisor" + planning_step: str = "start" + + +def _parse_selection(raw: Any) -> Dict[str, Any]: + """Best-effort parse of the resume payload (a JSON-encoded option) to a dict.""" + if isinstance(raw, dict): + return raw + if not isinstance(raw, str): + return {} + text = raw.strip() + if text.startswith("```"): + text = text.strip("`") + if "{" in text: + text = text[text.index("{"):] + try: + parsed = json.loads(text) + except (ValueError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +class SubgraphsFlow(Flow[TravelAgentState]): + """Supervisor-coordinated travel planner with two HITL selection steps.""" + + @start() + async def supervisor(self): + """Kick off planning: greet and hand over to the flights specialist.""" + self.state.active_agent = "supervisor" + self.state.planning_step = "flights" + + @listen(supervisor) + async def prepare_flights(self): + """Flights specialist takes over. + + A step of its own so the state (active agent + found flights) is + snapshotted for the UI before the next step suspends the flow. + """ + self.state.active_agent = "flights" + self.state.flights = STATIC_FLIGHTS + + @listen(prepare_flights) + @human_feedback( + message="Select a flight option.", + provider=agui_feedback_provider, + ) + def find_flights(self): + """Present the flight options and pause for the user's choice.""" + return { + "message": ( + f"Found {len(STATIC_FLIGHTS)} flights from {self.state.origin} to " + f"{self.state.destination}. I recommend {STATIC_FLIGHTS[0]['airline']} " + "since it is on time and cheaper." + ), + "options": STATIC_FLIGHTS, + "recommendation": STATIC_FLIGHTS[0], + "agent": "flights", + } + + @listen(find_flights) + async def select_flight(self, feedback): + """Resumed with the flight pick: record it and hand over to hotels.""" + answer = getattr(feedback, "feedback", feedback) + selected = _parse_selection(answer) or STATIC_FLIGHTS[0] + self.state.itinerary = {**self.state.itinerary, "flight": selected} + self.state.messages.append({ + "id": str(uuid.uuid4()), + "role": "assistant", + "content": ( + f"Flights Agent: Booked the {selected.get('airline')} flight from " + f"{selected.get('departure')} to {selected.get('arrival')}." + ), + }) + self.state.planning_step = "hotels" + + @listen(select_flight) + async def prepare_hotels(self): + """Hotels specialist takes over; snapshot state before the next suspend.""" + self.state.active_agent = "hotels" + self.state.hotels = STATIC_HOTELS + + @listen(prepare_hotels) + @human_feedback( + message="Select a hotel option.", + provider=agui_feedback_provider, + ) + def find_hotels(self): + """Present the hotel options and pause for the user's choice.""" + return { + "message": ( + f"Found {len(STATIC_HOTELS)} hotels in {self.state.destination}. I " + f"recommend {STATIC_HOTELS[2]['name']} for its balance of rating, " + "price, and location." + ), + "options": STATIC_HOTELS, + "recommendation": STATIC_HOTELS[2], + "agent": "hotels", + } + + @listen(find_hotels) + async def select_hotel(self, feedback): + """Resumed with the hotel pick: record it and hand over to experiences.""" + answer = getattr(feedback, "feedback", feedback) + selected = _parse_selection(answer) or STATIC_HOTELS[2] + self.state.itinerary = {**self.state.itinerary, "hotel": selected} + self.state.messages.append({ + "id": str(uuid.uuid4()), + "role": "assistant", + "content": f"Hotels Agent: Great choice, you'll love {selected.get('name')}.", + }) + self.state.planning_step = "experiences" + + @listen(select_hotel) + async def prepare_experiences(self): + """Experiences specialist takes over; snapshot state before narrating.""" + self.state.active_agent = "experiences" + self.state.experiences = STATIC_EXPERIENCES + + @listen(prepare_experiences) + async def find_experiences(self): + """Narrate the experiences the specialist found.""" + itinerary = self.state.itinerary + system_prompt = ( + "You are the experiences agent for a trip to " + f"{self.state.destination}. The traveller has chosen the " + f"{itinerary.get('flight', {}).get('airline', 'selected')} flight and " + f"the {itinerary.get('hotel', {}).get('name', 'selected')} hotel. You " + "already found these experiences: " + f"{json.dumps(STATIC_EXPERIENCES)}. In two or three friendly sentences, " + "let the traveller know what you found. Do not ask questions." + ) + + response = await copilotkit_stream( + await acompletion( + model=MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + *self.state.messages, + ], + stream=True, + ) + ) + self.state.messages.append(response.choices[0].message) + self.state.planning_step = "complete" diff --git a/integrations/crew-ai/python/ag_ui_crewai/examples/tool_based_generative_ui.py b/integrations/crew-ai/python/ag_ui_crewai/examples/tool_based_generative_ui.py index 83a75f3291..df260205fe 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/examples/tool_based_generative_ui.py +++ b/integrations/crew-ai/python/ag_ui_crewai/examples/tool_based_generative_ui.py @@ -1,5 +1,11 @@ """ An example demonstrating tool-based generative UI. + +The ``generate_haiku`` tool is defined on the FRONTEND (via ``useFrontendTool``): +its handler renders the haiku onto the main canvas and picks the background +image and gradient. So the flow binds the frontend actions and lets the model +call that tool, rather than defining a backend tool of the same name (which would +render the chat card but never run the frontend handler that updates the canvas). """ from crewai.flow.flow import Flow, start @@ -7,44 +13,6 @@ from ..sdk import copilotkit_stream, CopilotKitState -# This tool generates a haiku on the server. -# The tool call will be streamed to the frontend as it is being generated. -GENERATE_HAIKU_TOOL = { - "type": "function", - "function": { - "name": "generate_haiku", - "description": "Generate a haiku in Japanese and its English translation", - "parameters": { - "type": "object", - "properties": { - "japanese": { - "type": "array", - "items": { - "type": "string" - }, - "description": "An array of three lines of the haiku in Japanese" - }, - "english": { - "type": "array", - "items": { - "type": "string" - }, - "description": "An array of three lines of the haiku in English" - }, - "image_names": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Names of 3 relevant images from the provided list" - } - }, - "required": ["japanese", "english", "image_names"] - } - } -} - - class ToolBasedGenerativeUIFlow(Flow[CopilotKitState]): """ A flow that demonstrates tool-based generative UI. @@ -52,49 +20,27 @@ class ToolBasedGenerativeUIFlow(Flow[CopilotKitState]): @start() async def chat(self): - """ - The main function handling chat and tool calls. - """ - system_prompt = "You assist the user in generating a haiku. When generating a haiku using the 'generate_haiku' tool, you MUST also select exactly 3 image filenames from the following list that are most relevant to the haiku's content or theme. Return the filenames in the 'image_names' parameter. Dont provide the relavent image names in your final response to the user. " - + system_prompt = ( + "Help the user write haikus. When the user asks for a haiku, call the " + "generate_haiku tool to display it. Choose a fitting background image " + "and gradient for the haiku's theme." + ) - # 1. Run the model and stream the response - # Note: In order to stream the response, wrap the completion call in - # copilotkit_stream and set stream=True. response = await copilotkit_stream( await acompletion( - - # 1.1 Specify the model to use - model="openai/gpt-4o", + model="openai/gpt-5.4", messages=[ - { - "role": "system", - "content": system_prompt - }, - *self.state.messages + {"role": "system", "content": system_prompt}, + *self.state.messages, + ], + # Bind the frontend-provided tools (generate_haiku lives on the + # frontend, so its handler updates the canvas when called). + tools=[ + *self.state.copilotkit.actions, ], - - # 1.2 Bind the available tools to the model - tools=[ GENERATE_HAIKU_TOOL ], - - # 1.3 Disable parallel tool calls to avoid race conditions, - # enable this for faster performance if you want to manage - # the complexity of running tool calls in parallel. parallel_tool_calls=False, - stream=True + stream=True, ) ) - message = response.choices[0].message - # 2. Append the message to the messages in state - self.state.messages.append(message) - - # 3. If there are tool calls, append a tool message to the messages in state - if message.tool_calls: - self.state.messages.append( - { - "tool_call_id": message.tool_calls[0].id, - "role": "tool", - "content": "Haiku generated." - } - ) + self.state.messages.append(response.choices[0].message) diff --git a/integrations/crew-ai/python/ag_ui_crewai/sdk.py b/integrations/crew-ai/python/ag_ui_crewai/sdk.py index 92319cde5c..2636265f53 100644 --- a/integrations/crew-ai/python/ag_ui_crewai/sdk.py +++ b/integrations/crew-ai/python/ag_ui_crewai/sdk.py @@ -3,6 +3,7 @@ """ import copy +import inspect import logging import uuid from dataclasses import dataclass @@ -45,7 +46,33 @@ BridgedReasoningEndEvent, BridgedReasoningEncryptedValueEvent, ) -from ._reasoning import reasoning_from_delta +from ._reasoning import ( + DeltaReasoning, + reasoning_from_delta, + reasoning_from_responses_event, + responses_event_type, +) +from ._responses import ( + copilotkit_responses, + is_responses_stream, + is_sync_responses_stream, + iter_responses_events, + responses_channel_available, +) +# The event ``type`` discriminators the driver below branches on live next to the +# ROLE each one plays for this bridge, which is what decides the cost of losing +# one (see ``_responses_events``). +from ._responses_events import ( + RESPONSES_COMPLETED, + RESPONSES_CREATED, + RESPONSES_ERROR, + RESPONSES_FAILED, + RESPONSES_FUNCTION_CALL_ARGS_DELTA, + RESPONSES_INCOMPLETE, + RESPONSES_OUTPUT_ITEM_ADDED, + RESPONSES_OUTPUT_TEXT_DELTA, + RESPONSES_TERMINAL, +) from .utils import yield_control, convert_litellm_multimodal_to_agui _LOGGER = logging.getLogger(__name__) @@ -58,6 +85,16 @@ class CopilotKitState(FlowState): """CopilotKit state""" messages: List[Any] = Field(default_factory=list) copilotkit: CopilotKitProperties = Field(default_factory=CopilotKitProperties) + # CrewAI's experimental conversational runtime writes these fields while a + # turn is being routed. Exclude them from AG-UI state snapshots so enabling + # the runtime contract does not change regular Flow wire state. + current_user_message: Optional[str] = Field(default=None, exclude=True) + last_user_message: Optional[str] = Field(default=None, exclude=True) + last_intent: Optional[str] = Field(default=None, exclude=True) + ended: bool = Field(default=False, exclude=True) + events: List[Any] = Field(default_factory=list, exclude=True) + agent_threads: Dict[str, List[Any]] = Field(default_factory=dict, exclude=True) + session_ready: bool = Field(default=False, exclude=True) class PredictStateConfig(TypedDict): """ @@ -293,6 +330,111 @@ async def copilotkit_emit_state(state: Any) -> Literal[True]: return True +class _ReasoningChannel: + """The REASONING_* lifecycle for one streamed assistant turn. + + Both streaming drivers project their provider payload onto + :class:`DeltaReasoning` and hand it here, so the lifecycle is defined once: + a reasoning message opens lazily on the first reasoning payload and closes + on the first answer token, the first tool call, or the end of the stream. A + model that interleaves thinking with tool calls therefore gets one reasoning + message per thinking block. + """ + + def __init__(self, flow: Any): + self._flow = flow + self.message_id: Optional[str] = None + self.open = False + # Whether a reasoning message has already opened and closed this turn. + self.closed_once = False + + async def emit(self, reasoning: DeltaReasoning) -> None: + """Emit one reasoning payload, opening the message if needed. + + A payload carrying reasoning TEXT always opens a message when none is + open, so a genuine later thinking block still surfaces in full. + + An encrypted-only payload may open the FIRST message of a turn (an + Anthropic ``redacted_thinking`` block is entirely encrypted, and the + client still has to learn that thinking happened) but never a later one: + with a block already ended, it carries nothing renderable and would + surface as an empty second trace. It rides an open message when there is + one, and is otherwise dropped on its own. + """ + if not reasoning: + return + if not self.open: + if self.closed_once and not reasoning.text: + _LOGGER.debug( + "Dropping an encrypted-only reasoning blob that arrived after " + "its reasoning message closed" + ) + return + self.message_id = str(uuid.uuid4()) + crewai_event_bus.emit( + self._flow, + BridgedReasoningStartEvent( + type=EventType.REASONING_START, + message_id=self.message_id, + ), + ) + crewai_event_bus.emit( + self._flow, + BridgedReasoningMessageStartEvent( + type=EventType.REASONING_MESSAGE_START, + message_id=self.message_id, + role="reasoning", + ), + ) + self.open = True + if reasoning.text: + crewai_event_bus.emit( + self._flow, + BridgedReasoningMessageContentEvent( + type=EventType.REASONING_MESSAGE_CONTENT, + message_id=self.message_id, + delta=reasoning.text, + ), + ) + for value in reasoning.encrypted: + crewai_event_bus.emit( + self._flow, + BridgedReasoningEncryptedValueEvent( + type=EventType.REASONING_ENCRYPTED_VALUE, + subtype="message", + entity_id=self.message_id, + encrypted_value=value, + ), + ) + await yield_control() + + def close(self) -> None: + """Close an open reasoning message. A no-op when nothing is open. + + Records that a block ended, which is what stops a later encrypted-only + payload from opening an empty message of its own. + """ + if not self.open: + return + crewai_event_bus.emit( + self._flow, + BridgedReasoningMessageEndEvent( + type=EventType.REASONING_MESSAGE_END, + message_id=self.message_id, + ), + ) + crewai_event_bus.emit( + self._flow, + BridgedReasoningEndEvent( + type=EventType.REASONING_END, + message_id=self.message_id, + ), + ) + self.open = False + self.closed_once = True + self.message_id = None + + async def copilotkit_stream(response): """ Stream litellm responses token by token to CopilotKit. @@ -307,14 +449,40 @@ async def copilotkit_stream(response): ) ) ``` + + Also consumes an OpenAI Responses-API stream opened by + ``copilotkit_responses``, and returns the same chat-shaped + ``ModelResponse`` either way so a flow node's code is identical on both + channels. That stream must be the ASYNC one: a synchronous Responses + iterator raises the ``ValueError`` below, naming the async entrypoint. + + Raises + ------ + ValueError + For any response this helper cannot consume, so an unusable response is + one clear caller error rather than a failure deep inside a driver. """ if isinstance(response, ModelResponse): return _copilotkit_stream_response(response) if isinstance(response, CustomStreamWrapper): return await _copilotkit_stream_custom_stream_wrapper(response) + if is_responses_stream(response): + return await _copilotkit_stream_responses(response) + if is_sync_responses_stream(response): + # A recognisable Responses stream, just the synchronous one: the drivers + # here are async-only. Same ValueError as any other unusable type, with + # the fix named rather than left to a missing-__aiter__ AttributeError. + raise ValueError( + f"Invalid response type {type(response)!r}: this is a synchronous " + f"Responses-API streaming iterator, which cannot be consumed " + f"asynchronously. Open the stream with " + f"'await copilotkit_responses(...)' (litellm's async 'aresponses' " + f"entrypoint) instead of the synchronous one" + ) raise ValueError( - f"Invalid response type {type(response)!r}; " - f"expected {ModelResponse.__name__} or {CustomStreamWrapper.__name__}" + f"Invalid response type {type(response)!r}; expected " + f"{ModelResponse.__name__}, {CustomStreamWrapper.__name__} or an async " + f"Responses-API streaming iterator" ) @@ -338,29 +506,7 @@ async def _copilotkit_stream_custom_stream_wrapper(response: CustomStreamWrapper # delta.thinking_blocks) precede the answer; open a reasoning message on the # first reasoning delta and close it once the model emits answer text or a # tool call (or the stream ends). - reasoning_message_id: Optional[str] = None - reasoning_open = False - - def _close_reasoning(): - nonlocal reasoning_open, reasoning_message_id - if not reasoning_open: - return - crewai_event_bus.emit( - flow, - BridgedReasoningMessageEndEvent( - type=EventType.REASONING_MESSAGE_END, - message_id=reasoning_message_id, - ), - ) - crewai_event_bus.emit( - flow, - BridgedReasoningEndEvent( - type=EventType.REASONING_END, - message_id=reasoning_message_id, - ), - ) - reasoning_open = False - reasoning_message_id = None + reasoning = _ReasoningChannel(flow) try: async for chunk in response: @@ -377,53 +523,14 @@ def _close_reasoning(): delta = choice["delta"] # Stream reasoning tokens (provider-agnostic via litellm normalisation). - reasoning = reasoning_from_delta(delta) - if reasoning: - if not reasoning_open: - reasoning_message_id = str(uuid.uuid4()) - crewai_event_bus.emit( - flow, - BridgedReasoningStartEvent( - type=EventType.REASONING_START, - message_id=reasoning_message_id, - ), - ) - crewai_event_bus.emit( - flow, - BridgedReasoningMessageStartEvent( - type=EventType.REASONING_MESSAGE_START, - message_id=reasoning_message_id, - role="reasoning", - ), - ) - reasoning_open = True - if reasoning.text: - crewai_event_bus.emit( - flow, - BridgedReasoningMessageContentEvent( - type=EventType.REASONING_MESSAGE_CONTENT, - message_id=reasoning_message_id, - delta=reasoning.text, - ), - ) - for value in reasoning.encrypted: - crewai_event_bus.emit( - flow, - BridgedReasoningEncryptedValueEvent( - type=EventType.REASONING_ENCRYPTED_VALUE, - subtype="message", - entity_id=reasoning_message_id, - encrypted_value=value, - ), - ) - await yield_control() + await reasoning.emit(reasoning_from_delta(delta)) text_content = delta["content"] or None # Stream text messages if text_content is not None: # Reasoning is done once the answer starts. - _close_reasoning() + reasoning.close() # add to the current text message content += text_content crewai_event_bus.emit( @@ -442,7 +549,7 @@ def _close_reasoning(): tool_calls = delta["tool_calls"] or None if tool_calls is not None: # Reasoning is done once the model calls a tool. - _close_reasoning() + reasoning.close() for tool_call in tool_calls: delta_id = getattr(tool_call, "id", None) delta_name = tool_call.function["name"] @@ -551,7 +658,7 @@ def _close_reasoning(): # Close a reasoning message left open by a stream that carried only # reasoning, ended before any answer text / tool call, or raised # mid-reasoning. - _close_reasoning() + reasoning.close() incomplete = [ e for e in tool_calls_by_index.values() @@ -598,6 +705,367 @@ def _close_reasoning(): ] ) +async def _copilotkit_stream_responses(response): + """Stream an OpenAI Responses-API call to CopilotKit. + + The behavioural twin of ``_copilotkit_stream_custom_stream_wrapper`` for the + Responses channel: it emits the SAME ``Bridged*`` events (so both transports + carry them unchanged) and returns the SAME chat-shaped ``ModelResponse``, so + a flow node reads ``response.choices[0].message`` either way. + + The channel exists because OpenAI's reasoning models stream their reasoning + summaries here and NOWHERE on chat-completions. Event ``type`` values are + read as strings (see ``_responses``) so a litellm build that predates an + event still delivers it via ``GenericEvent``. + """ + flow = flow_context.get(None) + + message_id: Optional[str] = None + content = "" + created = 0 + model = "" + failure: Optional[str] = None + # Set when the turn ends on ``response.incomplete``: the assistant message + # was CUT OFF, and reporting a clean "stop" would make a truncated turn + # indistinguishable from a finished one. + truncated_finish_reason: Optional[str] = None + # Function calls keyed by the Responses ``item_id``, which every argument + # delta for that call carries. Insertion order is the provider's order. + calls_by_item: Dict[str, Dict[str, Any]] = {} + + reasoning = _ReasoningChannel(flow) + + def _message_id_for(event: Any) -> Optional[str]: + """The assistant message id for this turn, resolved once then reused. + + Reads whichever id this event shape actually carries (see + ``_responses_item_id``), falling back to a uuid when it carries none, so + the streamed message has ONE stable id across the turn either way. + ``response.created`` normally wins because the caller records its + ``response.id`` before any output item arrives. + """ + nonlocal message_id + if message_id is None: + message_id = _responses_item_id(event) or str(uuid.uuid4()) + return message_id + + events = iter_responses_events(response) + try: + async for event in events: + event_type = responses_event_type(event) + if event_type is None: + continue + + # Reasoning summaries + the encrypted reasoning blob. + await reasoning.emit(reasoning_from_responses_event(event)) + + if event_type == RESPONSES_CREATED: + # ``response.id`` is the stable id for this turn; use it as the + # assistant message id (parity with the chat path's chunk id). + created_response = getattr(event, "response", None) + if message_id is None: + message_id = _responses_attr(created_response, "id") + model = _responses_attr(created_response, "model") or model + created = _responses_created_timestamp( + _responses_attr(created_response, "created_at"), created + ) + continue + + if event_type == RESPONSES_OUTPUT_ITEM_ADDED: + item = getattr(event, "item", None) + if not isinstance(item, dict) or item.get("type") != "function_call": + continue + item_id = item.get("id") + # ``call_id`` is what a later ``function_call_output`` must + # reference, so it is the tool call's identity on the wire. + call_id = item.get("call_id") or item_id + name = item.get("name") + if not item_id or not call_id or not name: + _LOGGER.error( + "ag-ui-crewai dropped a Responses function_call item with " + "no id, call_id or name: %r", + item, + ) + continue + # Reasoning is done once the model calls a tool. + reasoning.close() + # A predicted tool that actually streams suppresses the node-exit + # STATE_SNAPSHOT, which would otherwise rebuild from flow.state and + # clobber the predicted state the client is already rendering. + _mark_predicted_tool_streamed(flow, name) + seeded_arguments = item.get("arguments") or "" + calls_by_item[item_id] = { + "id": call_id, + "name": name, + # ``item.arguments`` on the ADDED item is a complete-value + # snapshot, not a prefix: OpenAI sends "" here and streams the + # arguments as deltas. So it is provisional -- the first delta + # REPLACES it instead of appending, which is what stops a + # provider that populates both from counting them twice. It is + # not put on the wire yet either; a call that never receives a + # delta flushes its arguments after the loop. + "arguments": seeded_arguments, + "provisional": bool(seeded_arguments), + "streamed": False, + } + crewai_event_bus.emit( + flow, + BridgedToolCallChunkEvent( + type=EventType.TOOL_CALL_CHUNK, + tool_call_id=call_id, + tool_call_name=name, + parent_message_id=_message_id_for(event), + delta=None, + ), + ) + await yield_control() + continue + + if event_type == RESPONSES_OUTPUT_TEXT_DELTA: + delta = getattr(event, "delta", None) + if not isinstance(delta, str) or not delta: + continue + # Reasoning is done once the answer starts. + reasoning.close() + content += delta + crewai_event_bus.emit( + flow, + BridgedTextMessageChunkEvent( + type=EventType.TEXT_MESSAGE_CHUNK, + message_id=_message_id_for(event), + role="assistant", + delta=delta, + ), + ) + await yield_control() + continue + + if event_type == RESPONSES_FUNCTION_CALL_ARGS_DELTA: + delta = getattr(event, "delta", None) + item_id = getattr(event, "item_id", None) + entry = calls_by_item.get(item_id) + if entry is None or not isinstance(delta, str) or not delta: + continue + if entry["provisional"]: + # The added item already carried the whole call and the + # provider is streaming it as well: the deltas are + # authoritative, and nothing seeded reached the wire. + entry["arguments"] = "" + entry["provisional"] = False + entry["arguments"] += delta + entry["streamed"] = True + crewai_event_bus.emit( + flow, + BridgedToolCallChunkEvent( + type=EventType.TOOL_CALL_CHUNK, + tool_call_id=entry["id"], + tool_call_name=entry["name"], + parent_message_id=message_id, + delta=delta, + ), + ) + await yield_control() + continue + + if event_type in RESPONSES_TERMINAL: + if event_type in (RESPONSES_ERROR, RESPONSES_FAILED): + failure = _responses_failure_message(event) + if event_type in (RESPONSES_COMPLETED, RESPONSES_INCOMPLETE): + terminal = getattr(event, "response", None) + model = _responses_attr(terminal, "model") or model + created = _responses_created_timestamp( + _responses_attr(terminal, "created_at"), created + ) + if event_type == RESPONSES_INCOMPLETE: + truncated_finish_reason = _responses_incomplete_finish_reason(event) + break + finally: + # Close a reasoning message left open by a stream that carried only + # reasoning, ended before any answer text / tool call, or raised + # mid-reasoning. + reasoning.close() + # The terminal-event ``break`` above leaves both this generator and + # litellm's iterator suspended, so release them rather than waiting for + # the garbage collector to drop the open response. + await _release_responses_stream(response, events) + + if failure is not None: + # Surfaced as a RUN_ERROR by the drivers' exception taxonomy rather than + # returned as a silently empty message. + raise RuntimeError(f"OpenAI Responses stream failed: {failure}") + + # A call whose arguments arrived complete on its output item and never streamed + # a delta: put them on the wire now, so the streamed TOOL_CALL_ARGS still match + # the returned ModelResponse (the chat driver's invariant). + for entry in calls_by_item.values(): + if entry["streamed"] or not entry["arguments"]: + continue + crewai_event_bus.emit( + flow, + BridgedToolCallChunkEvent( + type=EventType.TOOL_CALL_CHUNK, + tool_call_id=entry["id"], + tool_call_name=entry["name"], + parent_message_id=message_id, + delta=entry["arguments"], + ), + ) + await yield_control() + + tool_calls = [ + ChatCompletionMessageToolCall( + function=LiteLLMFunction( + arguments=entry["arguments"], + name=entry["name"], + ), + id=entry["id"], + type="function", + ) + for entry in calls_by_item.values() + ] + return ModelResponse( + id=message_id, + created=created, + model=model, + object='chat.completion', + choices=[ + Choices( + # Truncation outranks ``tool_calls``: a cut-off turn's arguments are + # partial, so reporting a clean tool call would misdescribe it. + finish_reason=truncated_finish_reason + or ("tool_calls" if tool_calls else "stop"), + index=0, + message=LiteLLMMessage( + content=content, + role='assistant', + tool_calls=tool_calls or None, + function_call=None + ) + ) + ] + ) + + +def _responses_attr(response_object: Any, key: str) -> Any: + """Read ``key`` off a Responses payload that may be a model or a dict.""" + if response_object is None: + return None + if isinstance(response_object, dict): + return response_object.get(key) + return getattr(response_object, key, None) + + +def _responses_created_timestamp(value: Any, current: int) -> int: + """Project a Responses ``created_at`` onto ``ModelResponse.created``. + + ``ResponsesAPIResponse.created_at`` is typed ``float`` while + ``ModelResponse.created`` is a strict ``int``: pydantic coerces an integral + float but REJECTS a fractional one, and it would raise only at the end, after + the whole turn had already streamed to the client. Truncate to whole seconds, + and keep the previous value for anything non-numeric. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return current + try: + return int(value) + except (ValueError, OverflowError): # NaN / infinity + return current + + +#: ``incomplete_details.reason`` onto the chat-completions ``finish_reason`` +#: vocabulary, so a truncated turn reads the same on both channels. +_RESPONSES_INCOMPLETE_FINISH_REASONS = { + "max_output_tokens": "length", + "content_filter": "content_filter", +} + + +def _responses_incomplete_finish_reason(event: Any) -> str: + """The chat ``finish_reason`` for a ``response.incomplete`` terminal event. + + A truncated turn must not read as a clean ``stop``: the assistant message is + partial and any tool-call arguments in it are likely unparseable. Also logs + the reason, which is otherwise lost entirely. + """ + details = _responses_attr(getattr(event, "response", None), "incomplete_details") + reason = _responses_attr(details, "reason") + finish_reason = _RESPONSES_INCOMPLETE_FINISH_REASONS.get(reason, "length") + _LOGGER.warning( + "The OpenAI Responses turn ended incomplete (reason=%r): the assistant " + "message is truncated and is reported with finish_reason=%r", + reason, + finish_reason, + ) + return finish_reason + + +async def _close_quietly(candidate: Any) -> bool: + """Best-effort ``aclose()`` / ``close()`` on ``candidate``; True when one ran. + + Feature-detected, never assumed: litellm's Responses iterator exposes neither + (nor ``__aenter__`` / ``__aexit__``), and a closer that raises must not mask a + turn that already streamed. + """ + for name in ("aclose", "close"): + closer = getattr(candidate, name, None) + if not callable(closer): + continue + try: + outcome = closer() + if inspect.isawaitable(outcome): + await outcome + except Exception: # noqa: BLE001 - releasing must never void the turn + _LOGGER.debug( + "Could not release the Responses stream via %s()", name, exc_info=True + ) + continue + return True + return False + + +async def _release_responses_stream(response: Any, events: Any) -> None: + """Release a Responses stream the driver stopped reading. + + The driver breaks on the terminal event instead of draining to + ``StopAsyncIteration``, so neither the wrapping generator nor litellm's + iterator is ever asked to clean up, and that is the happy path for every run. + litellm's iterator exposes no closer of its own and holds the live httpx + response, so probe the iterator first and fall back to the response object it + carries. + """ + await _close_quietly(events) + for candidate in (response, getattr(response, "response", None)): + if candidate is not None and await _close_quietly(candidate): + return + + +def _responses_item_id(event: Any) -> Optional[str]: + """The output-item id a Responses stream event carries, if any. + + Text and function-call argument deltas expose it flat as ``item_id``, while + ``output_item.added`` defines no such field and carries the id inside + ``item``. Reading both shapes is what keeps a stream that skipped + ``response.created`` on a real id from the stream instead of a minted uuid. + """ + item_id = getattr(event, "item_id", None) + if isinstance(item_id, str) and item_id: + return item_id + nested = _responses_attr(getattr(event, "item", None), "id") + return nested if isinstance(nested, str) and nested else None + + +def _responses_failure_message(event: Any) -> str: + """Best-effort human-readable reason from a failed/error Responses event.""" + message = getattr(event, "message", None) + if isinstance(message, str) and message: + return message + error = _responses_attr(getattr(event, "response", None), "error") + error_message = _responses_attr(error, "message") + if isinstance(error_message, str) and error_message: + return error_message + return responses_event_type(event) or "unknown error" + + def _copilotkit_stream_response(response: ModelResponse): return response diff --git a/integrations/crew-ai/python/tests/test_a2ui.py b/integrations/crew-ai/python/tests/test_a2ui.py index f9d48915a8..8052aec10f 100644 --- a/integrations/crew-ai/python/tests/test_a2ui.py +++ b/integrations/crew-ai/python/tests/test_a2ui.py @@ -11,7 +11,7 @@ import pytest -from ag_ui.core import Context, RunAgentInput +from ag_ui.core import Context, RunAgentInput, Tool from ag_ui_a2ui_toolkit import ( A2UI_OPERATIONS_KEY, A2UI_SCHEMA_CONTEXT_DESCRIPTION, @@ -285,7 +285,9 @@ async def test_run_success_returns_envelope_and_streams(monkeypatch): # Progressive streaming: inner render_a2ui chunks were emitted on the wire. chunks = [e for e in bus.events if e.type == "TOOL_CALL_CHUNK"] - assert chunks, "expected progressive render_a2ui chunks" + # More than one chunk, else "progressive" is unproven and the + # name-only-on-the-opener assertion below has nothing to check. + assert len(chunks) > 1, chunks assert chunks[0].tool_call_name == "render_a2ui" # Name rides only the opening chunk (OpenAI streaming convention). assert all(c.tool_call_name is None for c in chunks[1:]) @@ -541,3 +543,1000 @@ async def test_run_no_tool_call_exhausts(monkeypatch): envelope = await tool.run({"intent": "create"}) assert json.loads(envelope)["code"] == "a2ui_recovery_exhausted" assert calls["n"] == 2 + + +# --------------------------------------------------------------------------- +# Action -> agent-response loop +# +# The A2UI middleware feeds a surface action back to the server as a synthetic +# ``log_a2ui_event`` assistant call plus its tool result, then the run has to +# answer it. A flow that streams the model ONCE per run and stops on its own +# tool call never produces that answer: the user sees the tool card flip and the +# previous generic summary, and nothing about the choice they made. These drive +# the real demo flows through BOTH transports. +# --------------------------------------------------------------------------- + +from litellm import CustomStreamWrapper # noqa: E402 + +from ag_ui.encoder import EventEncoder # noqa: E402 +from ag_ui_crewai.examples import a2ui_fixed_schema as fixed_demo # noqa: E402 +from ag_ui_crewai.examples import _a2ui_subagent as subagent_demo # noqa: E402 +from ag_ui_crewai.examples import _model_turn as mt # noqa: E402 + +HOTELS = [ + { + "id": "1", + "name": "The Ritz Paris", + "location": "Paris", + "rating": 4.9, + "price": 1200, + } +] +HOTELS_ARGS = json.dumps({"hotels": HOTELS}) + +# What ``search_hotels`` actually hands back: the a2ui_operations envelope the +# middleware paints from. A synthetic ``"{}"`` would let a flow that never reads +# the render result pass. +HOTEL_RENDER_RESULT = fixed_demo._envelope( + fixed_demo.HOTEL_SURFACE_ID, fixed_demo.HOTEL_SCHEMA, {"hotels": HOTELS} +) + +# The Book button as the hotel schema declares it: action name ``book_hotel`` +# with a ``hotelName`` / ``price`` context (see +# ``a2ui_fixed_schema_schemas/hotel_schema.json``). +BOOK_ACTION = { + "name": "book_hotel", + "surfaceId": fixed_demo.HOTEL_SURFACE_ID, + "sourceComponentId": "hotel-card", + "context": {"hotelName": "The Ritz Paris", "price": 1200}, +} +# The middleware's own rendering of that action (``formatUserActionResult``). +BOOK_ACTION_RESULT = ( + 'User performed action "book_hotel" on surface "hotel-search-results" ' + '(component: hotel-card). Context: ' + '{"hotelName":"The Ritz Paris","price":1200}' +) + + +def _book_click_messages(*, render_tool, render_args, render_result): + """The message list the A2UI middleware sends on a Book click. + + ``m1``-``m3`` are the turn that rendered the surface: the model's rendering + tool call and the a2ui_operations envelope it returned. ``m4``/``m5`` are the + synthetic pair the middleware appends, matching its own shapes: the + ``log_a2ui_event`` arguments are the whole userAction object and the tool + result is that action's formatted report. + """ + return [ + {"id": "m1", "role": "user", "content": "compare 3 luxury hotels in Paris"}, + { + "id": "m2", + "role": "assistant", + "content": "Here are your results.", + "tool_calls": [ + { + "id": "call_render1", + "type": "function", + "function": {"name": render_tool, "arguments": render_args}, + } + ], + }, + { + "id": "m3", + "role": "tool", + "tool_call_id": "call_render1", + "content": render_result, + }, + { + "id": "m4", + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_log", + "type": "function", + "function": { + "name": "log_a2ui_event", + "arguments": json.dumps(BOOK_ACTION), + }, + } + ], + }, + { + "id": "m5", + "role": "tool", + "tool_call_id": "call_log", + "content": BOOK_ACTION_RESULT, + }, + ] + + +# Fixed schema: the surface was rendered by the backend ``search_hotels`` tool. +BOOK_CLICK_MESSAGES = _book_click_messages( + render_tool="search_hotels", + render_args=HOTELS_ARGS, + render_result=HOTEL_RENDER_RESULT, +) + +# Dynamic schema: the surface was rendered by the auto-injected sub-agent tool, +# whose result is the envelope ``A2UITool.run`` returns. +DYNAMIC_BOOK_CLICK_MESSAGES = _book_click_messages( + render_tool="generate_a2ui", + render_args=json.dumps({"intent": "create", "changes": "3 luxury hotels in Paris"}), + render_result=json.dumps({A2UI_OPERATIONS_KEY: []}), +) + + +class _LoopFakeStream(CustomStreamWrapper): + def __init__(self, gen): # pylint: disable=super-init-not-called + self._gen = gen + + def __aiter__(self): + return self._gen + + +def _loop_chunk(delta, finish=None, chunk_id="chatcmpl-1"): + return { + "id": chunk_id, + "created": 1700000000, + "model": "gpt-5.4", + "system_fingerprint": "fp", + "choices": [{"delta": delta, "finish_reason": finish}], + } + + +def _tool_call_turn(call_id, name, arguments, text, chunk_id): + """A model turn that says something and then calls a tool.""" + return [ + _loop_chunk({"content": text, "tool_calls": None}, chunk_id=chunk_id), + _loop_chunk( + { + "content": None, + "tool_calls": [ + _FakeStreamToolCall(call_id, name, arguments), + ], + }, + chunk_id=chunk_id, + ), + _loop_chunk({"content": None, "tool_calls": None}, finish="tool_calls", + chunk_id=chunk_id), + ] + + +def _text_turn(text, chunk_id): + """A model turn that only answers in text.""" + return [ + _loop_chunk({"content": text, "tool_calls": None}, chunk_id=chunk_id), + _loop_chunk({"content": None, "tool_calls": None}, finish="stop", + chunk_id=chunk_id), + ] + + +class _FakeStreamToolCall: + """A litellm streaming tool-call delta (``index`` / ``id`` / ``function``).""" + + def __init__(self, call_id, name, arguments): + self.index = 0 + self.id = call_id + self.function = {"name": name, "arguments": arguments} + + +class _TurnScript: + """Serves one scripted model turn per ``acompletion`` call, in order.""" + + def __init__(self, turns): + self._turns = list(turns) + self.calls = [] + + async def __call__(self, **kwargs): # noqa: ANN001 - acompletion stand-in + self.calls.append(kwargs) + assert self._turns, "the flow called the model more times than scripted" + chunks = self._turns.pop(0) + + async def gen(): + for chunk in chunks: + yield chunk + + return _LoopFakeStream(gen()) + + +def _decode(encoded): + payloads = [] + for chunk in encoded: + for line in chunk.splitlines(): + if line.startswith("data:"): + payloads.append(json.loads(line[len("data:"):].strip())) + return payloads + + +def _assistant_text(payloads): + return "".join( + p.get("delta") or "" + for p in payloads + if p["type"] in ("TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK") + ) + + +async def _drive_flow(driver_name, flow, messages, *, tools=None, forwarded_props=None): + data = RunAgentInput( + thread_id="t-1", run_id="r-1", state={}, + messages=messages, tools=tools or [], context=[], + forwarded_props=forwarded_props or {}, + ) + inputs = ep.crewai_prepare_inputs( + state=data.state, messages=data.messages, tools=data.tools, + context=data.context, forwarded_props=data.forwarded_props, + ) + ep.FastAPICrewFlowEventListener() + encoded = [ + chunk + async for chunk in getattr(ep, driver_name)( + flow_copy=flow, encoder=EventEncoder(), input_data=data, + inputs=inputs, timeout=30.0, + ) + ] + return _decode(encoded) + + +BOTH_TRANSPORTS = pytest.mark.parametrize( + "driver", ["_run_flow_frame_stream", "_run_flow_event_stream"] +) + +# A frontend tool: the client runs it and sends the result back on the next run. +CHANGE_BACKGROUND_TOOL = { + "name": "change_background", + "description": "Change the page background colour.", + "parameters": { + "type": "object", + "properties": {"background": {"type": "string"}}, + "required": ["background"], + }, +} + + +def _snapshot_messages(payloads): + """The terminal MESSAGES_SNAPSHOT: the conversation the flow persisted, which + the client stores and replays on the next run of this thread.""" + snapshots = [p for p in payloads if p["type"] == "MESSAGES_SNAPSHOT"] + assert snapshots, "the run emitted no MESSAGES_SNAPSHOT" + return snapshots[-1]["messages"] + + +def _unanswered_tool_call_names(messages): + """Names of the tool calls with no matching tool result in ``messages``.""" + answered = {m.get("toolCallId") for m in messages if m.get("role") == "tool"} + return [ + call["function"]["name"] + for message in messages + for call in (message.get("toolCalls") or []) + if call["id"] not in answered + ] + + +def test_book_click_fixture_matches_production_shapes(): + """The synthetic click history must stay pinned to what production emits. + + The action name and surface id come from the hotel schema and the demo, and + the rendering tool's result is a real a2ui_operations envelope. Renaming the + schema's action or the surface without updating the fixture fails here, so the + action tests cannot keep passing against a history no client would send. + """ + hotel_card = next( + c for c in fixed_demo.HOTEL_SCHEMA if c["component"] == "HotelCard" + ) + assert BOOK_ACTION["name"] == hotel_card["action"]["event"]["name"] + assert set(BOOK_ACTION["context"]) == set( + hotel_card["action"]["event"]["context"] + ) + assert BOOK_ACTION["surfaceId"] == fixed_demo.HOTEL_SURFACE_ID + # The middleware's report is what the model reads; it must name the action. + assert BOOK_ACTION["name"] in BOOK_ACTION_RESULT + assert BOOK_ACTION["surfaceId"] in BOOK_ACTION_RESULT + # The render turn's tool result is the envelope the middleware paints from. + assert A2UI_OPERATIONS_KEY in json.loads(BOOK_CLICK_MESSAGES[2]["content"]) + assert A2UI_OPERATIONS_KEY in json.loads( + DYNAMIC_BOOK_CLICK_MESSAGES[2]["content"] + ) + + +@BOTH_TRANSPORTS +async def test_fixed_schema_action_click_gets_a_choice_specific_reply( + monkeypatch, driver +): + """Clicking Book elicits a reply naming the hotel. The model answers the + action only on a follow-up turn fed its own tool result; a single-shot flow + ends on the search call and the choice is never acknowledged.""" + script = _TurnScript([ + _tool_call_turn("call_search2", "search_hotels", HOTELS_ARGS, + "Here are your results.", "chatcmpl-2"), + _text_turn("You've booked The Ritz Paris. Confirmation is on its way.", + "chatcmpl-3"), + ]) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), BOOK_CLICK_MESSAGES + ) + + assert len(script.calls) == 2, "the tool result must drive a follow-up turn" + text = _assistant_text(payloads) + assert "The Ritz Paris" in text, text + assert "RUN_ERROR" not in [p["type"] for p in payloads] + + # The follow-up turn sees the tool result it is answering. + replayed = script.calls[1]["messages"] + assert replayed[-1]["role"] == "tool" + assert replayed[-1]["tool_call_id"] == "call_search2" + + +@BOTH_TRANSPORTS +async def test_fixed_schema_stops_on_a_frontend_tool_call(monkeypatch, driver): + """A frontend tool the flow does not execute ends the run so the client can + run it, and the call is persisted INTACT (the client answers it on the next + run). Looping here would feed the model a history with an unanswered call.""" + script = _TurnScript([ + _tool_call_turn("call_front", "change_background", '{"background":"red"}', + "Sure.", "chatcmpl-2"), + ]) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), + [{"id": "m1", "role": "user", "content": "make it red"}], + tools=[CHANGE_BACKGROUND_TOOL], + ) + + assert len(script.calls) == 1 + types = [p["type"] for p in payloads] + assert "TOOL_CALL_START" in types + assert "RUN_ERROR" not in types + assert _unanswered_tool_call_names(_snapshot_messages(payloads)) == ["change_background"] + + +@BOTH_TRANSPORTS +async def test_fixed_schema_loop_is_bounded(monkeypatch, driver): + """A model that keeps calling the tool cannot spin the run: the loop stops at + the turn cap and the run still finishes cleanly.""" + turns = [ + _tool_call_turn(f"call_{i}", "search_hotels", HOTELS_ARGS, "Results.", + f"chatcmpl-{i}") + for i in range(fixed_demo.MAX_MODEL_TURNS + 3) + ] + script = _TurnScript(turns) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), + [{"id": "m1", "role": "user", "content": "hotels please"}], + ) + + assert len(script.calls) == fixed_demo.MAX_MODEL_TURNS + types = [p["type"] for p in payloads] + assert "RUN_FINISHED" in types + assert "RUN_ERROR" not in types + + +@BOTH_TRANSPORTS +async def test_dynamic_schema_action_click_gets_a_choice_specific_reply( + monkeypatch, driver +): + """The auto-injected subagent demo answers a surface action too, with A2UI + injection running for REAL: the ``injectA2UITool`` runtime flag is what puts + ``generate_a2ui`` on the model's tool list, the real ``A2UITool`` generates + the surface (its own sub-agent completion stubbed), and its envelope drives a + follow-up turn that names the choice. + + Only the two model calls are stubbed: the outer flow's ``acompletion`` and the + sub-agent's. ``plan_a2ui_injection`` / ``apply_a2ui_plan_to_tools`` are NOT, + so a regression that stops injecting the tool fails here.""" + script = _TurnScript([ + _tool_call_turn( + "call_gen", "generate_a2ui", + json.dumps({"intent": "create", "changes": "3 luxury hotels in Paris"}), + "Rendered a comparison of 3 luxury hotels.", "chatcmpl-2", + ), + _text_turn("You've booked The Ritz Paris. Enjoy your stay.", "chatcmpl-3"), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + # The sub-agent's own render_a2ui completion (A2UITool.run drives it). + inner, inner_calls = _make_fake_acompletion([VALID_ARGS]) + monkeypatch.setattr(a2, "acompletion", inner) + + payloads = await _drive_flow( + driver, + subagent_demo_flow(), + DYNAMIC_BOOK_CLICK_MESSAGES, + # The middleware sends its render proxy alongside the flag; the plan has to + # SWAP it, so it must actually be on the input for that to mean anything. + tools=[ + Tool( + name="render_a2ui", + description="middleware-injected render proxy", + parameters={"type": "object", "properties": {}}, + ) + ], + forwarded_props={"injectA2UITool": True}, + ) + + # Injection: the flag alone put generate_a2ui on the tool list, and the + # middleware's render proxy was swapped out rather than offered alongside. + offered = [ + t["function"]["name"] for t in (script.calls[0].get("tools") or []) + ] + assert "generate_a2ui" in offered, offered + assert "render_a2ui" not in offered, offered + + # The real A2UITool ran and its envelope reached the wire. + assert inner_calls["n"] == 1 + results = [p for p in payloads if p["type"] == "TOOL_CALL_RESULT"] + assert results, [p["type"] for p in payloads] + assert A2UI_OPERATIONS_KEY in json.loads(results[0]["content"]) + + assert len(script.calls) == 2, "the generate_a2ui result must drive a follow-up" + replayed = script.calls[1]["messages"] + assert replayed[-1]["role"] == "tool" + assert replayed[-1]["tool_call_id"] == "call_gen" + assert "The Ritz Paris" in _assistant_text(payloads) + assert "RUN_ERROR" not in [p["type"] for p in payloads] + + +@BOTH_TRANSPORTS +async def test_dynamic_schema_replans_against_the_current_conversation( + monkeypatch, driver +): + """Every model turn must plan against the CURRENT conversation. + + The plan snapshots the messages it hands the render sub-agent, so a plan + built once before the loop shows turn 2's sub-agent the turn-1 history: no + assistant message, no tool result, no action report. An in-run + ``intent="update"`` then finds no prior surface and paints a hard failure, + and a second create is designed blind to the first surface. + """ + script = _TurnScript([ + _tool_call_turn("call_gen1", "generate_a2ui", '{"intent":"create"}', + "Rendered the hotels.", "chatcmpl-2"), + _tool_call_turn("call_gen2", "generate_a2ui", '{"intent":"update"}', + "Updating it.", "chatcmpl-3"), + _text_turn("All set.", "chatcmpl-4"), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + + glue_per_call = [] + + async def _record_run(self, args, *, tool_call_id=None, flow=None, **_kw): # noqa: ANN001 + glue_per_call.append(list(self._glue.get("messages") or [])) + return json.dumps({A2UI_OPERATIONS_KEY: []}) + + monkeypatch.setattr(a2.A2UITool, "run", _record_run) + + payloads = await _drive_flow( + driver, subagent_demo_flow(), + [{"id": "m1", "role": "user", "content": "compare 3 luxury hotels in Paris"}], + forwarded_props={"injectA2UITool": True}, + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert len(glue_per_call) == 2, "both generate_a2ui calls must run" + first, second = glue_per_call + assert [m["role"] for m in first] == ["user"] + assert [m["role"] for m in second] == ["user", "assistant", "tool"] + assert second[-1]["tool_call_id"] == "call_gen1" + + +@BOTH_TRANSPORTS +async def test_fixed_schema_drops_a_tool_call_nobody_will_answer(monkeypatch, driver): + """A tool name neither this flow nor the frontend knows (a hallucination) is + answered by no one. Persisting the call would leave an assistant + ``tool_calls`` entry with no matching result, which the chat-completions API + rejects on every later run of the thread. + + Dropping the call must not cost the user a reply: the turn continues so the + model can answer in text, and the dropped call is absent from the history it + is re-prompted with. + """ + script = _TurnScript([ + _tool_call_turn("call_ghost", "search_restaurants", '{"city":"Paris"}', + "Looking that up.", "chatcmpl-2"), + _text_turn("I can search flights and hotels, not restaurants.", + "chatcmpl-3"), + ]) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), + [{"id": "m1", "role": "user", "content": "where should I eat?"}], + tools=[CHANGE_BACKGROUND_TOOL], + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + messages = _snapshot_messages(payloads) + assert _unanswered_tool_call_names(messages) == [] + # What the model did say is still persisted. + assert any(m.get("content") == "Looking that up." for m in messages) + # The run does not end silently on a call nobody can answer. + assert len(script.calls) == 2 + assert "search_restaurants" not in json.dumps(script.calls[1]["messages"]) + assert "not restaurants" in _assistant_text(payloads) + + +@BOTH_TRANSPORTS +async def test_dynamic_schema_drops_a_tool_call_nobody_will_answer( + monkeypatch, driver +): + """Same for the subagent demo: only ``generate_a2ui`` and the frontend tools + can be answered, so an unknown name must not be persisted unanswered - and + dropping it still leaves the model a turn to reply in text.""" + script = _TurnScript([ + _tool_call_turn("call_ghost", "search_restaurants", '{"city":"Paris"}', + "Looking that up.", "chatcmpl-2"), + _text_turn("I can render surfaces, not look up restaurants.", + "chatcmpl-3"), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + + class _NeverRunTool: + schema = {"type": "function", "function": {"name": "generate_a2ui"}} + + async def run(self, args, tool_call_id=None, **_kw): # noqa: ANN001 - test double + raise AssertionError("the flow must not run an unknown tool call") + + monkeypatch.setattr( + subagent_demo, + "plan_a2ui_injection", + lambda **kwargs: {"tool_name": "generate_a2ui", "tool": _NeverRunTool()}, + ) + + payloads = await _drive_flow( + driver, subagent_demo_flow(), + [{"id": "m1", "role": "user", "content": "where should I eat?"}], + tools=[CHANGE_BACKGROUND_TOOL], + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert _unanswered_tool_call_names(_snapshot_messages(payloads)) == [] + assert len(script.calls) == 2 + assert "search_restaurants" not in json.dumps(script.calls[1]["messages"]) + assert "not look up restaurants" in _assistant_text(payloads) + + +@BOTH_TRANSPORTS +async def test_dynamic_schema_stops_on_a_frontend_tool_call(monkeypatch, driver): + """A genuine frontend call still ends the run with the call intact, so the + client can run it and send the result back on the next one.""" + script = _TurnScript([ + _tool_call_turn("call_front", "change_background", '{"background":"red"}', + "Sure.", "chatcmpl-2"), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, subagent_demo_flow(), + [{"id": "m1", "role": "user", "content": "make it red"}], + tools=[CHANGE_BACKGROUND_TOOL], + ) + + assert len(script.calls) == 1 + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert _unanswered_tool_call_names(_snapshot_messages(payloads)) == [ + "change_background" + ] + + +def subagent_demo_flow(): + from ag_ui_crewai.examples.a2ui_dynamic_schema import A2UIDynamicSchemaFlow + + return A2UIDynamicSchemaFlow() + + +# --------------------------------------------------------------------------- +# Shared model-turn bookkeeping (``_model_turn``) +# --------------------------------------------------------------------------- + + +class _FakeStreamedResponse: + def __init__(self, response_id="chatcmpl-x"): + self.id = response_id + + +class _FakeStreamedMessage: + """Stands in for the ``ModelResponse`` message ``copilotkit_stream`` returns.""" + + def __init__(self, content="", tool_calls=None): + self._dump = { + "role": "assistant", + "content": content, + "tool_calls": tool_calls, + "function_call": None, + } + + def model_dump(self): + return dict(self._dump) + + +def _fake_call_dump(name): + return {"id": "c1", "type": "function", + "function": {"name": name, "arguments": "{}"}} + + +def test_append_assistant_message_skips_an_empty_turn(): + """A turn that produced neither text nor a tool call carries nothing. It must + not be persisted: it would be replayed as an empty assistant message on every + later run of the thread.""" + state = {"messages": []} + assert mt.append_assistant_message( + state, _FakeStreamedResponse(), _FakeStreamedMessage() + ) is None + assert state["messages"] == [] + + +def test_append_assistant_message_persists_text_and_tool_calls(): + """The empty-turn guard must not swallow a turn with real payload: text + alone, a tool call alone, and the streamed id all survive.""" + state = {"messages": []} + text_only = mt.append_assistant_message( + state, _FakeStreamedResponse("chatcmpl-1"), _FakeStreamedMessage("hi") + ) + assert text_only["content"] == "hi" + assert text_only["id"] == "chatcmpl-1" + + call_only = mt.append_assistant_message( + state, + _FakeStreamedResponse("chatcmpl-2"), + _FakeStreamedMessage("", [_fake_call_dump("search_hotels")]), + ) + assert [c["function"]["name"] for c in call_only["tool_calls"]] == [ + "search_hotels" + ] + assert len(state["messages"]) == 2 + + +def test_append_assistant_message_drops_orphans_and_skips_a_call_only_turn(): + """Dropping every tool call from a turn with no text leaves nothing to + persist, while a turn that also said something keeps the text.""" + state = {"messages": []} + assert mt.append_assistant_message( + state, + _FakeStreamedResponse(), + _FakeStreamedMessage("", [_fake_call_dump("ghost")]), + drop_indexes={0}, + ) is None + assert state["messages"] == [] + + kept = mt.append_assistant_message( + state, + _FakeStreamedResponse(), + _FakeStreamedMessage("Looking that up.", [_fake_call_dump("ghost")]), + drop_indexes={0}, + ) + assert kept["content"] == "Looking that up." + assert kept["tool_calls"] is None + + +def test_resolve_client_tools_excludes_the_swapped_out_render_proxy(): + """A tool the flow swapped out is neither offered to the model nor treated as + the client's to answer.""" + actions = [_fn_tool("render_a2ui"), _fn_tool("change_background")] + offered, client_names = mt.resolve_client_tools( + actions, backend_names={"generate_a2ui"}, drop_names=["render_a2ui"] + ) + assert [t["function"]["name"] for t in offered] == ["change_background"] + assert client_names == {"change_background"} + + +def test_resolve_client_tools_logs_a_backend_name_collision(caplog): + """A frontend action sharing a backend tool's name loses to the backend, and + the collision is logged rather than resolved silently.""" + actions = [_fn_tool("search_hotels"), _fn_tool("change_background")] + with caplog.at_level("WARNING", logger="ag_ui_crewai"): + offered, client_names = mt.resolve_client_tools( + actions, backend_names={"search_hotels", "search_flights"} + ) + assert [t["function"]["name"] for t in offered] == ["change_background"] + assert client_names == {"change_background"} + assert any( + "search_hotels" in r.getMessage() for r in caplog.records + ), [r.getMessage() for r in caplog.records] + + +@BOTH_TRANSPORTS +async def test_dynamic_schema_keeps_a_render_call_inside_the_recovery_loop( + monkeypatch, driver, caplog +): + """A call to the SWAPPED-OUT render proxy must not be handed to the client. + + Auto-injection replaces the middleware's ``render_a2ui`` proxy with + ``generate_a2ui``, whose sub-agent validates and retries the surface. Treating + a render call as a frontend call would end the run with that call intact, so + the client paints it directly and the whole validate/retry path this demo + exists to show is skipped. + """ + script = _TurnScript([ + _tool_call_turn( + "call_render", "render_a2ui", + json.dumps({"surfaceId": "s", "components": []}), + "Rendering that.", "chatcmpl-2", + ), + _text_turn("Here is your comparison.", "chatcmpl-3"), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + + with caplog.at_level("WARNING", logger="ag_ui_crewai"): + payloads = await _drive_flow( + driver, subagent_demo_flow(), + [{"id": "m1", "role": "user", "content": "compare 3 hotels"}], + tools=[ + Tool( + name="render_a2ui", + description="middleware-injected render proxy", + parameters={"type": "object", "properties": {}}, + ) + ], + forwarded_props={"injectA2UITool": True}, + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + offered = [t["function"]["name"] for t in (script.calls[0].get("tools") or [])] + assert offered == ["generate_a2ui"], offered + # The render call is NOT left for the client to answer. + assert _unanswered_tool_call_names(_snapshot_messages(payloads)) == [] + assert any("render_a2ui" in r.getMessage() for r in caplog.records), [ + r.getMessage() for r in caplog.records + ] + + +@BOTH_TRANSPORTS +async def test_dynamic_schema_still_answers_the_render_proxy_when_a2ui_is_off( + monkeypatch, driver +): + """With no injection there is no plan and nothing was swapped out, so the + middleware's render proxy IS a plain frontend tool: the run ends with the call + intact for the client to answer.""" + script = _TurnScript([ + _tool_call_turn( + "call_render", "render_a2ui", "{}", "Rendering that.", "chatcmpl-2" + ), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, subagent_demo_flow(), + [{"id": "m1", "role": "user", "content": "compare 3 hotels"}], + tools=[ + Tool( + name="render_a2ui", + description="middleware-injected render proxy", + parameters={"type": "object", "properties": {}}, + ) + ], + ) + + assert len(script.calls) == 1 + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert _unanswered_tool_call_names(_snapshot_messages(payloads)) == [ + "render_a2ui" + ] + + +@BOTH_TRANSPORTS +async def test_fixed_schema_does_not_persist_an_empty_model_turn( + monkeypatch, driver +): + """A model turn that streamed nothing at all must not land in the history as + an empty assistant message.""" + script = _TurnScript([ + [_loop_chunk({"content": None, "tool_calls": None}, finish="stop", + chunk_id="chatcmpl-2")], + ]) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), + [{"id": "m1", "role": "user", "content": "hi"}], + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert [m["role"] for m in _snapshot_messages(payloads)] == ["user"] + + +@BOTH_TRANSPORTS +async def test_fixed_schema_backend_tool_wins_a_frontend_name_collision( + monkeypatch, driver, caplog +): + """A frontend action that shares a backend tool's name is a wiring bug: the + model would be offered two definitions of one name and only the backend half + can run. The backend wins and the collision is logged, not swallowed.""" + script = _TurnScript([ + _tool_call_turn("call_search3", "search_hotels", HOTELS_ARGS, + "Here are your results.", "chatcmpl-2"), + _text_turn("Anything else?", "chatcmpl-3"), + ]) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + with caplog.at_level("WARNING", logger="ag_ui_crewai"): + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), + [{"id": "m1", "role": "user", "content": "hotels in Paris"}], + tools=[ + { + "name": "search_hotels", + "description": "a frontend action shadowing the backend tool", + "parameters": {"type": "object", "properties": {}}, + } + ], + ) + + offered = [t["function"]["name"] for t in (script.calls[0].get("tools") or [])] + assert offered.count("search_hotels") == 1, offered + assert any("search_hotels" in r.getMessage() for r in caplog.records), [ + r.getMessage() for r in caplog.records + ] + # Backend precedence: this flow ran the search and the run continued. + assert len(script.calls) == 2 + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert _unanswered_tool_call_names(_snapshot_messages(payloads)) == [] + + +def test_system_prompts_do_not_name_the_synthetic_action_tool(): + """The middleware SYNTHESISES the surface-action call and its result into the + history; it never offers that tool. Naming it in a system prompt invites the + model to call a tool it does not have, and that call can only be dropped.""" + for prompt in (fixed_demo.SYSTEM_PROMPT, subagent_demo.SYSTEM_PROMPT): + assert "log_a2ui_event" not in prompt, prompt + # The surface-interaction guidance itself must survive the rewording: the + # model still has to recognise the report and answer it in text. + assert "interacted with" in prompt, prompt + assert "Reply in text" in prompt, prompt + + +def test_fixed_schema_envelope_coerces_a_null_list_argument(): + """An explicit JSON ``null`` for the results argument must paint an EMPTY + surface, not ``updateDataModel {"hotels": null}``.""" + envelope = json.loads(fixed_demo._TOOL_ENVELOPE["search_hotels"]({"hotels": None})) + data_ops = [ + op["updateDataModel"] + for op in envelope[A2UI_OPERATIONS_KEY] + if "updateDataModel" in op + ] + assert data_ops and data_ops[0]["value"] == {"hotels": []}, data_ops + flights = json.loads( + fixed_demo._TOOL_ENVELOPE["search_flights"]({"flights": None}) + ) + flight_ops = [ + op["updateDataModel"] + for op in flights[A2UI_OPERATIONS_KEY] + if "updateDataModel" in op + ] + assert flight_ops[0]["value"] == {"flights": []} + + +GUIDE_CARD_COMPONENTS = ("HotelCard", "ProductCard", "TeamMemberCard") + + +def _guide_json_objects(guide: str) -> list[dict]: + """Every top-level JSON object the guide spells out. + + Brace counting is enough: the guide's snippets carry no braces inside string + literals, and a snippet that failed to parse would be one the sub-agent could + not copy either, so it is skipped rather than tolerated. + """ + objects: list[dict] = [] + depth = 0 + start = None + for index, char in enumerate(guide): + if char == "{": + if depth == 0: + start = index + depth += 1 + elif char == "}" and depth: + depth -= 1 + if depth == 0 and start is not None: + try: + parsed = json.loads(guide[start : index + 1]) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + objects.append(parsed) + return objects + +def test_composition_guide_teaches_the_action_event_shape(): + """Every card the guide teaches must carry an ``action`` in the shape the a2ui + middleware documents: ``{"event": {"name": ..., "context": {...}}}``. + + A model shown only ``Props: ... action`` can emit a bare string or drop the + prop, and the rendered card's button then fires nothing, so the action-click + reply the demo exists to show can never happen. + """ + examples = { + obj["component"]: obj + for obj in _guide_json_objects(subagent_demo.COMPOSITION_GUIDE) + if isinstance(obj.get("component"), str) + } + + for component in GUIDE_CARD_COMPONENTS: + card = examples.get(component) + assert card, f"the guide shows no {component} example to copy" + action = card.get("action") + assert isinstance(action, dict), ( + f"{component}'s example action must be an object, not {action!r}" + ) + event = action.get("event") + assert isinstance(event, dict), ( + f"{component}'s action must nest an event object, got {action!r}" + ) + assert isinstance(event.get("name"), str) and event["name"], ( + f"{component}'s action event must name the action, got {event!r}" + ) + # The context is what lets the reply name the chosen item: the click is + # forwarded as the action name plus this context and nothing else. + context = event.get("context") + assert isinstance(context, dict) and context, ( + f"{component}'s action event must carry a context, got {event!r}" + ) + for field, binding in context.items(): + assert isinstance(binding, dict) and isinstance(binding.get("path"), str), ( + f"{component}'s action context {field!r} must bind a data path, " + f"got {binding!r}" + ) + # Inside a repeated card template the path is relative, so an + # absolute one silently resolves against the whole data model. + assert not binding["path"].startswith("/"), ( + f"{component}'s action context {field!r} must use a relative path" + ) + +def _streamed_tool_result(payloads): + """The single streamed TOOL_CALL_RESULT of a run.""" + results = [p for p in payloads if p["type"] == "TOOL_CALL_RESULT"] + assert len(results) == 1, [p["type"] for p in payloads] + return results[0] + +def _snapshot_tool_message_ids(payloads): + return [ + message["id"] + for message in _snapshot_messages(payloads) + if message.get("role") == "tool" + ] + +@BOTH_TRANSPORTS +async def test_fixed_schema_tool_result_keeps_one_message_id(monkeypatch, driver): + """The streamed search result and the snapshot's copy of it are ONE message.""" + script = _TurnScript([ + _tool_call_turn("call_search", "search_hotels", HOTELS_ARGS, + "Here are your results.", "chatcmpl-2"), + _text_turn("Anything else?", "chatcmpl-3"), + ]) + monkeypatch.setattr(fixed_demo, "acompletion", script) + + payloads = await _drive_flow( + driver, fixed_demo.A2UIFixedSchemaFlow(), + [{"id": "m1", "role": "user", "content": "hotels in Paris please"}], + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + streamed = _streamed_tool_result(payloads) + assert _snapshot_tool_message_ids(payloads) == [streamed["messageId"]] + +@BOTH_TRANSPORTS +async def test_dynamic_schema_tool_result_keeps_one_message_id(monkeypatch, driver): + """Same for the sub-agent demo, whose TOOL_CALL_RESULT ``A2UITool.run`` emits: + the id it streams has to be the id the flow persists.""" + script = _TurnScript([ + _tool_call_turn("call_gen", "generate_a2ui", '{"intent":"create"}', + "Rendered the comparison.", "chatcmpl-2"), + _text_turn("Anything else?", "chatcmpl-3"), + ]) + monkeypatch.setattr(subagent_demo, "acompletion", script) + inner, inner_calls = _make_fake_acompletion([VALID_ARGS]) + monkeypatch.setattr(a2, "acompletion", inner) + + payloads = await _drive_flow( + driver, subagent_demo_flow(), + [{"id": "m1", "role": "user", "content": "compare 3 luxury hotels in Paris"}], + forwarded_props={"injectA2UITool": True}, + ) + + assert "RUN_ERROR" not in [p["type"] for p in payloads] + assert inner_calls["n"] == 1 + streamed = _streamed_tool_result(payloads) + assert _snapshot_tool_message_ids(payloads) == [streamed["messageId"]] + + diff --git a/integrations/crew-ai/python/tests/test_capabilities.py b/integrations/crew-ai/python/tests/test_capabilities.py index b867350d5c..accf18f636 100644 --- a/integrations/crew-ai/python/tests/test_capabilities.py +++ b/integrations/crew-ai/python/tests/test_capabilities.py @@ -1,6 +1,6 @@ """Capability-detection + import-resilience suite. -Covers two graceful-degradation invariants of the crewai capability layer: +Covers the graceful-degradation invariants of the crewai capability layer: * ``_first_module`` treats "module not found" as a soft miss (fall through to the next candidate) but PROPAGATES a genuinely broken import inside an @@ -10,6 +10,10 @@ ``ag_ui_crewai.endpoint`` degrades to a plain ``object`` base rather than crashing at class-definition time with an opaque ``TypeError: NoneType takes no arguments`` (fewer capabilities, not a crash). +* A litellm that raises a non-ImportError, from its own top level or from the + ``responses.streaming_iterator`` submodule the Responses probe imports, only + costs the Responses isinstance shortcut. It never fails the import, since the + litellm probe already decided to continue degraded. """ import importlib @@ -22,6 +26,11 @@ from ag_ui_crewai import _capabilities as cap +def test_conversational_stream_probe_is_publicly_available(): + """Conversational mode is selected by capability, never by version.""" + assert callable(getattr(cap, "flow_supports_conversational_stream", None)) + + def _run_isolated(script: str) -> subprocess.CompletedProcess: """Run ``script`` in a fresh interpreter (this venv's python). @@ -121,3 +130,161 @@ def test_endpoint_module_degrades_when_base_event_listener_missing(): ) assert result.returncode == 0, result.stderr assert "OK" in result.stdout + + +# -------------------------------------------------------------------------- +# A broken litellm degrades the Responses channel; it never fails the import +# -------------------------------------------------------------------------- +# The litellm probe tolerates ANY exception from litellm's top level (bare +# ``except Exception``) and continues with ``_litellm_available = False``. The +# Responses-iterator resolution that follows must honour that decision: it +# imports a litellm SUBMODULE, which re-executes the same failing top level, so +# an unguarded probe re-raises a non-ImportError and converts the tolerated +# degraded mode into a hard import failure. + +#: Preamble installing a meta-path finder whose matched modules raise a +#: NON-ImportError from their body, which is how a genuinely broken install +#: (bad C extension, incompatible transitive dep, failing side effect) presents. +#: A ``ModuleNotFoundError`` would be the uninteresting case: ``_first_module`` +#: already treats that as a soft miss. +#: +#: Indented to match the inline scripts below so ``_run_isolated``'s +#: ``textwrap.dedent`` sees ONE common prefix over the concatenation. At column 0 +#: it would instead pin the common prefix to zero, leaving every appended line +#: indented into ``find_spec``'s body: dead code after its ``return``, so the +#: subprocess would exit 0 having run none of the assertions. +_BROKEN_MODULE_FINDER = """ + import importlib.abc + import importlib.machinery + import sys + import types + + + class _BrokenLoader(importlib.abc.Loader): + def create_module(self, spec): + return types.ModuleType(spec.name) + + def exec_module(self, module): + raise RuntimeError("simulated broken module body") + + + class _BrokenFinder(importlib.abc.MetaPathFinder): + def __init__(self, *names): + self._names = names + + def find_spec(self, fullname, path=None, target=None): + if fullname in self._names: + return importlib.machinery.ModuleSpec(fullname, _BrokenLoader()) + return None +""" + + +def test_capabilities_import_survives_broken_litellm_top_level(): + """litellm's top level raising a non-ImportError must not fail this import. + + Breaking ``litellm`` itself breaks every ``litellm.*`` submodule with it: + importing a submodule imports its parent first, so the Responses probe hits + the same ``RuntimeError``. Yet the litellm probe above it already chose to + continue degraded, so the module body must complete and report the whole + litellm-backed surface as absent. + + Loaded straight from its file rather than as ``ag_ui_crewai._capabilities``, + because that dotted import would first execute the package ``__init__``, + which reaches ``sdk``'s top-level ``from litellm.types.utils import ...``. + litellm is a DECLARED DIRECT dependency there, so a broken litellm failing + that import is by design; this module's degraded mode is not. Loading the + file directly asserts exactly the leaf-module property its own docstring + claims, with no dependency on the package's import order. + """ + result = _run_isolated( + _BROKEN_MODULE_FINDER + + """ + import importlib.util + import pathlib + + # ``find_spec`` on a top-level name locates without executing, so the + # package __init__ (and its litellm imports) never runs. + origin = importlib.util.find_spec("ag_ui_crewai").origin + path = pathlib.Path(origin).with_name("_capabilities.py") + + sys.meta_path.insert(0, _BrokenFinder("litellm")) + + # Load it UNDER the real package name so its relative imports of the + # stdlib-only sibling vocabulary resolve; a bare file-path load would + # fail on those, which says nothing about the litellm degradation. + pkg = importlib.util.module_from_spec(importlib.util.find_spec("ag_ui_crewai")) + pkg.__path__ = [str(path.parent)] + sys.modules.setdefault("ag_ui_crewai", pkg) + + spec = importlib.util.spec_from_file_location( + "ag_ui_crewai._capabilities", path + ) + cap = importlib.util.module_from_spec(spec) + # Register before executing: ``@dataclass`` resolves the deferred + # annotations of ``_Capabilities`` through ``sys.modules[__module__]``. + sys.modules[spec.name] = cap + # The module body is the code under test: it must run to completion. + spec.loader.exec_module(cap) + + assert cap.CAPABILITIES.litellm_available is False + assert "litellm" in cap.CAPABILITIES.missing + # Both litellm-backed Responses symbols degrade to absent, not to a raise. + assert cap.responses_entrypoint() is None + assert cap.CAPABILITIES.responses_api_available is False + assert cap.ResponsesAPIStreamingIteratorBase is None + # crewai resolved normally, so this is a litellm-only degradation. + assert cap.CAPABILITIES.has_event_bus is True + print("OK") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_package_import_survives_broken_litellm_responses_submodule(): + """A broken ``litellm.responses.streaming_iterator`` must not fail the import. + + Same asymmetry seen from the other side: litellm imports fine, so nothing + else in the package is affected, and the ONLY thing that reaches the broken + submodule is this probe. Its result is optional by design + (``_responses.is_responses_stream`` duck-types the iterator when the base + class is ``None``), so a failure there costs an isinstance shortcut, not + ``import ag_ui_crewai``. + + The installed litellm imports that submodule during its own startup, so the + cached entry is dropped first to make the probe actually load it, standing in + for a litellm build that does not preload it. + """ + result = _run_isolated( + _BROKEN_MODULE_FINDER + + """ + import litellm # noqa: F401 + + sys.modules.pop("litellm.responses.streaming_iterator", None) + sys.meta_path.insert(0, _BrokenFinder("litellm.responses.streaming_iterator")) + + import ag_ui_crewai # the whole package, not just the capability leaf + from ag_ui_crewai import _capabilities as cap + from ag_ui_crewai._responses import is_responses_stream + + # litellm itself is fine, so the channel stays advertised. + assert cap.CAPABILITIES.litellm_available is True + assert cap.CAPABILITIES.responses_api_available is True + # Only the isinstance shortcut is lost; duck-typing still recognises an + # iterator, and a non-iterator is still rejected. + assert cap.ResponsesAPIStreamingIteratorBase is None + + class _FakeIterator: + def __aiter__(self): + return self + + def _process_chunk(self, chunk): + return chunk + + assert is_responses_stream(_FakeIterator()) is True + assert is_responses_stream(object()) is False + print("OK") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout diff --git a/integrations/crew-ai/python/tests/test_capability_declaration.py b/integrations/crew-ai/python/tests/test_capability_declaration.py index c2bc6b8c71..ef35c86f67 100644 --- a/integrations/crew-ai/python/tests/test_capability_declaration.py +++ b/integrations/crew-ai/python/tests/test_capability_declaration.py @@ -8,14 +8,36 @@ import pytest from fastapi import FastAPI -from ag_ui_crewai import get_capabilities +from ag_ui_crewai import _capabilities as capability_module from ag_ui_crewai import _capabilities as caps_mod from ag_ui_crewai import _config as config_mod from ag_ui_crewai import endpoint as ep +from ag_ui_crewai import get_capabilities # -- shape of the declaration ---------------------------------------------- + +def test_get_capabilities_declares_conversational_flow_transport(): + conversational = get_capabilities()["conversationalFlows"] + + assert conversational == { + "supported": capability_module._conversational_stream_available, + "entrypoint": "stream_turn", + "sessionId": "threadId", + } + + +def test_conversational_capability_requires_public_stream_turn(monkeypatch): + monkeypatch.setattr( + capability_module, + "_conversational_stream_available", + False, + raising=False, + ) + + assert get_capabilities()["conversationalFlows"]["supported"] is False + @pytest.fixture(autouse=True) def _clean_protocol_env(monkeypatch): """Clear the RAW env var: otherwise an exported AGUI_CREWAI_EMIT_RAW_EVENTS makes @@ -26,11 +48,13 @@ def _clean_protocol_env(monkeypatch): def test_get_capabilities_gates_raw_events_on_the_streamframe_transport(monkeypatch): """RAW passthrough needs the scoped stream sink, so ``supported`` / ``enabled`` - track the StreamFrame transport rather than the flag alone.""" - if caps_mod.LLMThinkingChunkEvent is None: # pragma: no cover - # Skipped up front: a mid-test skip would silently void the rawEvents - # assertions that precede it. - pytest.skip("installed crewai does not expose LLMThinkingChunkEvent") + track the StreamFrame transport rather than the flag alone. + + Nothing here reads crewai's ``LLMThinkingChunkEvent``: the reasoning leg below + rests on the litellm channel, which is a direct dependency. It is pinned live + in the snapshot swap so the assertion states its own premise instead of + inheriting whatever the ambient probes resolved. + """ def _set_stream_frames(available): # ``CAPABILITIES`` is a frozen dataclass, so swap the whole cached probe # result rather than mutating a field. @@ -39,7 +63,9 @@ def _set_stream_frames(available): caps_mod, "CAPABILITIES", dataclasses.replace( - caps_mod.CAPABILITIES, stream_frame_available=available + caps_mod.CAPABILITIES, + stream_frame_available=available, + litellm_available=True, ), ) @@ -123,16 +149,27 @@ def test_reasoning_supported_across_providers(): def test_reasoning_still_supported_via_litellm_when_thinking_event_absent(monkeypatch): """On a crewai without ``LLMThinkingChunkEvent`` reasoning is STILL supported through the litellm channel; only the extra native Gemini source is gone. The - ``reasoning_available`` flag keys off the litellm channel too, so patch both.""" + declaration reads one snapshot, so drop the native channel there. + + The Responses channel is pinned DARK as well. Left live it also satisfies + ``supported`` on its own, so the assertion would pass without the litellm + channel carrying anything and the test would not prove what it is named for.""" monkeypatch.setattr(caps_mod, "_thinking_event_available", False) monkeypatch.setattr( caps_mod, "CAPABILITIES", - dataclasses.replace(caps_mod.CAPABILITIES, reasoning_available=True), + dataclasses.replace( + caps_mod.CAPABILITIES, + native_reasoning_event_available=False, + responses_api_available=False, + litellm_available=True, + ), ) reasoning = caps_mod._reasoning_capability(_FakeNativeGemini()) assert reasoning["supported"] is True assert reasoning["thinkingEventAvailable"] is False + assert reasoning["responsesApiChannel"] is False + assert reasoning["litellmChannel"] is True assert reasoning["reason"] is None diff --git a/integrations/crew-ai/python/tests/test_conversational_dojo.py b/integrations/crew-ai/python/tests/test_conversational_dojo.py new file mode 100644 index 0000000000..dbe4c27539 --- /dev/null +++ b/integrations/crew-ai/python/tests/test_conversational_dojo.py @@ -0,0 +1,171 @@ +"""CrewAI Conversational Flow dojo matrix parity.""" + +import importlib +from collections.abc import Mapping +from types import SimpleNamespace + +import pytest + + +EXPECTED_CONVERSATIONAL_FEATURES = { + "agentic_chat", + "agentic_chat_reasoning", + "agentic_chat_multimodal", + "backend_tool_rendering", + "interrupt", + "human_in_the_loop", + "agentic_generative_ui", + "predictive_state_updates", + "shared_state", + "tool_based_generative_ui", + "subgraphs", + "a2ui_dynamic_schema", + "a2ui_recovery", + "a2ui_fixed_schema", +} + + +def _conversational_examples(): + try: + return importlib.import_module("ag_ui_crewai.examples.conversational") + except ModuleNotFoundError: + pytest.fail("conversational dojo examples are not implemented") + + +def test_conversational_example_matrix_matches_regular_flows(): + examples = _conversational_examples() + + assert set(examples.CONVERSATIONAL_FLOW_TYPES) == EXPECTED_CONVERSATIONAL_FEATURES + assert "crew_chat" not in examples.CONVERSATIONAL_FLOW_TYPES + for flow_type in examples.CONVERSATIONAL_FLOW_TYPES.values(): + assert flow_type.conversational is True + assert flow_type.conversational_config.defer_trace_finalization is False + + +def test_conversational_examples_preserve_every_regular_flow_method(): + examples = _conversational_examples() + + for feature, flow_type in examples.CONVERSATIONAL_FLOW_TYPES.items(): + regular_flow_type = flow_type.__mro__[2] + regular_methods = set(regular_flow_type.flow_definition().methods) + conversational_methods = set(flow_type.flow_definition().methods) + + assert regular_methods <= conversational_methods, feature + + +def test_regular_end_methods_do_not_trigger_builtin_conversation_termination(): + examples = _conversational_examples() + + for feature, flow_type in examples.CONVERSATIONAL_FLOW_TYPES.items(): + end_definition = flow_type.flow_definition().methods["end_conversation"] + + assert end_definition.listen != "end", feature + + +@pytest.mark.parametrize( + "feature", + ["a2ui_dynamic_schema", "a2ui_recovery", "a2ui_fixed_schema"], +) +def test_untyped_mapping_flows_keep_mapping_compatible_state(feature): + examples = _conversational_examples() + state = examples.CONVERSATIONAL_FLOW_TYPES[feature]().state + + assert state.get("copilotkit") == {"actions": []} + assert state["messages"] == [] + + +def test_untyped_mapping_flows_preserve_a2ui_runtime_input(): + from ag_ui_crewai._conversation import ( + ConversationalTurn, + hydrate_conversational_flow, + ) + + examples = _conversational_examples() + flow = examples.CONVERSATIONAL_FLOW_TYPES["a2ui_recovery"]() + hydrate_conversational_flow( + flow, + {"ag-ui": {"inject_a2ui_tool": True}}, + ConversationalTurn(message="compare", history=[], current_media=[]), + ) + + assert isinstance(flow.state, Mapping) + assert flow.state.get("ag-ui") == {"inject_a2ui_tool": True} + + +def test_dojo_registers_a_conversational_route_for_every_feature(): + dojo = importlib.import_module("ag_ui_crewai.dojo") + paths = {route.path for route in dojo.app.routes} + + assert { + f"/conversational_flows/{feature}" + for feature in EXPECTED_CONVERSATIONAL_FEATURES + }.issubset(paths) + + +def test_conversational_examples_keep_litellm_compatible_message_dicts(): + examples = _conversational_examples() + flow = examples.CONVERSATIONAL_FLOW_TYPES["agentic_chat"]() + + flow.receive_user_message("hello") + + assert flow.state.current_user_message == "hello" + assert flow.state.messages[-1] == {"role": "user", "content": "hello"} + + +def test_hitl_tool_contract_respects_the_requested_step_count(): + hitl = importlib.import_module("ag_ui_crewai.examples.human_in_the_loop") + function = hitl.DEFINE_TASK_TOOL["function"] + contract = " ".join( + [ + function["description"], + function["parameters"]["properties"]["steps"]["description"], + ] + ).lower() + + assert "requested" in contract + assert "10 steps" not in contract + + +@pytest.mark.parametrize("conversational", [False, True]) +async def test_hitl_flow_sends_rejection_and_terse_revision_semantics( + monkeypatch, + conversational, +): + hitl = importlib.import_module("ag_ui_crewai.examples.human_in_the_loop") + examples = _conversational_examples() + flow_type = ( + examples.CONVERSATIONAL_FLOW_TYPES["human_in_the_loop"] + if conversational + else hitl.HumanInTheLoopFlow + ) + captured = {} + + async def fake_acompletion(**kwargs): + captured.update(kwargs) + return object() + + async def fake_stream(_response): + return SimpleNamespace( + choices=[ + SimpleNamespace( + message={"role": "assistant", "content": "waiting"} + ) + ] + ) + + monkeypatch.setattr(hitl, "acompletion", fake_acompletion) + monkeypatch.setattr(hitl, "copilotkit_stream", fake_stream) + + await flow_type().chat() + + prompt = captured["messages"][0]["content"].lower() + tool_contract = captured["tools"][-1]["function"]["description"].lower() + + assert prompt == hitl.HITL_SYSTEM_PROMPT.lower() + assert "critical:" in prompt + assert "accepted" in prompt + assert "false" in prompt + assert "do not perform" in prompt + assert "numeric" in prompt + assert "step count" in prompt + assert "requested" in tool_contract diff --git a/integrations/crew-ai/python/tests/test_conversational_flows.py b/integrations/crew-ai/python/tests/test_conversational_flows.py new file mode 100644 index 0000000000..a2c8d9e9fe --- /dev/null +++ b/integrations/crew-ai/python/tests/test_conversational_flows.py @@ -0,0 +1,734 @@ +"""Native CrewAI Conversational Flow bridge behavior.""" + +import asyncio +import importlib +import threading +from types import SimpleNamespace + +import pytest + +from ag_ui.core import ( + AssistantMessage, + ImageInputContent, + InputContentUrlSource, + SystemMessage, + TextInputContent, + ToolCall, + ToolMessage, + FunctionCall, + UserMessage, + EventType, + RunAgentInput, +) +from ag_ui.core.types import ResumeEntry +from ag_ui.encoder import EventEncoder +from crewai.experimental.conversational import ConversationConfig +from crewai.flow import human_feedback +from crewai.flow.flow import Flow, listen, start + +from ag_ui_crewai import _capabilities as capabilities +from ag_ui_crewai.sdk import CopilotKitState +from ag_ui_crewai.context import flow_context +from ag_ui_crewai.events import BridgedTextMessageChunkEvent +from ag_ui_crewai._hitl import ( + HITLOptions, + agui_feedback_provider, +) + + +class _WithStreamTurn: + conversational = True + + def stream_turn(self, message, *, session_id=None): + return (message, session_id) + + +class _WithoutStreamTurn: + conversational = True + + +class _DisabledWithStreamTurn: + conversational = False + + def stream_turn(self, message, *, session_id=None): + return (message, session_id) + + +class _RaisingStreamTurn: + conversational = True + + @property + def stream_turn(self): + raise RuntimeError("probe must degrade") + + +class _DocumentState(CopilotKitState): + document: str = "" + + +def test_conversational_stream_probe_uses_callable_surface(): + probe = capabilities.flow_supports_conversational_stream + + assert probe(_WithStreamTurn()) is True + assert probe(_WithoutStreamTurn()) is False + assert probe(_DisabledWithStreamTurn()) is False + assert probe(_RaisingStreamTurn()) is False + + +def test_conversational_stream_probe_requires_stream_frame_transport(monkeypatch): + monkeypatch.setattr(capabilities, "_stream_frame_available", False) + + assert capabilities.flow_supports_conversational_stream(_WithStreamTurn()) is False + + +def test_copilotkit_state_carries_crewai_conversation_runtime_fields(): + state = CopilotKitState() + + assert state.current_user_message is None + assert state.last_user_message is None + assert state.last_intent is None + assert state.ended is False + assert state.events == [] + assert state.agent_threads == {} + assert state.session_ready is False + + +def test_conversational_turn_preparer_is_available(): + try: + module = importlib.import_module("ag_ui_crewai._conversation") + except ModuleNotFoundError: + pytest.fail("ag_ui_crewai._conversation is not implemented") + + assert callable(getattr(module, "prepare_conversational_turn", None)) + + +def test_prepare_conversational_turn_splits_history_from_latest_user_text(): + from ag_ui_crewai._conversation import prepare_conversational_turn + + messages = [ + SystemMessage(id="s1", role="system", content="system"), + UserMessage(id="u1", role="user", content="first"), + AssistantMessage(id="a1", role="assistant", content="answer"), + UserMessage(id="u2", role="user", content="second"), + ] + + turn = prepare_conversational_turn(messages) + + assert turn.message == "second" + assert [ + {key: message[key] for key in ("id", "role", "content")} + for message in turn.history + ] == [ + {"id": "u1", "role": "user", "content": "first"}, + {"id": "a1", "role": "assistant", "content": "answer"}, + ] + assert turn.current_media == [] + assert messages[-1].content == "second" + + +def test_prepare_conversational_turn_keeps_media_out_of_text_argument(): + from ag_ui_crewai._conversation import prepare_conversational_turn + + messages = [ + UserMessage( + id="u2", + role="user", + content=[ + TextInputContent(type="text", text="look here"), + ImageInputContent( + type="image", + source=InputContentUrlSource( + type="url", value="https://example.com/image.png" + ), + ), + ], + ) + ] + + turn = prepare_conversational_turn(messages) + + assert turn.message == "look here" + assert turn.history == [] + assert turn.current_media == [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + } + ] + + +def test_prepare_conversational_turn_allows_image_only_turn(): + from ag_ui_crewai._conversation import prepare_conversational_turn + + turn = prepare_conversational_turn( + [ + UserMessage( + id="u1", + role="user", + content=[ + ImageInputContent( + type="image", + source=InputContentUrlSource( + type="url", value="https://example.com/image.png" + ), + ) + ], + ) + ] + ) + + assert turn.message == "" + assert len(turn.current_media) == 1 + + +def test_prepare_conversational_turn_preserves_frontend_tool_continuation(): + from ag_ui_crewai._conversation import prepare_conversational_turn + + messages = [ + UserMessage(id="u1", role="user", content="change the background"), + AssistantMessage( + id="a1", + role="assistant", + tool_calls=[ + ToolCall( + id="call-1", + type="function", + function=FunctionCall( + name="change_background", + arguments='{"background":"blue"}', + ), + ) + ], + ), + ToolMessage( + id="t1", + role="tool", + tool_call_id="call-1", + content='{"status":"success"}', + ), + ] + + turn = prepare_conversational_turn(messages) + + assert turn.message == "" + assert [message["role"] for message in turn.history] == [ + "user", + "assistant", + "tool", + ] + assert turn.history[-1]["tool_call_id"] == "call-1" + + +def test_hydrate_conversational_flow_preserves_regular_inputs_and_media(): + from ag_ui_crewai._conversation import ( + ConversationalTurn, + hydrate_conversational_flow, + ) + + flow = SimpleNamespace(_state=_DocumentState()) + turn = ConversationalTurn( + message="describe it", + history=[{"role": "assistant", "content": "send an image"}], + current_media=[ + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + } + ], + ) + + hydrate_conversational_flow( + flow, + { + "id": "thread-1", + "messages": [{"role": "user", "content": "ignored duplicate"}], + "document": "shared state", + "copilotkit": {"actions": [{"name": "frontend_tool"}]}, + }, + turn, + ) + + assert flow._state.id == "thread-1" + assert flow._state.document == "shared state" + assert flow._state.copilotkit.actions == [{"name": "frontend_tool"}] + assert flow._state.messages == [ + {"role": "assistant", "content": "send an image"}, + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + } + ], + }, + ] + + +def test_hydrate_conversational_flow_supports_mapping_state(): + from ag_ui_crewai._conversation import ( + ConversationalTurn, + hydrate_conversational_flow, + ) + + flow = SimpleNamespace(_state={"existing": True}) + turn = ConversationalTurn(message="hello", history=[], current_media=[]) + + hydrate_conversational_flow(flow, {"id": "thread-2", "value": 3}, turn) + + assert flow._state == { + "existing": True, + "id": "thread-2", + "value": 3, + "messages": [], + } + + +class _SyncSession: + def __init__(self, frames=(), error=None): + self.frames = list(frames) + self.error = error + self.closed = False + + def __iter__(self): + yield from self.frames + if self.error is not None: + raise self.error + + def close(self): + self.closed = True + + +class _StoredStatePersistence: + def load_state(self, flow_id): + return { + "id": flow_id, + "messages": [{"role": "assistant", "content": "stored history"}], + "document": "stored document", + } + + +class _PersistentRestoreFlow: + conversational = True + + def __init__(self): + self._state = _DocumentState() + self.persistence = _StoredStatePersistence() + self.state_seen_after_restore = None + + def stream_turn(self, _message, *, session_id=None): + self._state = _DocumentState.model_validate( + self.persistence.load_state(session_id) + ) + self.state_seen_after_restore = self._state.model_dump() + return _SyncSession() + + +@pytest.mark.asyncio +async def test_sync_stream_session_adapter_preserves_order_and_closes(): + from ag_ui_crewai._conversation import SyncStreamSessionAdapter + + session = _SyncSession(["one", "two", "three"]) + adapter = SyncStreamSessionAdapter(session) + + assert [frame async for frame in adapter] == ["one", "two", "three"] + assert session.closed is True + + +@pytest.mark.asyncio +async def test_sync_stream_session_adapter_propagates_producer_error(): + from ag_ui_crewai._conversation import SyncStreamSessionAdapter + + adapter = SyncStreamSessionAdapter( + _SyncSession(["one"], error=RuntimeError("producer failed")) + ) + + with pytest.raises(RuntimeError, match="producer failed"): + _ = [frame async for frame in adapter] + + +@pytest.mark.asyncio +async def test_sync_stream_session_adapter_aclose_is_non_blocking(caplog): + from ag_ui_crewai._conversation import SyncStreamSessionAdapter + + release = threading.Event() + + class _BlockedSession(_SyncSession): + def __iter__(self): + release.wait(timeout=5) + yield "late" + + session = _BlockedSession() + adapter = SyncStreamSessionAdapter(session) + iterator = adapter.__aiter__() + pending = asyncio.create_task(iterator.__anext__()) + await asyncio.sleep(0) + + await asyncio.wait_for(adapter.aclose(), timeout=0.1) + assert "requested cooperative cancellation" in caplog.text + release.set() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + +@pytest.mark.asyncio +async def test_sync_stream_session_adapter_logs_close_failures(caplog): + from ag_ui_crewai._conversation import SyncStreamSessionAdapter + + class _CloseFailingSession(_SyncSession): + def close(self): + raise RuntimeError("close failed") + + adapter = SyncStreamSessionAdapter(_CloseFailingSession(["one"])) + + assert [frame async for frame in adapter] == ["one"] + assert "failed to close a conversational StreamSession" in caplog.text + assert "close failed" in caplog.text + + +@pytest.mark.asyncio +async def test_frame_driver_reapplies_agui_inputs_after_persistence_restore(): + from ag_ui_crewai import endpoint + from ag_ui_crewai._conversation import ConversationalTurn + + flow = _PersistentRestoreFlow() + input_data = RunAgentInput( + thread_id="thread-persisted", + run_id="run-persisted", + state={"document": "incoming document"}, + messages=[UserMessage(id="u2", role="user", content="next turn")], + tools=[], + context=[], + forwarded_props={}, + ) + turn = ConversationalTurn( + message="next turn", + history=[{"role": "user", "content": "incoming history"}], + current_media=[], + ) + + _ = [ + chunk + async for chunk in endpoint._run_flow_frame_stream( + flow_copy=flow, + encoder=EventEncoder(), + input_data=input_data, + inputs={ + "id": input_data.thread_id, + "messages": turn.history, + "document": "incoming document", + }, + timeout=30, + conversational_turn=turn, + ) + ] + + assert flow.state_seen_after_restore["document"] == "incoming document" + assert flow.state_seen_after_restore["messages"] == turn.history + + +@ConversationConfig(defer_trace_finalization=False) +class _ConversationalBridgeFlow(Flow[CopilotKitState]): + conversational = True + + @start() + async def chat(self): + running = flow_context.get() + from ag_ui_crewai._capabilities import crewai_event_bus + + crewai_event_bus.emit( + running, + BridgedTextMessageChunkEvent( + type=EventType.TEXT_MESSAGE_CHUNK, + message_id="assistant-1", + role="assistant", + delta="hello back", + ), + ) + self.state.messages.append( + {"role": "assistant", "content": "hello back", "id": "assistant-1"} + ) + + def route_turn(self, _context): + return "ag_ui_complete" + + @listen("ag_ui_complete") + def finish_ag_ui_turn(self): + return None + + +@ConversationConfig() +class _DeferredConversationalFlow(_ConversationalBridgeFlow): + conversational = True + + +class _RegularOnlyFlow(Flow[CopilotKitState]): + @start() + def run_regular(self): + raise AssertionError("regular execution must not be used as fallback") + + +class _ConversationalInterruptState(CopilotKitState): + result: str = "" + + +@ConversationConfig(defer_trace_finalization=False) +class _ConversationalInterruptFlow(Flow[_ConversationalInterruptState]): + conversational = True + + @start() + @human_feedback(message="Approve the plan?", provider=agui_feedback_provider) + def propose(self): + return {"plan": ["a", "b"]} + + @listen(propose) + def apply(self, feedback): + answer = getattr(feedback, "feedback", feedback) + self.state.result = f"done: {answer}" + + def route_turn(self, _context): + return "ag_ui_complete" + + @listen("ag_ui_complete") + def finish_ag_ui_turn(self): + return None + + +def _decode_sse(chunks): + import json + + return [ + json.loads(line.removeprefix("data:").strip()) + for chunk in chunks + for line in chunk.splitlines() + if line.startswith("data:") + ] + + +@pytest.mark.asyncio +async def test_frame_driver_opens_public_conversational_turn(): + from ag_ui_crewai import endpoint + from ag_ui_crewai._conversation import prepare_conversational_turn + + flow = _ConversationalBridgeFlow() + input_data = RunAgentInput( + thread_id="thread-1", + run_id="run-1", + state={}, + messages=[UserMessage(id="u1", role="user", content="hello")], + tools=[], + context=[], + forwarded_props={}, + ) + turn = prepare_conversational_turn(input_data.messages) + + chunks = [ + chunk + async for chunk in endpoint._run_flow_frame_stream( + flow_copy=flow, + encoder=EventEncoder(), + input_data=input_data, + inputs={"id": "thread-1", "messages": []}, + timeout=30, + conversational_turn=turn, + ) + ] + events = _decode_sse(chunks) + + assert events[0]["type"] == "RUN_STARTED" + assert events[-1]["type"] == "RUN_FINISHED" + assert [ + event["delta"] for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" + ] == ["hello back"] + current_user_snapshot = next( + index + for index, event in enumerate(events) + if event["type"] == "MESSAGES_SNAPSHOT" + and any( + message.get("role") == "user" and message.get("content") == "hello" + for message in event["messages"] + ) + ) + first_assistant_content = next( + index + for index, event in enumerate(events) + if event["type"] == "TEXT_MESSAGE_CONTENT" + ) + assert current_user_snapshot < first_assistant_content + assert { + message["id"] + for event in events + if event["type"] == "MESSAGES_SNAPSHOT" + for message in event["messages"] + if message["role"] == "user" and message["content"] == "hello" + } == {"u1"} + assert flow.state.id == "thread-1" + assert ( + sum( + 1 + for message in flow.state.messages + if ( + message.get("role") + if isinstance(message, dict) + else getattr(message, "role", None) + ) + == "user" + and ( + message.get("content") + if isinstance(message, dict) + else getattr(message, "content", None) + ) + == "hello" + ) + == 1 + ) + + +def test_fastapi_endpoint_exposes_conversational_mode(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint + + app = FastAPI() + add_crewai_flow_fastapi_endpoint( + app, + _ConversationalBridgeFlow(), + path="/conversation", + conversational=True, + ) + input_data = RunAgentInput( + thread_id="thread-http", + run_id="run-http", + state={}, + messages=[UserMessage(id="u1", role="user", content="hello")], + tools=[], + context=[], + forwarded_props={}, + ) + + response = TestClient(app).post( + "/conversation", + json=input_data.model_dump(by_alias=True), + ) + + assert response.status_code == 200 + assert '"type":"RUN_STARTED"' in response.text + assert '"type":"RUN_FINISHED"' in response.text + + +def test_bridge_forces_per_request_conversation_trace_finalization(): + from ag_ui_crewai._conversation import force_per_turn_trace_finalization + + flow = _DeferredConversationalFlow() + assert flow._should_defer_trace_finalization() is True + + force_per_turn_trace_finalization(flow) + + assert flow._should_defer_trace_finalization() is False + + +@pytest.mark.asyncio +async def test_conversational_turn_pauses_and_resumes_human_feedback( + tmp_path, + monkeypatch, +): + from ag_ui_crewai import endpoint + from ag_ui_crewai._conversation import prepare_conversational_turn + + monkeypatch.chdir(tmp_path) + flow = _ConversationalInterruptFlow() + input_data = RunAgentInput( + thread_id="thread-interrupt", + run_id="run-interrupt", + state={}, + messages=[UserMessage(id="u1", role="user", content="make a plan")], + tools=[], + context=[], + forwarded_props={}, + ) + paused_chunks = [ + chunk + async for chunk in endpoint._run_flow_frame_stream( + flow_copy=flow, + encoder=EventEncoder(), + input_data=input_data, + inputs={"id": input_data.thread_id, "messages": []}, + timeout=30, + hitl_options=HITLOptions(emit_interrupt_outcome=True), + conversational_turn=prepare_conversational_turn(input_data.messages), + ) + ] + paused = _decode_sse(paused_chunks) + + assert paused[-1]["outcome"]["type"] == "interrupt" + interrupt_id = paused[-1]["outcome"]["interrupts"][0]["id"] + + resumed_input = RunAgentInput( + thread_id=input_data.thread_id, + run_id="run-resume", + state={}, + messages=input_data.messages, + tools=[], + context=[], + forwarded_props={}, + resume=[ + ResumeEntry( + interrupt_id=interrupt_id, + status="resolved", + payload="approved", + ) + ], + ) + resumed_chunks = [ + chunk + async for chunk in endpoint._run_flow_resume_stream( + flow=flow, + encoder=EventEncoder(), + input_data=resumed_input, + timeout=30, + hitl_options=HITLOptions(emit_interrupt_outcome=True), + ) + ] + resumed = _decode_sse(resumed_chunks) + + assert resumed[0]["type"] == "RUN_STARTED" + assert resumed[-1]["type"] == "RUN_FINISHED" + assert any( + event.get("snapshot", {}).get("result") == "done: approved" + for event in resumed + if event.get("type") == "STATE_SNAPSHOT" + ) + + +def test_conversational_endpoint_fails_loudly_for_regular_flow(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint + + app = FastAPI() + add_crewai_flow_fastapi_endpoint( + app, + _RegularOnlyFlow(), + path="/conversation", + conversational=True, + ) + input_data = RunAgentInput( + thread_id="thread-unsupported", + run_id="run-unsupported", + state={}, + messages=[UserMessage(id="u1", role="user", content="hello")], + tools=[], + context=[], + forwarded_props={}, + ) + + response = TestClient(app).post( + "/conversation", + json=input_data.model_dump(by_alias=True), + ) + + assert response.status_code == 200 + assert "AGUI_CREWAI_CONVERSATIONAL_FLOW_UNSUPPORTED" in response.text + assert '"threadId":"thread-unsupported"' in response.text + assert '"runId":"run-unsupported"' in response.text diff --git a/integrations/crew-ai/python/tests/test_interrupts.py b/integrations/crew-ai/python/tests/test_interrupts.py index 85af6975e9..ecf852059a 100644 --- a/integrations/crew-ai/python/tests/test_interrupts.py +++ b/integrations/crew-ai/python/tests/test_interrupts.py @@ -114,7 +114,7 @@ def done(self, feedback): # -------------------------------------------------------------------------- def test_hitl_symbols_resolve_on_supported_crewai(): - # The lock pins crewai 1.15.7, which exposes the whole async-HITL surface. + # The lock pins crewai 1.15.11, which exposes the whole async-HITL surface. assert caps.HumanFeedbackPending is not None assert caps.HumanFeedbackRequestedEvent is not None assert caps.FlowPausedEvent is not None diff --git a/integrations/crew-ai/python/tests/test_reasoning.py b/integrations/crew-ai/python/tests/test_reasoning.py index cb2c3b522b..458ee5d9a9 100644 --- a/integrations/crew-ai/python/tests/test_reasoning.py +++ b/integrations/crew-ai/python/tests/test_reasoning.py @@ -5,13 +5,20 @@ Ordering note: the crewai 1.x event bus dispatches sync handlers on a ThreadPoolExecutor, so bus-path tests assert the MULTISET of emitted events -(counts + content by message id), not cross-event capture order. Exact -lifecycle ordering is asserted against the synchronous ``StreamFrameTranslator`` -and, end-to-end, by driving a real Flow through ``_run_flow_frame_stream`` and -decoding the SSE (the ordering there is deterministic). +(counts + content by message id), not cross-event capture order. That covers +delta CONTENT as well as lifecycle: two deltas drained from a per-run queue need +not be in emit order, so a bus-path test never asserts their concatenation. +Exact ordering (lifecycle and multi-delta reassembly alike) is asserted against +the synchronous ``StreamFrameTranslator`` and, end-to-end, by driving a real Flow +through ``_run_flow_frame_stream`` and decoding the SSE, where it is +deterministic. """ +import contextlib +import importlib import json as _json +import logging +from collections import Counter import pytest @@ -435,6 +442,127 @@ async def _gen(): assert kinds.count("end") == 1 +def _group_by_id(events, kind, id_attr, value_attr): + """``{message id: [payloads]}`` for one captured reasoning event kind.""" + grouped = {} + for captured_kind, event in events: + if captured_kind == kind: + grouped.setdefault(getattr(event, id_attr), []).append( + getattr(event, value_attr) + ) + return grouped + + +def _assert_lifecycles_for(events, message_ids): + """Each id in ``message_ids`` has exactly one START/MESSAGE_START/END pair, and + no other id appears in the lifecycle.""" + for kind in ("start", "msg_start", "msg_end", "end"): + ids = sorted(e.message_id for k, e in events if k == kind) + assert ids == sorted(message_ids), (kind, ids) + + +async def test_copilotkit_stream_reasoning_text_after_close_opens_a_second_block(): + """Reasoning TEXT arriving after the answer text closed the first block is a + genuine SECOND thinking block: it gets its own complete lifecycle under a new + message id. Latching the channel shut on close discards that reasoning + instead, which is content loss on a working reasoning channel.""" + from ag_ui_crewai._capabilities import crewai_event_bus + + flow_context.set(None) + events = [] + with crewai_event_bus.scoped_handlers(): + _capture_reasoning(crewai_event_bus, events) + + async def _gen(): + yield _chunk("mr", delta=Delta(content=None, reasoning_content="first")) + yield _chunk("mr", content="answer") + yield _chunk("mr", delta=Delta(content=None, reasoning_content="late")) + yield _chunk("mr", finish_reason="stop") + + await copilotkit_stream(_FakeStreamWrapper(_gen())) + await _settle_bus() + + content_by_id = _group_by_id(events, "content", "message_id", "delta") + assert sorted(content_by_id.values()) == [["first"], ["late"]], content_by_id + _assert_lifecycles_for(events, content_by_id) + + +async def test_copilotkit_stream_anthropic_thinking_interleaved_with_tool_call(): + """Anthropic extended thinking around a tool call: the driver closes the + reasoning message when the tool call streams, and the thinking block that + FOLLOWS opens a second complete one. Both texts and both signatures surface, + so the working Anthropic channel never loses a block.""" + from types import SimpleNamespace + + from ag_ui_crewai._capabilities import crewai_event_bus + + flow_context.set(None) + events = [] + with crewai_event_bus.scoped_handlers(): + _capture_reasoning(crewai_event_bus, events) + + async def _gen(): + yield _chunk("ma", delta=Delta( + content=None, + thinking_blocks=[ + {"type": "thinking", "thinking": "step one", "signature": "SIG1"} + ], + )) + yield _chunk("ma", delta={ + "content": None, + "tool_calls": [ + SimpleNamespace(id="c-1", function={"name": "tool", "arguments": "{}"}) + ], + }) + yield _chunk("ma", delta=Delta( + content=None, + thinking_blocks=[ + {"type": "thinking", "thinking": "step two", "signature": "SIG2"} + ], + )) + yield _chunk("ma", finish_reason="tool_calls") + + await copilotkit_stream(_FakeStreamWrapper(_gen())) + await _settle_bus() + + content_by_id = _group_by_id(events, "content", "message_id", "delta") + assert sorted(content_by_id.values()) == [["step one"], ["step two"]], content_by_id + encrypted_by_id = _group_by_id(events, "enc", "entity_id", "encrypted_value") + assert sorted(encrypted_by_id.values()) == [["SIG1"], ["SIG2"]], encrypted_by_id + # Each signature rides the block it belongs to, not a stray message. + assert set(encrypted_by_id) == set(content_by_id) + _assert_lifecycles_for(events, content_by_id) + + +async def test_copilotkit_stream_encrypted_only_reasoning_after_close_is_dropped(): + """A redacted-thinking blob (no text) arriving after the block closed must NOT + open a second reasoning message: it carries nothing renderable, so the client + would show an empty second trace under the answer. Only the blob is dropped.""" + from ag_ui_crewai._capabilities import crewai_event_bus + + flow_context.set(None) + events = [] + with crewai_event_bus.scoped_handlers(): + _capture_reasoning(crewai_event_bus, events) + + async def _gen(): + yield _chunk("mz", delta=Delta(content=None, reasoning_content="first")) + yield _chunk("mz", content="answer") + yield _chunk("mz", delta=Delta( + content=None, + thinking_blocks=[{"type": "redacted_thinking", "data": "LATE"}], + )) + yield _chunk("mz", finish_reason="stop") + + await copilotkit_stream(_FakeStreamWrapper(_gen())) + await _settle_bus() + + content_by_id = _group_by_id(events, "content", "message_id", "delta") + assert sorted(content_by_id.values()) == [["first"]], content_by_id + _assert_lifecycles_for(events, content_by_id) + assert [e.encrypted_value for k, e in events if k == "enc"] == [] + + # -------------------------------------------------------------------------- # Legacy transport: endpoint listener translates Bridged -> wire events # -------------------------------------------------------------------------- @@ -805,6 +933,12 @@ async def test_litellm_reasoning_closes_before_answer_text_e2e(): < types.index("REASONING_MESSAGE_END") < types.index("REASONING_END") ) + # Multi-delta reasoning reassembles in the order the provider sent it. This + # is the deterministic home for that claim: on the bus path the drained order + # is not guaranteed, so those tests assert the multiset instead. + deltas = [p["delta"] for p in payloads if p["type"] == "REASONING_MESSAGE_CONTENT"] + assert len(deltas) == 2, payloads + assert "".join(deltas) == "Because X." @requires_stream_frames @@ -830,8 +964,9 @@ async def test_native_thinking_surfaces_reasoning_e2e(): ) assert content is not None, types assert content["delta"] == "pondering" - # Never RAW-mirrored (recognized channel), even though RAW defaults off here. - assert "RAW" not in types, types + # RAW passthrough is off in this call, so "no RAW here" says nothing about the + # recognized-channel rule; that claim is asserted with ``emit_raw_events=True`` + # by test_native_thinking_not_double_emitted_under_raw_passthrough. @requires_stream_frames @@ -856,3 +991,2425 @@ async def test_native_thinking_not_double_emitted_under_raw_passthrough(): if p["type"] == "RAW" and p.get("event", {}).get("type") == "llm_thinking_chunk" ] assert raw_thinking == [], payloads + + +# -------------------------------------------------------------------------- +# OpenAI Responses channel: reasoning summaries never appear on the +# chat-completions delta, so this is the ONLY channel that can surface an +# OpenAI thinking trace. These build the same event objects litellm produces +# (typed events where litellm knows the type, ``GenericEvent`` for the +# reasoning-summary deltas it does not) so the projection is exercised against +# real shapes, not hand-rolled stand-ins. +# +# Those event models are Responses-API additions, so they are NOT present on +# every litellm this package's declared floor (``litellm>=1.60.2``) permits. +# Import them DEFENSIVELY: an unguarded module-level import raises at COLLECTION +# time on such a build and takes down every test in this file, including the +# chat-completions and native-thinking channels that have nothing to do with the +# Responses API. +# +# What a test that needs an absent model must do is SKIP, never fail: the build +# genuinely cannot exercise the code. Two layers deliver that, and the second is +# the one that holds as tests are added: +# +# 1. ``requires_responses_types`` skips at COLLECTION time. Cheap and explicit, +# but it only protects the tests someone remembered to decorate. +# 2. Each absent model is bound to an ``_AbsentResponsesType`` PROXY rather than +# ``None``. Constructing, subscripting or reading an attribute off it skips the +# test that reached it. So a new Responses test needs no decorator and CANNOT +# turn a permitted litellm red; binding ``None`` instead made every undecorated +# test raise ``TypeError: 'NoneType' object is not callable``. +# +# Symbols imported at CALL time rather than through the block below go through +# ``_responses_symbol``, which skips the same way. +# -------------------------------------------------------------------------- + +from pydantic import ValidationError # noqa: E402 + +_RESPONSES_TYPES_ERROR: ImportError | None = None + + +class _AbsentResponsesType: + """Stand-in for a Responses-API model the installed litellm does not expose. + + Any use of it skips the calling test. ``pytest.skip`` raises a + ``BaseException``, so a production ``except Exception`` in the path under test + cannot swallow the skip into a pass or into some unrelated assertion failure. + """ + + def __init__(self, name): + self._name = name + + def _skip(self, *_args, **_kwargs): + pytest.skip( + f"installed litellm exposes no {self._name}: {_RESPONSES_TYPES_ERROR}" + ) + + __call__ = _skip + __getitem__ = _skip + + def __getattr__(self, name): + # Dunder lookups are INTROSPECTION, not use: pytest's own collection reads + # ``__test__`` off every module global. Report those absent instead of + # skipping the whole module. + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + self._skip() + + def __repr__(self): + return f"" + + +try: # noqa: E402 + from litellm.types.llms.openai import ( + FunctionCallArgumentsDeltaEvent, + GenericEvent, + IncompleteDetails, + OutputItemAddedEvent, + OutputTextDeltaEvent, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseIncompleteEvent, + ResponsesAPIResponse, + ) +except ImportError as exc: # pragma: no cover - depends on the installed litellm + _RESPONSES_TYPES_ERROR = exc + FunctionCallArgumentsDeltaEvent = _AbsentResponsesType( + "FunctionCallArgumentsDeltaEvent" + ) + GenericEvent = _AbsentResponsesType("GenericEvent") + IncompleteDetails = _AbsentResponsesType("IncompleteDetails") + OutputItemAddedEvent = _AbsentResponsesType("OutputItemAddedEvent") + OutputTextDeltaEvent = _AbsentResponsesType("OutputTextDeltaEvent") + ResponseCompletedEvent = _AbsentResponsesType("ResponseCompletedEvent") + ResponseCreatedEvent = _AbsentResponsesType("ResponseCreatedEvent") + ResponseIncompleteEvent = _AbsentResponsesType("ResponseIncompleteEvent") + ResponsesAPIResponse = _AbsentResponsesType("ResponsesAPIResponse") + +requires_responses_types = pytest.mark.skipif( + _RESPONSES_TYPES_ERROR is not None, + reason=( + "installed litellm exposes no Responses-API event models: " + f"{_RESPONSES_TYPES_ERROR}" + ), +) + + +def _responses_symbol(module_path, name): + """Return ``module_path.name``, skipping the calling test if it is absent. + + For Responses-API symbols a single assertion needs, imported where they are + used rather than through the guarded block above. An unguarded call-time + import is the same red build in a different place. + """ + try: + module = importlib.import_module(module_path) + return getattr(module, name) + except (ImportError, AttributeError) as exc: # pragma: no cover - build-dependent + pytest.skip(f"installed litellm exposes no {module_path}.{name}: {exc}") + + +from ag_ui_crewai import _responses as responses_mod # noqa: E402 +from ag_ui_crewai import sdk as sdk_mod # noqa: E402 +from ag_ui_crewai._reasoning import ( # noqa: E402 + reasoning_from_responses_event, + responses_event_type, +) +from ag_ui_crewai.examples.agentic_chat_reasoning import ( # noqa: E402 + AgenticChatReasoningFlow, +) + + +def _responses_api_response(status="completed", *, created_at=1700000000, + incomplete_details=None): + """A real ``ResponsesAPIResponse``, as litellm hands back on created/completed. + + ``created_at`` is typed ``float`` here exactly as it is on the wire, so a + fractional timestamp can be exercised. + """ + return ResponsesAPIResponse( + id="resp_1", + object="response", + created_at=created_at, + model="gpt-5.4", + status=status, + output=[], + error=None, + incomplete_details=incomplete_details, + instructions=None, + metadata={}, + parallel_tool_calls=False, + temperature=1.0, + tool_choice="auto", + tools=[], + top_p=1.0, + max_output_tokens=None, + previous_response_id=None, + reasoning={"effort": "medium", "summary": "auto"}, + text={"format": {"type": "text"}}, + truncation="disabled", + usage=None, + user=None, + ) + + +def _summary_delta(text, *, item_id="rs_1"): + """The reasoning-summary delta litellm surfaces as a ``GenericEvent``.""" + return GenericEvent( + type="response.reasoning_summary_text.delta", + item_id=item_id, + output_index=0, + summary_index=0, + delta=text, + ) + + +class _FakeResponsesStream: + """Duck-types litellm's Responses streaming iterator. + + ``is_responses_stream`` probes for an async-iterable exposing + ``_process_chunk``; that is exactly the iterator's public shape. + """ + + def __init__(self, events): + self._events = list(events) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._events: + raise StopAsyncIteration + return self._events.pop(0) + + def _process_chunk(self, chunk): # pragma: no cover - probe target only + return None + + +def _reasoning_then_text_events(): + return [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _summary_delta("Weighing the "), + _summary_delta("options."), + OutputItemAddedEvent( + type="response.output_item.added", + output_index=1, + item={"id": "msg_1", "type": "message", "role": "assistant", "content": []}, + ), + OutputTextDeltaEvent( + type="response.output_text.delta", + item_id="msg_1", + output_index=1, + content_index=0, + delta="Answer", + ), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ] + + +def _function_call_added(item_id="fc_1", *, call_id="call_abc", arguments=""): + """The ``output_item.added`` event that opens a Responses function call. + + ``OutputItemAddedEvent`` defines NO ``item_id`` field: the item's id lives at + ``item["id"]``, which is why the message-id lookup must read both shapes. + """ + return OutputItemAddedEvent( + type="response.output_item.added", + output_index=0, + item={ + "id": item_id, + "call_id": call_id, + "type": "function_call", + "name": "change_background", + "arguments": arguments, + }, + ) + + +def _reasoning_item_done(encrypted="BLOB", *, item_id="rs_1"): + """The finished ``reasoning`` output item carrying the encrypted blob.""" + return GenericEvent( + type="response.output_item.done", + output_index=0, + item={"id": item_id, "type": "reasoning", "encrypted_content": encrypted}, + ) + + +def _text_delta(text, *, item_id="msg_1"): + return OutputTextDeltaEvent( + type="response.output_text.delta", + item_id=item_id, output_index=1, content_index=0, delta=text, + ) + + +# -- projection ------------------------------------------------------------ + +@requires_responses_types +def test_responses_event_type_normalizes_enum_and_string(): + """A typed event's enum ``type`` and a GenericEvent's string both read as the + wire string, so the projection matches either. + + The returned value must be a PLAIN ``str``: litellm's + ``ResponsesAPIStreamEvents`` is a str-mixin enum, so an equality check alone + passes on the un-normalised enum too. Callers put these values in + ``frozenset`` membership tests and serialise them, so the type is the point.""" + typed = OutputTextDeltaEvent( + type="response.output_text.delta", + item_id="i", output_index=0, content_index=0, delta="x", + ) + # The raw field really is an enum here, else the normalisation is untested. + assert type(typed.type) is not str + normalized = responses_event_type(typed) + assert normalized == "response.output_text.delta" + assert type(normalized) is str + generic = responses_event_type(_summary_delta("x")) + assert generic == "response.reasoning_summary_text.delta" + assert type(generic) is str + assert responses_event_type(object()) is None + + +@requires_responses_types +def test_reasoning_from_responses_summary_delta(): + """A reasoning-summary delta yields its text and no encrypted blob.""" + r = reasoning_from_responses_event(_summary_delta("because X")) + assert r == DeltaReasoning(text="because X", encrypted=()) + assert bool(r) is True + + +@requires_responses_types +def test_reasoning_from_responses_raw_reasoning_text_delta(): + """The raw ``reasoning_text`` variant is projected too.""" + event = GenericEvent( + type="response.reasoning_text.delta", item_id="rs_1", output_index=0, delta="hm" + ) + assert reasoning_from_responses_event(event).text == "hm" + + +@requires_responses_types +def test_reasoning_from_responses_encrypted_content(): + """A finished ``reasoning`` output item surfaces its encrypted blob.""" + event = GenericEvent( + type="response.output_item.done", + output_index=0, + item={"id": "rs_1", "type": "reasoning", "encrypted_content": "BLOB"}, + ) + r = reasoning_from_responses_event(event) + assert r.text == "" + assert r.encrypted == ("BLOB",) + + +@requires_responses_types +def test_reasoning_from_responses_ignores_other_events(): + """Text deltas, non-reasoning finished items and empty deltas are no-ops, so a + non-reasoning model produces nothing.""" + text_delta = OutputTextDeltaEvent( + type="response.output_text.delta", + item_id="i", output_index=0, content_index=0, delta="hello", + ) + assert not reasoning_from_responses_event(text_delta) + message_done = GenericEvent( + type="response.output_item.done", + output_index=0, + item={"id": "msg_1", "type": "message"}, + ) + assert not reasoning_from_responses_event(message_done) + assert not reasoning_from_responses_event(_summary_delta("")) + assert not reasoning_from_responses_event(object()) + + +# -- copilotkit_stream over the Responses channel -------------------------- + +@requires_responses_types +async def test_copilotkit_stream_responses_emits_reasoning_then_text(): + """A Responses stream surfaces REASONING_* for its summary deltas, closes the + reasoning message before the answer text, and returns a chat-shaped + ModelResponse. Without the Responses path in ``copilotkit_stream`` there is + no reasoning at all on OpenAI. + + Bus path, so the MULTISET of reasoning deltas is asserted (see the module + docstring). Their exact concatenation is asserted on the deterministic frame + path by ``test_responses_reasoning_closes_before_tool_call_e2e``.""" + flow = _FakeFlow() + ep.FastAPICrewFlowEventListener() + queue = await ep.create_queue(flow) + flow_context.set(flow) + try: + result = await copilotkit_stream( + _FakeResponsesStream(_reasoning_then_text_events()) + ) + await _settle_bus() + items = _drain(queue) + finally: + await ep.delete_queue(flow) + + types = [e.type for e in items] + assert EventType.REASONING_START in types + assert EventType.REASONING_MESSAGE_START in types + assert EventType.REASONING_MESSAGE_END in types + assert EventType.REASONING_END in types + assert Counter( + e.delta for e in items if e.type == EventType.REASONING_MESSAGE_CONTENT + ) == Counter(["Weighing the ", "options."]) + # One reasoning message id across every delta: a fresh id per delta would + # split one trace into a message per token on the client. + assert len({ + e.message_id for e in items + if e.type == EventType.REASONING_MESSAGE_CONTENT + }) == 1 + text = "".join( + e.delta for e in items if e.type == EventType.TEXT_MESSAGE_CHUNK + ) + assert text == "Answer" + + assert result.choices[0].message.content == "Answer" + assert result.choices[0].finish_reason == "stop" + assert result.model == "gpt-5.4" + + +@requires_responses_types +async def test_copilotkit_stream_responses_tool_call_round_trip(): + """A Responses function call streams as TOOL_CALL_CHUNK under its ``call_id`` + (what a later ``function_call_output`` must reference), closes reasoning + first, and lands on the returned message's ``tool_calls``. + + Bus path, so the streamed argument fragments are asserted as a MULTISET (see + the module docstring); their exact concatenation is asserted on the + deterministic frame path by + ``test_responses_reasoning_closes_before_tool_call_e2e``. The REASSEMBLED + arguments on the returned message are order-critical and asserted exactly + below, because ``copilotkit_stream`` builds them synchronously.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _summary_delta("Picking a gradient."), + OutputItemAddedEvent( + type="response.output_item.added", + output_index=1, + item={ + "id": "fc_1", + "call_id": "call_abc", + "type": "function_call", + "name": "change_background", + "arguments": "", + }, + ), + FunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id="fc_1", output_index=1, delta='{"background":', + ), + FunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id="fc_1", output_index=1, delta='"red"}', + ), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ] + flow = _FakeFlow() + ep.FastAPICrewFlowEventListener() + queue = await ep.create_queue(flow) + flow_context.set(flow) + try: + result = await copilotkit_stream(_FakeResponsesStream(events)) + await _settle_bus() + items = _drain(queue) + finally: + await ep.delete_queue(flow) + + chunks = [e for e in items if e.type == EventType.TOOL_CALL_CHUNK] + assert chunks, [e.type for e in items] + assert {c.tool_call_id for c in chunks} == {"call_abc"} + assert {c.tool_call_name for c in chunks} == {"change_background"} + # The opening chunk carries the name with empty args, then one chunk per + # argument fragment. + assert Counter(c.delta or "" for c in chunks) == Counter( + ["", '{"background":', '"red"}'] + ) + # Reasoning closed: the tool call is not swallowed into the reasoning message. + assert EventType.REASONING_END in [e.type for e in items] + + tool_calls = result.choices[0].message.tool_calls + assert len(tool_calls) == 1 + assert tool_calls[0].id == "call_abc" + assert tool_calls[0].function.name == "change_background" + assert tool_calls[0].function.arguments == '{"background":"red"}' + assert result.choices[0].finish_reason == "tool_calls" + + +@requires_responses_types +async def test_copilotkit_stream_responses_failure_raises(): + """A failed Responses stream raises rather than returning an empty message, so + the drivers' RUN_ERROR taxonomy reports it.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + GenericEvent(type="error", code="server_error", message="upstream exploded"), + ] + with pytest.raises(RuntimeError, match="upstream exploded"): + await copilotkit_stream(_FakeResponsesStream(events)) + + +@requires_responses_types +async def test_copilotkit_stream_responses_closes_reasoning_on_error(): + """A stream that raises mid-reasoning still closes the reasoning message, so no + half-open lifecycle reaches the client.""" + + class _Boom(_FakeResponsesStream): + async def __anext__(self): + if not self._events: + raise ValueError("stream died") + return self._events.pop(0) + + flow = _FakeFlow() + ep.FastAPICrewFlowEventListener() + queue = await ep.create_queue(flow) + flow_context.set(flow) + try: + with pytest.raises(ValueError, match="stream died"): + await copilotkit_stream(_Boom([_summary_delta("half a thought")])) + await _settle_bus() + items = _drain(queue) + finally: + await ep.delete_queue(flow) + + types = [e.type for e in items] + assert EventType.REASONING_MESSAGE_END in types, types + assert EventType.REASONING_END in types, types + + +async def test_copilotkit_stream_rejects_unknown_response_type(): + """A response that is neither a ModelResponse, a CustomStreamWrapper nor a + Responses stream still raises, so the new branch did not widen the gate.""" + with pytest.raises(ValueError, match="Invalid response type"): + await copilotkit_stream(object()) + + +def test_is_responses_stream_rejects_chat_stream(): + """The chat-completions wrapper must not be routed to the Responses driver.""" + + async def _gen(): # pragma: no cover - never iterated + yield _chunk("m1", content="x") + + assert responses_mod.is_responses_stream(_FakeStreamWrapper(_gen())) is False + assert responses_mod.is_responses_stream(_FakeResponsesStream([])) is True + + +requires_responses_iterator_base = pytest.mark.skipif( + responses_mod.ResponsesAPIStreamingIteratorBase is None, + reason="litellm exposes no BaseResponsesAPIStreamingIterator to subclass", +) + + +def _sync_responses_iterator(events=()): + """A SYNCHRONOUS Responses iterator, shaped like litellm's own. + + ``SyncResponsesAPIStreamingIterator`` subclasses the SAME + ``BaseResponsesAPIStreamingIterator`` the async one does but exposes + ``__iter__`` only, so an isinstance-only probe cannot tell the two apart. + """ + base = responses_mod.ResponsesAPIStreamingIteratorBase + + class _SyncResponsesStream(base): # pylint: disable=too-few-public-methods + def __init__(self, items): # pylint: disable=super-init-not-called + self._events = list(items) + + def __iter__(self): + return self + + def __next__(self): + if not self._events: + raise StopIteration + return self._events.pop(0) + + def _process_chunk(self, chunk): # pragma: no cover - probe target only + return None + + return _SyncResponsesStream(events) + + +def _async_responses_iterator(events=()): + """An ASYNC Responses iterator that subclasses litellm's real base class.""" + base = responses_mod.ResponsesAPIStreamingIteratorBase + + class _AsyncResponsesStream(base): # pylint: disable=too-few-public-methods + def __init__(self, items): # pylint: disable=super-init-not-called + self._events = list(items) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._events: + raise StopAsyncIteration + return self._events.pop(0) + + def _process_chunk(self, chunk): # pragma: no cover - probe target only + return None + + return _AsyncResponsesStream(events) + + +@requires_responses_iterator_base +def test_is_responses_stream_rejects_sync_iterator(): + """A synchronous Responses iterator is NOT usable by the async driver. + + It shares the base class with the async one, so an isinstance-only check + would route it into ``_copilotkit_stream_responses`` and die there on a + missing ``__aiter__``. + """ + assert responses_mod.is_responses_stream(_sync_responses_iterator()) is False + + +def test_is_responses_stream_rejects_duck_typed_sync_iterator(): + """The duck-typed branch agrees: ``_process_chunk`` without ``__aiter__`` is + not an async Responses stream.""" + + class _DuckSync: # pylint: disable=too-few-public-methods + def __iter__(self): # pragma: no cover - probe target only + return iter(()) + + def _process_chunk(self, chunk): # pragma: no cover - probe target only + return None + + assert responses_mod.is_responses_stream(_DuckSync()) is False + + +@requires_responses_iterator_base +def test_is_responses_stream_accepts_async_iterator_subclass(): + """The async iterator, matched through the base class, is still accepted.""" + assert responses_mod.is_responses_stream(_async_responses_iterator()) is True + + +@requires_responses_iterator_base +async def test_copilotkit_stream_rejects_sync_responses_iterator(): + """A sync Responses stream raises the SAME ``ValueError`` as any other + unusable response type, naming the async entrypoint, instead of an + ``AttributeError`` from the async driver.""" + with pytest.raises(ValueError) as excinfo: + await copilotkit_stream(_sync_responses_iterator()) + message = str(excinfo.value) + assert "synchronous" in message, message + assert "copilotkit_responses" in message, message + + +@requires_responses_types +@requires_responses_iterator_base +async def test_copilotkit_stream_routes_async_responses_iterator(): + """An async Responses iterator still reaches the Responses driver and returns + the chat-shaped result.""" + result = await copilotkit_stream( + _async_responses_iterator(_reasoning_then_text_events()) + ) + assert result.choices[0].message.content == "Answer" + + +async def test_copilotkit_stream_routes_chat_wrapper_to_chat_driver(monkeypatch): + """A chat-completions ``CustomStreamWrapper`` keeps going to the chat driver: + tightening the Responses probe must not steal or reroute it.""" + + async def _unreachable(response): # pragma: no cover - must not be called + raise AssertionError("chat stream was routed to the Responses driver") + + monkeypatch.setattr(sdk_mod, "_copilotkit_stream_responses", _unreachable) + + async def _gen(): + yield _chunk("m1", content="hello") + yield _chunk("m1", finish_reason="stop") + + result = await copilotkit_stream(_FakeStreamWrapper(_gen())) + assert result.choices[0].message.content == "hello" + + +# -- input / tool conversion ---------------------------------------------- + +def test_chat_tools_to_responses_tools_flattens(): + """The nested chat-completions tool spec is flattened, and ``strict`` is opted + out so a schema written for chat-completions is still accepted.""" + converted = responses_mod.chat_tools_to_responses_tools([ + { + "type": "function", + "function": { + "name": "change_background", + "description": "d", + "parameters": {"type": "object", "properties": {"b": {"type": "string"}}}, + }, + } + ]) + assert converted == [ + { + "type": "function", + "name": "change_background", + "description": "d", + "parameters": {"type": "object", "properties": {"b": {"type": "string"}}}, + "strict": False, + } + ] + assert responses_mod.chat_tools_to_responses_tools([]) is None + assert responses_mod.chat_tools_to_responses_tools(None) is None + + +def test_chat_messages_to_responses_input_tool_round_trip(): + """An assistant tool call becomes a ``function_call`` and its tool message the + matching ``function_call_output``, keyed by the same ``call_id``.""" + items = responses_mod.chat_messages_to_responses_input([ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "make it red"}, + { + "role": "assistant", + "content": "sure", + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "change_background", "arguments": '{"b":"red"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_abc", "content": "done"}, + ]) + assert items == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "make it red"}, + {"role": "assistant", "content": "sure"}, + { + "type": "function_call", + "call_id": "call_abc", + "name": "change_background", + "arguments": '{"b":"red"}', + }, + {"type": "function_call_output", "call_id": "call_abc", "output": "done"}, + ] + + +def test_chat_messages_to_responses_input_drops_unresolved_call(): + """A tool call whose output never arrived is dropped: the Responses API rejects + the whole request over one unmatched call, which would break every later turn.""" + items = responses_mod.chat_messages_to_responses_input([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_orphan", + "type": "function", + "function": {"name": "change_background", "arguments": "{}"}, + } + ], + }, + ]) + assert items == [{"role": "user", "content": "hi"}] + + +def test_chat_messages_to_responses_input_multimodal(): + """Multimodal user content is converted to Responses input parts. + + ``detail`` is carried because the Responses input-image part requires it; + ``auto`` is the value the API itself defaults to, so nothing changes about + what the model sees. + """ + items = responses_mod.chat_messages_to_responses_input([ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this"}, + {"type": "image_url", "image_url": {"url": "https://x/y.png"}}, + ], + } + ]) + assert items == [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "what is this"}, + {"type": "input_image", "image_url": "https://x/y.png", "detail": "auto"}, + ], + } + ] + _assert_valid_responses_input(items) + + +def _assert_valid_responses_input(items): + """Validate ``items`` against the Responses ``input`` contract. + + The union comes from the openai types litellm re-exports (litellm types its + own ``input`` as ``ResponseInputParam``), so this asserts the shape the API + is handed, not a hand-rolled idea of it. + """ + from pydantic import TypeAdapter + + response_input_param = _responses_symbol( + "litellm.types.llms.openai", "ResponseInputParam" + ) + TypeAdapter(response_input_param).validate_python(items) + + +def test_chat_messages_to_responses_input_tool_pair_is_accepted_by_openai_types(): + """A round trip: an assistant tool call plus its result convert to a + ``function_call`` / ``function_call_output`` pair the Responses input union + accepts, with the arguments as the JSON string the API requires.""" + items = responses_mod.chat_messages_to_responses_input([ + {"role": "user", "content": "make it red"}, + { + "role": "assistant", + "content": "on it", + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "change_background", "arguments": '{"b":"red"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_abc", "content": "done"}, + ]) + assert items[-2:] == [ + { + "type": "function_call", + "call_id": "call_abc", + "name": "change_background", + "arguments": '{"b":"red"}', + }, + {"type": "function_call_output", "call_id": "call_abc", "output": "done"}, + ] + # Last: the union validation is the one step a litellm without the Responses + # types cannot run, and it skips the test when it cannot. Everything this test + # can assert on such a build has already been asserted above. + _assert_valid_responses_input(items) + + +def test_chat_messages_to_responses_input_drops_orphan_output(caplog): + """An output with no matching call is dropped, the mirror of dropping a call + with no output: the Responses API rejects either shape.""" + with caplog.at_level(logging.WARNING, logger="ag_ui_crewai._responses"): + items = responses_mod.chat_messages_to_responses_input([ + {"role": "user", "content": "hi"}, + {"role": "tool", "tool_call_id": "call_ghost", "content": "done"}, + ]) + assert items == [{"role": "user", "content": "hi"}] + assert any("call_ghost" in r.getMessage() for r in caplog.records), caplog.text + _assert_valid_responses_input(items) + + +def test_chat_messages_to_responses_input_drops_output_of_a_dropped_call(caplog): + """Dropping a malformed call must not leave its output behind: the drop that + protects the request would otherwise create the shape it protects against.""" + with caplog.at_level(logging.WARNING, logger="ag_ui_crewai._responses"): + items = responses_mod.chat_messages_to_responses_input([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + # No function name: this call cannot be emitted at all. + "tool_calls": [{"id": "call_nameless", "type": "function", "function": {}}], + }, + {"role": "tool", "tool_call_id": "call_nameless", "content": "done"}, + ]) + assert items == [{"role": "user", "content": "hi"}] + _assert_valid_responses_input(items) + + +def test_tool_call_arguments_are_serialised_when_not_a_string(caplog): + """Non-string arguments (a dict, as some providers produce) are serialised, + not discarded: emptying them would silently change what the model is told + it called.""" + with caplog.at_level(logging.WARNING, logger="ag_ui_crewai._responses"): + items = responses_mod.chat_messages_to_responses_input([ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "change_background", "arguments": {"b": "red"}}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_abc", "content": "done"}, + ]) + call = next(item for item in items if item.get("type") == "function_call") + assert _json.loads(call["arguments"]) == {"b": "red"} + assert any("arguments" in r.getMessage() for r in caplog.records), caplog.text + _assert_valid_responses_input(items) + + +def test_assistant_content_parts_are_not_emitted_as_input_parts(caplog): + """Assistant content parts cannot ride the input-part shape: no Responses + input item accepts ``input_text`` under the assistant role, so the parts + collapse onto the string content that item does accept, and a part with no + assistant representation is dropped with a log.""" + with caplog.at_level(logging.WARNING, logger="ag_ui_crewai._responses"): + items = responses_mod.chat_messages_to_responses_input([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "here it is"}, + {"type": "image_url", "image_url": {"url": "https://x/y.png"}}, + ], + }, + ]) + assert items == [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "here it is"}, + ] + assert any("image" in r.getMessage().lower() for r in caplog.records), caplog.text + _assert_valid_responses_input(items) + + +def test_tool_message_dict_content_is_json_not_a_python_repr(): + """A tool result that is not a string is serialised as JSON. ``str()`` would + hand the model a single-quoted Python repr no JSON parser accepts, the same + hazard the backend_tool_rendering example documents for crewai.""" + items = responses_mod.chat_messages_to_responses_input([ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call_abc", "type": "function", "function": {"name": "w", "arguments": "{}"}} + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc", + "content": {"temperature": 20, "conditions": "sunny", "ok": True}, + }, + ]) + output = next(item for item in items if item.get("type") == "function_call_output")["output"] + assert _json.loads(output) == {"temperature": 20, "conditions": "sunny", "ok": True} + assert "'" not in output + _assert_valid_responses_input(items) + + +async def test_copilotkit_responses_requires_the_channel(monkeypatch): + """With no ``aresponses`` entrypoint the helper raises a named error instead of + silently answering with no trace.""" + monkeypatch.setattr(responses_mod, "responses_entrypoint", lambda: None) + with pytest.raises(RuntimeError, match="aresponses"): + await responses_mod.copilotkit_responses( + model="openai/gpt-5.4", messages=[{"role": "user", "content": "hi"}] + ) + + +async def test_copilotkit_responses_passes_reasoning_and_stream(monkeypatch): + """The helper streams, converts messages + tools, and forwards ``reasoning`` + verbatim: without a ``summary`` OpenAI emits no reasoning deltas at all.""" + captured = {} + + async def _fake_entrypoint(**kwargs): + captured.update(kwargs) + return _FakeResponsesStream([]) + + monkeypatch.setattr(responses_mod, "responses_entrypoint", lambda: _fake_entrypoint) + await responses_mod.copilotkit_responses( + model="openai/gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + tools=[{"type": "function", "function": {"name": "t", "parameters": {}}}], + reasoning={"effort": "medium", "summary": "auto"}, + ) + assert captured["stream"] is True + assert captured["model"] == "openai/gpt-5.4" + assert captured["input"] == [{"role": "user", "content": "hi"}] + assert captured["reasoning"] == {"effort": "medium", "summary": "auto"} + assert captured["tools"][0]["name"] == "t" + + +# -- the demo picks the channel that actually carries OpenAI reasoning ----- + +class _ChannelSpy: + """Records which channel the reasoning demo opened for a provider choice. + + Both channels are faked, and the Responses PROBE is pinned live. The demo + routes OpenAI on ``responses_channel_available()``, so without the pin the + branch under test depends on the installed litellm: on a build without + ``aresponses`` the demo correctly degrades to chat-completions and every + assertion about the Responses branch reports that supported degrade as a + failure. Pinning makes the branch deterministic without weakening anything: + the probe's own effect is asserted by + ``test_reasoning_demo_degrades_without_the_responses_channel`` (which pins it + dark, after this constructor, and asserts the fallback) and by + ``test_responses_channel_capability_follows_the_probe``. A test that wants the + dark branch overrides the pin the same way. + """ + + def __init__(self, monkeypatch, *, channel_available=True): + # ``channel_available=None`` leaves the REAL probe in place, so a test + # can exercise the registry -> probe -> degrade chain end to end. + self.chat_calls = [] + self.responses_calls = [] + import ag_ui_crewai.examples.agentic_chat_reasoning as demo + + async def _fake_acompletion(**kwargs): + self.chat_calls.append(kwargs) + + async def _gen(): + yield _chunk("m1", content="plain answer") + yield _chunk("m1", finish_reason="stop") + + return _FakeStreamWrapper(_gen()) + + async def _fake_responses(**kwargs): + self.responses_calls.append(kwargs) + return _FakeResponsesStream(_reasoning_then_text_events()) + + monkeypatch.setattr(demo, "acompletion", _fake_acompletion) + monkeypatch.setattr(demo, "copilotkit_responses", _fake_responses) + if channel_available is not None: + monkeypatch.setattr( + demo, "responses_channel_available", lambda: channel_available + ) + + +async def _drive_reasoning_demo(model): + payloads = _decode_sse(await _collect(ep._run_flow_frame_stream( + flow_copy=AgenticChatReasoningFlow(), + encoder=EventEncoder(), + input_data=_run_input(), + inputs={"id": "t-1", "model": model, "messages": [], "copilotkit": {"actions": []}}, + timeout=30.0, + ))) + return payloads + + +@requires_stream_frames +@requires_responses_types +async def test_reasoning_demo_openai_surfaces_a_trace(monkeypatch): + """Selecting OpenAI streams over the Responses channel and surfaces a real + thinking trace. This is the reported defect: routed to chat-completions + (``acompletion``) OpenAI returns no reasoning content, so no REASONING_* is + emitted and this fails. + + ``_ChannelSpy`` pins the Responses probe live, so this asserts the routing and + the projection on every litellm rather than depending on the installed build's + channel.""" + spy = _ChannelSpy(monkeypatch) + payloads = await _drive_reasoning_demo("OpenAI") + types = [p["type"] for p in payloads] + + assert spy.responses_calls, "OpenAI must not stream over chat-completions" + assert spy.chat_calls == [] + # A summary is what makes OpenAI stream reasoning deltas at all. + assert spy.responses_calls[0]["reasoning"].get("summary") + + assert "REASONING_START" in types, types + trace = "".join( + p["delta"] for p in payloads if p["type"] == "REASONING_MESSAGE_CONTENT" + ) + assert trace == "Weighing the options." + first_text = types.index("TEXT_MESSAGE_START") + assert types.index("REASONING_END") < first_text + + +@requires_stream_frames +@pytest.mark.parametrize("provider", ["Anthropic", "Gemini"]) +async def test_reasoning_demo_keeps_chat_completions_channel(monkeypatch, provider): + """Anthropic and Gemini reason on the chat-completions delta and MUST keep + streaming through ``acompletion``: the Responses path is additive, not a + replacement.""" + spy = _ChannelSpy(monkeypatch) + await _drive_reasoning_demo(provider) + assert spy.responses_calls == [] + assert len(spy.chat_calls) == 1 + assert spy.chat_calls[0]["stream"] is True + + +@requires_stream_frames +async def test_reasoning_demo_degrades_without_the_responses_channel(monkeypatch): + """With the Responses channel unavailable, OpenAI falls back to + chat-completions rather than raising.""" + import ag_ui_crewai.examples.agentic_chat_reasoning as demo + + spy = _ChannelSpy(monkeypatch) + # After the spy: it pins the probe live, and this test owns the dark branch. + monkeypatch.setattr(demo, "responses_channel_available", lambda: False) + payloads = await _drive_reasoning_demo("OpenAI") + assert spy.responses_calls == [] + assert len(spy.chat_calls) == 1 + assert "RUN_ERROR" not in [p["type"] for p in payloads] + + +# -- capability declaration ------------------------------------------------ + +def test_responses_channel_capability_follows_the_probe(monkeypatch): + """Drive the Responses probe to BOTH states and assert the declaration and the + runtime selector callers gate on move together. + + Not a restatement of the field it is built from: a hard-coded value, or a + declaration sourced from somewhere other than what + ``responses_channel_available`` reads, fails one leg.""" + import ag_ui_crewai._capabilities as caps + + for probe_state in (True, False): + monkeypatch.setattr(caps, "_responses_api_available", probe_state) + monkeypatch.setattr(caps, "CAPABILITIES", caps._detect()) + monkeypatch.setattr(responses_mod, "CAPABILITIES", caps.CAPABILITIES) + block = caps.get_capabilities()["reasoning"] + assert block["responsesApiChannel"] is probe_state + assert responses_mod.responses_channel_available() is probe_state + assert block["requiresEmitRawEvents"] is False + + +@pytest.mark.parametrize( + "litellm_live,thinking_live,responses_live", + [ + (True, True, True), + (True, False, False), + (False, True, False), + (False, False, True), + (False, False, False), + ], +) +def test_reasoning_block_cannot_self_contradict( + monkeypatch, litellm_live, thinking_live, responses_live +): + """Whatever resolved, the block agrees with itself. + + Every channel field comes from ONE snapshot and ``supported`` / ``reason`` are + derived from those three fields, so no single-probe patch can make the block + advertise a channel it also reports absent, claim support with every channel + dark, or hand back a ``reason`` that contradicts ``supported``. Sourcing one + field from a live module global while another comes from the snapshot breaks + this.""" + import ag_ui_crewai._capabilities as caps + + monkeypatch.setattr(caps, "_litellm_available", litellm_live) + monkeypatch.setattr(caps, "_thinking_event_available", thinking_live) + monkeypatch.setattr(caps, "_responses_api_available", responses_live) + monkeypatch.setattr(caps, "CAPABILITIES", caps._detect()) + + # One probe, one value: the snapshot's native-event field is that same probe, + # not a stale copy taken at import. + assert caps.CAPABILITIES.native_reasoning_event_available is thinking_live + + block = caps._reasoning_capability() + assert block["litellmChannel"] is litellm_live + assert block["thinkingEventAvailable"] is thinking_live + assert block["responsesApiChannel"] is responses_live + + expected_supported = caps.any_reasoning_channel( + litellm_available=block["litellmChannel"], + thinking_event_available=block["thinkingEventAvailable"], + responses_api_available=block["responsesApiChannel"], + ) + assert block["supported"] is expected_supported + assert block["supported"] is caps.CAPABILITIES.reasoning_available + assert (block["reason"] is None) is expected_supported + + +def test_reasoning_unavailable_reason_names_the_all_channels_absent_condition(monkeypatch): + """Reasoning drops out only when ALL THREE channels are absent, so the reason + must report that condition instead of pinning it on litellm.""" + import ag_ui_crewai._capabilities as caps + + monkeypatch.setattr(caps, "_litellm_available", False) + monkeypatch.setattr(caps, "_thinking_event_available", False) + monkeypatch.setattr(caps, "_responses_api_available", False) + monkeypatch.setattr(caps, "CAPABILITIES", caps._detect()) + + block = caps._reasoning_capability() + assert block["supported"] is False + assert block["reason"] == "no_reasoning_channel_available" + assert "litellm" not in block["reason"] + + +@pytest.mark.parametrize( + "litellm_live,thinking_live,responses_live,expected", + [ + (False, False, False, False), + (True, False, False, True), + (False, True, False, True), + (False, False, True, True), + (True, True, True, True), + ], +) +def test_any_reasoning_channel_rule(litellm_live, thinking_live, responses_live, expected): + """Reasoning is available whenever ANY channel is live. A build with ONLY the + Responses channel must still report supported; narrowing the rule to the + litellm channel makes the (False, False, True) case fail.""" + from ag_ui_crewai._capabilities import any_reasoning_channel + + assert ( + any_reasoning_channel( + litellm_available=litellm_live, + thinking_event_available=thinking_live, + responses_api_available=responses_live, + ) + is expected + ) + + +def test_capability_snapshot_reports_reasoning_from_every_channel(monkeypatch): + """The snapshot recomputes availability from the LIVE probes, so a build where + only the Responses channel resolved still reports reasoning available. + Hard-wiring the snapshot to the litellm channel makes this fail.""" + import ag_ui_crewai._capabilities as caps + + monkeypatch.setattr(caps, "_litellm_available", False) + monkeypatch.setattr(caps, "_thinking_event_available", False) + monkeypatch.setattr(caps, "_responses_api_available", True) + snapshot = caps._detect() + assert snapshot.reasoning_available is True + assert snapshot.responses_api_available is True + + monkeypatch.setattr(caps, "_responses_api_available", False) + assert caps._detect().reasoning_available is False + + +class _ResponsesToolCallFlow(Flow): + """A real Flow streaming a Responses tool-call turn (reasoning summary, then + the function call), driven through ``copilotkit_stream``. + + Both the summary and the call arguments arrive in MULTIPLE deltas so the + frame-path test below can assert their exact concatenation.""" + + @start() + async def chat(self): + await copilotkit_stream(_FakeResponsesStream([ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _summary_delta("Picking "), + _summary_delta("a gradient."), + OutputItemAddedEvent( + type="response.output_item.added", + output_index=1, + item={ + "id": "fc_1", + "call_id": "call_abc", + "type": "function_call", + "name": "change_background", + "arguments": "", + }, + ), + FunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id="fc_1", output_index=1, delta='{"background":', + ), + FunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id="fc_1", output_index=1, delta='"red"}', + ), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ])) + return "done" + + +@requires_stream_frames +@requires_responses_types +async def test_responses_reasoning_closes_before_tool_call_e2e(): + """The reasoning message CLOSES before the tool call opens. Deleting the + ``reasoning.close()`` hook on the function-call branch leaves only the + ``finally`` to close it, which lands AFTER the tool call, so this fails. + + Also the deterministic home for the ORDER of the streamed fragments: both the + reasoning summary and the tool-call arguments must reassemble on the wire in + the order the provider sent them (the bus-path tests can only assert the + multiset).""" + payloads = _decode_sse(await _collect(ep._run_flow_frame_stream( + flow_copy=_ResponsesToolCallFlow(), + encoder=EventEncoder(), + input_data=_run_input(), + inputs={"id": "t-1"}, + timeout=30.0, + ))) + types = [p["type"] for p in payloads] + assert "REASONING_END" in types, types + assert "TOOL_CALL_START" in types, types + assert types.index("REASONING_END") < types.index("TOOL_CALL_START"), types + assert types.index("REASONING_MESSAGE_END") < types.index("TOOL_CALL_START"), types + + reasoning_deltas = [ + p["delta"] for p in payloads if p["type"] == "REASONING_MESSAGE_CONTENT" + ] + assert len(reasoning_deltas) == 2, payloads + assert "".join(reasoning_deltas) == "Picking a gradient." + arg_deltas = [p["delta"] for p in payloads if p["type"] == "TOOL_CALL_ARGS"] + assert len(arg_deltas) == 2, payloads + assert "".join(arg_deltas) == '{"background":"red"}' + + +@requires_stream_frames +@requires_responses_types +async def test_responses_reasoning_only_stream_closes_on_finalize_e2e(): + """A stream carrying reasoning and nothing else still closes its reasoning + lifecycle, via the driver's ``finally``.""" + + class _ReasoningOnlyFlow(Flow): + @start() + async def chat(self): + await copilotkit_stream(_FakeResponsesStream([_summary_delta("only this")])) + return "done" + + payloads = _decode_sse(await _collect(ep._run_flow_frame_stream( + flow_copy=_ReasoningOnlyFlow(), + encoder=EventEncoder(), + input_data=_run_input(), + inputs={"id": "t-1"}, + timeout=30.0, + ))) + types = [p["type"] for p in payloads] + assert types.index("REASONING_MESSAGE_END") < types.index("REASONING_END") + assert "RUN_ERROR" not in types, types + + +# -- one unparseable event: skip the envelope, surface everything else ------- +# +# litellm validates each stream event against its own typed model, so a single +# event can fail to parse. Which event it was decides what a skip costs: an +# envelope event carries no payload this bridge maps, but a payload event +# carries answer text / tool-call arguments and a terminal event carries the +# stream's outcome. Parsing is what failed, so the classification cannot read a +# ``type`` off the object; ``ValidationError.title`` (the model litellm +# attempted) and litellm's own "Unknown event type: " ``ValueError`` are +# the signals that remain. + +class _ScriptedEventStream(_FakeResponsesStream): + """Streams a script of events, RAISING any entry that is an exception. + + That is exactly how litellm surfaces a per-event parse failure: it raises + out of ``__anext__`` for the event it could not build, and the rest of the + stream is still there to read. + """ + + async def __anext__(self): + if not self._events: + raise StopAsyncIteration + entry = self._events.pop(0) + if isinstance(entry, BaseException): + raise entry + return entry + + +def _litellm_validation_error(model_name): + """The ValidationError litellm raises when a provider omits a field the typed + model for that event requires. + + ``title`` is the model litellm attempted, verified against + ``OpenAIResponsesAPIConfig.get_event_model_class(...)(**chunk)``: that is the + only signal identifying the event once parsing has failed. + """ + return ValidationError.from_exception_data( + model_name, + [{"type": "missing", "loc": ("response",), "input": {}}], + ) + + +def _completed_event(): + return ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ) + + +def test_litellm_validation_error_title_is_the_attempted_model(): + """The signal the classification rests on, asserted against litellm itself: + the ValidationError litellm raises for a malformed event is titled after the + model it tried to build, so the event can be identified without the object.""" + config = _responses_symbol( + "litellm.llms.openai.responses.transformation", "OpenAIResponsesAPIConfig" + ) + + for event_type, expected_title in ( + ("response.created", "ResponseCreatedEvent"), + ("response.in_progress", "ResponseInProgressEvent"), + ("response.output_text.delta", "OutputTextDeltaEvent"), + ("response.failed", "ResponseFailedEvent"), + ): + model = config.get_event_model_class(event_type=event_type) + with pytest.raises(ValidationError) as caught: + model(**{"type": event_type}) + assert caught.value.title == expected_title + + +@requires_responses_types +async def test_responses_stream_survives_unparseable_envelope_events(): + """An envelope event the client cannot parse is skipped, not fatal: the + reasoning trace and the answer still reach the wire. Letting the error + propagate loses the whole turn to a RUN_ERROR.""" + events = [ + _litellm_validation_error("ResponseCreatedEvent"), + _litellm_validation_error("ResponseInProgressEvent"), + _summary_delta("Weighing the options."), + _text_delta("Ans"), + _text_delta("wer"), + _completed_event(), + ] + flow = _FakeFlow() + ep.FastAPICrewFlowEventListener() + queue = await ep.create_queue(flow) + flow_context.set(flow) + try: + result = await copilotkit_stream(_ScriptedEventStream(events)) + await _settle_bus() + items = _drain(queue) + finally: + await ep.delete_queue(flow) + + trace = "".join( + e.delta for e in items if e.type == EventType.REASONING_MESSAGE_CONTENT + ) + assert trace == "Weighing the options." + assert result.choices[0].message.content == "Answer" + + # With response.created skipped, EVERY chunk of the message still carries the + # SAME id (resolved once from the output item). A fresh id per chunk would + # split one answer into a message per token on the client. + text_events = [e for e in items if e.type == EventType.TEXT_MESSAGE_CHUNK] + assert len(text_events) == 2 + assert {e.message_id for e in text_events} == {"msg_1"} + + +@pytest.mark.parametrize( + "model_name", + ["OutputTextDeltaEvent", "FunctionCallArgumentsDeltaEvent", "OutputItemAddedEvent"], +) +async def test_responses_stream_surfaces_unparseable_payload_event(model_name): + """An unparseable PAYLOAD event must not vanish. Skipping one drops answer + text or leaves a tool call's arguments truncated to invalid JSON, and the + turn still "succeeds", so nothing downstream can tell content was lost.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _litellm_validation_error(model_name), + _text_delta("only half"), + _completed_event(), + ] + with pytest.raises(RuntimeError, match="failed to parse") as caught: + await copilotkit_stream(_ScriptedEventStream(events)) + assert model_name in str(caught.value) + assert isinstance(caught.value.__cause__, ValidationError) + + +async def test_responses_stream_surfaces_unparseable_terminal_failure(): + """An unparseable TERMINAL failure must surface as an error, not as an empty + assistant message: skipping ``response.failed`` leaves a failed stream with + no failure recorded, no RUN_ERROR, and zero content.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _litellm_validation_error("ResponseFailedEvent"), + ] + with pytest.raises(RuntimeError, match="failed to parse"): + await copilotkit_stream(_ScriptedEventStream(events)) + + +async def test_responses_stream_surfaces_unparseable_terminal_completion(): + """``response.completed`` is terminal too: skipping it means the stream ended + for an unknown reason, which is not a clean completion.""" + events = [ + _text_delta("Answer"), + _litellm_validation_error("ResponseCompletedEvent"), + ] + with pytest.raises(RuntimeError, match="failed to parse"): + await copilotkit_stream(_ScriptedEventStream(events)) + + +async def test_responses_stream_handles_unknown_event_type_lookup_error(): + """litellm 1.63-1.67 raise ``ValueError("Unknown event type: ")`` from + their event-type lookup (newer builds answer with ``GenericEvent`` instead), + so the classification must handle a plain ValueError, not only a + ValidationError. + + A type litellm has no model for is a fact about the BUILD, so what it costs + still depends on the role that type plays here: a type this bridge never + reads costs nothing; a reasoning delta costs a gap in a trace; answer text, + tool-call arguments and the outcome cannot be read at all on such a build, + which is reported as that build fact (asserted in full by + ``test_unmodellable_load_bearing_type_reports_the_build_not_a_corrupt_event``). + """ + unread = [ + ValueError("Unknown event type: response.audio.delta"), + _text_delta("Answer"), + _completed_event(), + ] + result = await copilotkit_stream(_ScriptedEventStream(unread)) + assert result.choices[0].message.content == "Answer" + + # Case is not part of the signal: the type is captured off the ORIGINAL + # message, whatever case litellm wrote the prefix in. + shouty = [ + ValueError("UNKNOWN EVENT TYPE: response.audio.delta"), + _text_delta("Answer"), + _completed_event(), + ] + result = await copilotkit_stream(_ScriptedEventStream(shouty)) + assert result.choices[0].message.content == "Answer" + + for event_type in ( + "response.output_text.delta", + "response.function_call_arguments.delta", + "response.failed", + ): + with pytest.raises(RuntimeError, match="no model for"): + await copilotkit_stream( + _ScriptedEventStream([ValueError(f"Unknown event type: {event_type}")]) + ) + + # A message that names no type at all cannot be judged, so it is reported + # rather than skipped on the assumption that nothing was lost. + nameless = ValueError("Unknown event type") + with pytest.raises(RuntimeError, match="failed to parse"): + await copilotkit_stream(_ScriptedEventStream([nameless])) + + +async def test_responses_stream_gives_up_when_nothing_parses(): + """A stream where every event fails to parse raises instead of silently + returning an empty assistant message.""" + events = [_litellm_validation_error("ResponseCreatedEvent")] * ( + responses_mod._MAX_SKIPPED_EVENTS + 2 + ) + with pytest.raises(RuntimeError, match="unreadable"): + await copilotkit_stream(_ScriptedEventStream(events)) + + +async def test_responses_stream_propagates_transport_errors(): + """A transport failure is NOT swallowed by the skip path.""" + + class _Broken(_FakeResponsesStream): + async def __anext__(self): + raise ConnectionError("socket closed") + + with pytest.raises(ConnectionError, match="socket closed"): + await copilotkit_stream(_Broken([])) + + +async def test_responses_stream_propagates_non_litellm_value_errors(): + """``json.JSONDecodeError`` is a ``ValueError`` too, so a truncated SSE frame + must propagate untouched rather than be mistaken for an event litellm could + not model.""" + truncated = _json.JSONDecodeError("Expecting value", '{"type":', 8) + with pytest.raises(_json.JSONDecodeError): + await copilotkit_stream(_ScriptedEventStream([truncated])) + + with pytest.raises(ValueError, match="stream died"): + await copilotkit_stream(_ScriptedEventStream([ValueError("stream died")])) + + +async def test_responses_stream_propagates_cancellation(): + """Cancellation must not be counted as an unparseable event.""" + import asyncio + + with pytest.raises(asyncio.CancelledError): + await copilotkit_stream(_ScriptedEventStream([asyncio.CancelledError()])) + + +@requires_stream_frames +async def test_unparseable_terminal_failure_reaches_the_wire_as_run_error_e2e(): + """The whole point, end to end: a ``response.failed`` whose payload does not + parse reports a RUN_ERROR instead of finishing the run with an empty + assistant message and no record of the failure.""" + + class _FailedTerminalFlow(Flow): + @start() + async def chat(self): + await copilotkit_stream(_ScriptedEventStream([ + ResponseCreatedEvent( + type="response.created", + response=_responses_api_response("in_progress"), + ), + _litellm_validation_error("ResponseFailedEvent"), + ])) + return "done" + + payloads = _decode_sse(await _collect(ep._run_flow_frame_stream( + flow_copy=_FailedTerminalFlow(), + encoder=EventEncoder(), + input_data=_run_input(), + inputs={"id": "t-1"}, + timeout=30.0, + ))) + types = [p["type"] for p in payloads] + assert "RUN_ERROR" in types, types + assert "RUN_FINISHED" not in types, types + + +# -- what an unparseable event costs comes from the event's ROLE -------------- +# +# Which event failed decides whether skipping it is free or silently drops +# content, so the disposition is derived from the role the event plays for this +# bridge (``_responses_events.EVENT_ROLES``) plus litellm's OWN event-type to +# model registry, never from a list of model names kept next to the decision. +# The tests below pin both halves: the role map cannot drift away from the code +# that reads the events, and the attribution cannot drift away from litellm. + +#: Every Responses event type this bridge reads, spelled out here so the table +#: below is driven by literals rather than by the map it is checking. The +#: anti-drift test asserts this IS the role map's key set. +_ALL_READ_EVENT_TYPES = ( + "response.created", + "response.in_progress", + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + "response.output_item.added", + "response.output_item.done", + "response.output_text.delta", + "response.function_call_arguments.delta", + "response.completed", + "response.incomplete", + "response.failed", + "error", +) + + +def _responses_event_types_referenced_by(func): + """The Responses event type strings ``func``'s own source branches on. + + Parses the FUNCTION's source, so the set is what the code does today rather + than what a list next to it claims. Both a ``RESPONSES_*`` constant (resolved + through the module, and expanded when it holds a set of types) and an inlined + literal count, so bypassing the constants does not bypass the check. + """ + import ast + import inspect + import textwrap + + tree = ast.parse(textwrap.dedent(inspect.getsource(func))) + module = inspect.getmodule(func) + event_types = set() + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if node.value.startswith("response.") or node.value == "error": + event_types.add(node.value) + continue + if not isinstance(node, ast.Name) or not node.id.startswith("RESPONSES_"): + continue + value = getattr(module, node.id, None) + if isinstance(value, str): + event_types.add(value) + elif isinstance(value, (frozenset, set, tuple, list)): + event_types.update(v for v in value if isinstance(v, str)) + return event_types + + +def test_event_roles_cover_every_type_the_responses_code_handles(): + """The role map is checked against the code that consumes the events. + + Both directions: a driver branch on a type with no role would leave that + event's parse failure unclassified, and a role entry no code reads would be + describing a channel that no longer exists. Adding a branch without a role + (or the reverse) fails here instead of surfacing as a mis-severity later.""" + from ag_ui_crewai import _reasoning as reasoning_mod + from ag_ui_crewai import _responses_events as vocab + from ag_ui_crewai import sdk as sdk_mod + + handled = _responses_event_types_referenced_by( + sdk_mod._copilotkit_stream_responses + ) | _responses_event_types_referenced_by( + reasoning_mod.reasoning_from_responses_event + ) + assert handled, "the Responses code no longer names its event types by constant" + assert set(_ALL_READ_EVENT_TYPES) == set(vocab.EVENT_ROLES) + + without_a_role = sorted(handled - set(vocab.EVENT_ROLES)) + assert not without_a_role, without_a_role + + # ``response.in_progress`` is the one bookkeeping type no code branches on: + # it is listed so an unparseable one is provably skippable. + unread = sorted( + event_type + for event_type, role in vocab.EVENT_ROLES.items() + if role != vocab.ENVELOPE and event_type not in handled + ) + assert not unread, unread + + +@requires_responses_types +def test_parse_failure_attribution_comes_from_litellms_own_registry(): + """Every read type's role is reachable through the model class LITELLM builds + it with, which is the only signal a ``ValidationError`` leaves behind. + + A class litellm uses for SEVERAL read types (its catch-all) carries the most + severe of their roles, so a catch-all failure is never treated as cheaper + than the worst event it could have been.""" + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + + from ag_ui_crewai import _capabilities as caps + from ag_ui_crewai import _responses_events as vocab + + modelling = caps.responses_event_modelling() + assert modelling.resolver_available + + served_by = {} + for event_type, role in vocab.EVENT_ROLES.items(): + model = OpenAIResponsesAPIConfig.get_event_model_class(event_type=event_type) + served_by.setdefault(model.__name__, []).append(role) + + for model_name, roles in served_by.items(): + attributed = modelling.model_roles[model_name] + assert attributed == max(roles, key=vocab.role_severity), ( + model_name, + roles, + attributed, + ) + + +@requires_responses_types +@pytest.mark.parametrize("event_type", _ALL_READ_EVENT_TYPES) +async def test_unparseable_event_fatality_follows_its_role(event_type): + """One table for the whole classification: an unparseable event is reported + when its role is load-bearing (answer text, a tool call's identity or + arguments, the stream's outcome) and skipped when it is not (stream + bookkeeping, one reasoning-summary delta, the optional encrypted-reasoning + item). + + Driven per type through litellm's own model for that type, so the severity + comes from the role rather than from which model names someone remembered to + list.""" + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + + from ag_ui_crewai import _responses_events as vocab + + model_name = OpenAIResponsesAPIConfig.get_event_model_class( + event_type=event_type + ).__name__ + events = [ + _litellm_validation_error(model_name), + _text_delta("Answer"), + _completed_event(), + ] + if vocab.is_load_bearing(vocab.EVENT_ROLES[event_type]): + with pytest.raises(RuntimeError, match="failed to parse"): + await copilotkit_stream(_ScriptedEventStream(events)) + return + result = await copilotkit_stream(_ScriptedEventStream(events)) + assert result.choices[0].message.content == "Answer" + + +@requires_responses_types +async def test_responses_stream_survives_an_unparseable_output_item_done(): + """``response.output_item.done`` carries ONE thing this bridge reads: the + OPTIONAL encrypted-reasoning blob, present only when the caller asked for + it. Losing it must not cost the run the answer it already streamed.""" + events = [ + _summary_delta("Weighing the options."), + _litellm_validation_error("OutputItemDoneEvent"), + _text_delta("Answer"), + _completed_event(), + ] + result = await copilotkit_stream(_ScriptedEventStream(events)) + assert result.choices[0].message.content == "Answer" + + +@requires_responses_types +@pytest.mark.parametrize( + "model_name", + [ + "ContentPartAddedEvent", + "ContentPartDoneEvent", + "OutputTextAnnotationAddedEvent", + "OutputTextDoneEvent", + "RefusalDeltaEvent", + "RefusalDoneEvent", + "WebSearchCallCompletedEvent", + "FileSearchCallCompletedEvent", + "FunctionCallArgumentsDoneEvent", + ], +) +async def test_responses_stream_skips_events_litellm_knows_and_this_bridge_never_reads( + model_name, +): + """litellm models many events this bridge does not read at all. An unparseable + one costs nothing this bridge maps, so killing the turn over it throws away + an answer whose content is entirely intact.""" + events = [ + _litellm_validation_error(model_name), + _text_delta("Answer"), + _completed_event(), + ] + result = await copilotkit_stream(_ScriptedEventStream(events)) + assert result.choices[0].message.content == "Answer" + + +async def test_unparseable_event_is_reported_when_nothing_can_attribute_it(): + """With no event-type registry to attribute it to, a parse failure cannot be + shown harmless, so it is reported rather than assumed to be.""" + import ag_ui_crewai._capabilities as caps + + with _litellm_event_registry(None): + assert caps.responses_event_modelling().resolver_available is False + with pytest.raises(RuntimeError, match="failed to parse"): + await copilotkit_stream( + _ScriptedEventStream([_litellm_validation_error("ResponseCreatedEvent")]) + ) + + +# -- a litellm build that RAISES for a type it has no model for --------------- +# +# litellm 1.63-1.67 (inside this package's declared ``litellm>=1.60.2`` floor) +# raise ``ValueError("Unknown event type: ")`` from their event-type lookup, +# and on those builds the reasoning-summary deltas and the answer text delta this +# channel exists to read are exactly the unknown types. The channel cannot be read +# there at all, so it must report UNAVAILABLE and callers must degrade to +# chat-completions -- not fail once per turn on a channel declared as working. + +#: What litellm 1.63-1.67 have no model for, per the reproduction: the reasoning +#: deltas and the answer text delta. +_RAISING_BUILD_UNKNOWN_TYPES = ( + "response.output_text.delta", + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", +) + + +def _raising_event_registry(unknown_types): + """An event-type lookup shaped like litellm 1.63-1.67. + + Those builds have no catch-all: they RAISE whenever there is no dedicated + model for the type. Simulated by answering from the installed litellm and + raising both for ``unknown_types`` and for anything the installed build + serves with its catch-all, since a catch-all answer is exactly the case the + older builds turned into a ``ValueError``. + """ + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + + unknown = frozenset(unknown_types) + + def get_event_model_class(event_type): + model = OpenAIResponsesAPIConfig.get_event_model_class(event_type=event_type) + if event_type in unknown or model is GenericEvent: + raise ValueError(f"Unknown event type: {event_type}") + return model + + return get_event_model_class + + +@contextlib.contextmanager +def _litellm_event_registry(registry): + """Run the block against ``registry`` as litellm's event-type lookup. + + Availability is re-derived through the package's OWN probe + (``refresh_responses_channel_probe``) rather than by setting the flags this + test wants, so a rule that ignores what litellm can model fails here.""" + import ag_ui_crewai._capabilities as caps + + original_registry = caps._RESPONSES_EVENT_MODEL_RESOLVER + original_snapshot = caps.CAPABILITIES + original_responses_snapshot = responses_mod.CAPABILITIES + caps._RESPONSES_EVENT_MODEL_RESOLVER = registry + caps.refresh_responses_channel_probe() + caps.CAPABILITIES = caps._detect() + responses_mod.CAPABILITIES = caps.CAPABILITIES + try: + yield caps + finally: + caps._RESPONSES_EVENT_MODEL_RESOLVER = original_registry + caps.refresh_responses_channel_probe() + caps.CAPABILITIES = original_snapshot + responses_mod.CAPABILITIES = original_responses_snapshot + + +@requires_responses_types +def test_responses_channel_unavailable_when_litellm_cannot_model_what_it_reads(): + """The capability declaration matches reality on a raising build. + + Reporting the channel available there advertises a channel whose every + reasoning turn and every text turn dies with a RUN_ERROR, because those are + exactly the types such a build has no model for.""" + with _litellm_event_registry( + _raising_event_registry(_RAISING_BUILD_UNKNOWN_TYPES) + ) as caps: + modelling = caps.responses_event_modelling() + assert modelling.tolerates_unknown_types is False + assert set(modelling.unmodellable_event_types) == set( + _RAISING_BUILD_UNKNOWN_TYPES + ) + assert responses_mod.responses_channel_available() is False + assert caps.get_capabilities()["reasoning"]["responsesApiChannel"] is False + + # Restored: the installed litellm answers for unknown types, so the channel + # is available again and the teardown did not leave a stale probe behind. + assert responses_mod.responses_channel_available() is True + + +@requires_responses_types +async def test_copilotkit_responses_refuses_a_build_that_cannot_model_what_it_reads(): + """A caller that ignores the probe is refused BEFORE the stream opens, naming + the types, instead of failing mid-turn once the client has already been shown + part of an answer.""" + with _litellm_event_registry( + _raising_event_registry(_RAISING_BUILD_UNKNOWN_TYPES) + ): + with pytest.raises(RuntimeError, match="no model for") as caught: + await responses_mod.copilotkit_responses( + model="openai/gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + reasoning={"effort": "medium", "summary": "auto"}, + ) + assert "response.output_text.delta" in str(caught.value) + assert "acompletion" in str(caught.value) + + +@requires_responses_types +async def test_unmodellable_load_bearing_type_reports_the_build_not_a_corrupt_event(): + """A caller that opened the stream anyway is told what is actually wrong: this + litellm has no model for a type the bridge must read. Reporting it as an event + that "failed to parse" blames the provider for a build limitation and hides + the one action that fixes it.""" + with pytest.raises(RuntimeError, match="no model for") as caught: + await copilotkit_stream( + _ScriptedEventStream( + [ValueError("Unknown event type: response.output_text.delta")] + ) + ) + message = str(caught.value) + assert "responses_channel_available" in message + assert "chat-completions" in message + + +@requires_responses_types +async def test_unmodellable_reasoning_delta_costs_the_trace_not_the_run(): + """A reasoning delta this build cannot model leaves a gap in a trace; the + answer and the outcome are untouched, so the run must survive. (Such a build + reports the channel unavailable, so this is the belt-and-braces path for a + caller that streamed anyway.)""" + events = [ + ValueError("Unknown event type: response.reasoning_summary_text.delta"), + _text_delta("Answer"), + _completed_event(), + ] + result = await copilotkit_stream(_ScriptedEventStream(events)) + assert result.choices[0].message.content == "Answer" + + +@requires_stream_frames +@requires_responses_types +async def test_reasoning_demo_degrades_on_a_build_that_raises_for_unknown_types( + monkeypatch, +): + """End to end, the point of the probe: on a litellm that raises for the types + this channel reads, the demo streams over chat-completions and the run + finishes. Advertising the channel there sends OpenAI down the Responses path + and every turn ends in a RUN_ERROR.""" + with _litellm_event_registry( + _raising_event_registry(_RAISING_BUILD_UNKNOWN_TYPES) + ): + spy = _ChannelSpy(monkeypatch, channel_available=None) + payloads = await _drive_reasoning_demo("OpenAI") + + assert spy.responses_calls == [] + assert len(spy.chat_calls) == 1 + types = [p["type"] for p in payloads] + assert "RUN_ERROR" not in types, types + assert "RUN_FINISHED" in types, types + + +@requires_responses_types +async def test_responses_message_id_is_resolved_once_per_turn(): + """Every chunk of one answer carries the SAME message id even when neither + ``response.created`` nor the event itself supplies one. Resolving the id per + event splits one answer into a message per token on the client.""" + events = [ + GenericEvent(type="response.output_text.delta", output_index=0, delta="Ans"), + GenericEvent(type="response.output_text.delta", output_index=0, delta="wer"), + ] + flow = _FakeFlow() + ep.FastAPICrewFlowEventListener() + queue = await ep.create_queue(flow) + flow_context.set(flow) + try: + await copilotkit_stream(_FakeResponsesStream(events)) + await _settle_bus() + items = _drain(queue) + finally: + await ep.delete_queue(flow) + + text_events = [e for e in items if e.type == EventType.TEXT_MESSAGE_CHUNK] + assert len(text_events) == 2 + ids = {e.message_id for e in text_events} + assert len(ids) == 1, ids + assert next(iter(ids)) + + +# -------------------------------------------------------------------------- +# Responses-channel parity with the chat-completions driver. Each obligation +# below is one the chat driver honours and the Responses driver was written +# without, so they are asserted against the Responses driver directly. +# -------------------------------------------------------------------------- + +async def _drive_responses(stream, *, flow=None): + """Stream a Responses turn on the bus path; return (result, wire events).""" + if isinstance(stream, (list, tuple)): + stream = _FakeResponsesStream(stream) + flow = _FakeFlow() if flow is None else flow + ep.FastAPICrewFlowEventListener() + queue = await ep.create_queue(flow) + flow_context.set(flow) + try: + result = await copilotkit_stream(stream) + await _settle_bus() + items = _drain(queue) + finally: + await ep.delete_queue(flow) + return result, items + + +def _function_call_events(*, seeded_arguments="", deltas=(), terminal=None): + """A Responses tool-call turn: the added function-call item, then argument + deltas. ``seeded_arguments`` populates ``item.arguments`` the way a provider + that already knows the whole call would.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + OutputItemAddedEvent( + type="response.output_item.added", + output_index=0, + item={ + "id": "fc_1", + "call_id": "call_abc", + "type": "function_call", + "name": "change_background", + "arguments": seeded_arguments, + }, + ), + ] + events.extend( + FunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id="fc_1", output_index=0, delta=delta, + ) + for delta in deltas + ) + events.append( + terminal + if terminal is not None + else ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ) + ) + return events + + +# -- predicted-state suppression ------------------------------------------- + +async def test_responses_predicted_tool_streamed_suppresses_node_snapshot(): + """A predicted tool that streams over the RESPONSES channel must suppress the + node-exit STATE_SNAPSHOT, exactly as it does on chat-completions. Without the + ``_mark_predicted_tool_streamed`` call the flag is never set, the snapshot is + rebuilt from flow.state at node exit, and it clobbers the predicted state the + client is already rendering.""" + from ag_ui_crewai.sdk import ( + _record_predicted_tools, + consume_node_exit_snapshot_suppression, + ) + + flow = _FakeFlow() + _record_predicted_tools(flow, {"change_background"}) + await _drive_responses( + _FakeResponsesStream(_function_call_events(deltas=('{"b":"red"}',))), + flow=flow, + ) + assert consume_node_exit_snapshot_suppression(flow) is True + + +async def test_responses_unpredicted_tool_leaves_the_snapshot_alone(): + """Only a tool that was actually PREDICTED suppresses the snapshot: a node + that declared predict_state for another tool still emits its snapshot.""" + from ag_ui_crewai.sdk import ( + _record_predicted_tools, + consume_node_exit_snapshot_suppression, + ) + + flow = _FakeFlow() + _record_predicted_tools(flow, {"some_other_tool"}) + await _drive_responses( + _FakeResponsesStream(_function_call_events(deltas=('{"b":"red"}',))), + flow=flow, + ) + assert consume_node_exit_snapshot_suppression(flow) is False + + +# -- a truncated turn is not a clean one ------------------------------------ + +@pytest.mark.parametrize( + "reason,expected", + [ + ("max_output_tokens", "length"), + ("content_filter", "content_filter"), + (None, "length"), + ], +) +async def test_responses_incomplete_turn_is_distinguishable(reason, expected, caplog): + """``response.incomplete`` means the assistant message was CUT OFF. Reporting + it as ``finish_reason="stop"`` makes a truncated turn indistinguishable from a + finished one, and the reason is lost entirely; it must map onto the + chat-completions vocabulary and be logged.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + OutputTextDeltaEvent( + type="response.output_text.delta", + item_id="msg_1", output_index=0, content_index=0, delta="Half an ans", + ), + ResponseIncompleteEvent( + type="response.incomplete", + response=_responses_api_response( + "incomplete", incomplete_details=IncompleteDetails(reason=reason) + ), + ), + ] + with caplog.at_level(logging.WARNING, logger="ag_ui_crewai.sdk"): + result, _ = await _drive_responses(_FakeResponsesStream(events)) + + assert result.choices[0].finish_reason == expected + assert result.choices[0].message.content == "Half an ans" + assert any("incomplete" in r.getMessage() for r in caplog.records), caplog.text + + +async def test_responses_truncation_outranks_tool_calls_finish_reason(): + """A turn truncated MID tool call reports the truncation, not ``tool_calls``: + the arguments are partial, so telling the node the model cleanly asked for a + tool would be a lie.""" + events = _function_call_events( + deltas=('{"background":',), + terminal=ResponseIncompleteEvent( + type="response.incomplete", + response=_responses_api_response( + "incomplete", + incomplete_details=IncompleteDetails(reason="max_output_tokens"), + ), + ), + ) + result, _ = await _drive_responses(_FakeResponsesStream(events)) + assert result.choices[0].finish_reason == "length" + assert result.choices[0].message.tool_calls[0].function.arguments == '{"background":' + + +# -- seeded arguments are never double-counted ------------------------------ + +async def test_responses_seeded_arguments_are_not_double_counted(): + """A provider that populates ``item.arguments`` AND streams the same arguments + as deltas must not have them counted twice. Seeding the accumulator and then + appending every delta yields the arguments twice, on the wire and in the + returned ModelResponse.""" + result, items = await _drive_responses(_FakeResponsesStream(_function_call_events( + seeded_arguments='{"background":"red"}', + deltas=('{"background":', '"red"}'), + ))) + + assert result.choices[0].message.tool_calls[0].function.arguments == ( + '{"background":"red"}' + ) + chunks = [e for e in items if e.type == EventType.TOOL_CALL_CHUNK] + streamed = "".join(c.delta or "" for c in chunks) + assert streamed == '{"background":"red"}' + + +async def test_responses_seeded_arguments_stream_when_no_delta_follows(): + """A provider that delivers the whole call on the output item and streams no + delta still puts the arguments on the wire, so the streamed TOOL_CALL_ARGS + match the returned ModelResponse (the chat driver's invariant).""" + result, items = await _drive_responses(_FakeResponsesStream(_function_call_events( + seeded_arguments='{"background":"red"}', + ))) + + assert result.choices[0].message.tool_calls[0].function.arguments == ( + '{"background":"red"}' + ) + chunks = [e for e in items if e.type == EventType.TOOL_CALL_CHUNK] + assert "".join(c.delta or "" for c in chunks) == '{"background":"red"}' + assert {c.tool_call_id for c in chunks} == {"call_abc"} + + +# -- created_at is a float, ModelResponse.created is a strict int ------------ + +async def test_responses_fractional_created_at_does_not_void_the_turn(): + """``ResponsesAPIResponse.created_at`` is a float and ``ModelResponse.created`` + a strict int, so a FRACTIONAL timestamp raises a ValidationError, and it raises + only after the whole turn has already streamed to the client.""" + events = [ + ResponseCreatedEvent( + type="response.created", + response=_responses_api_response("in_progress", created_at=1700000000.75), + ), + OutputTextDeltaEvent( + type="response.output_text.delta", + item_id="msg_1", output_index=0, content_index=0, delta="Answer", + ), + ResponseCompletedEvent( + type="response.completed", + response=_responses_api_response(created_at=1700000000.75), + ), + ] + result, _ = await _drive_responses(_FakeResponsesStream(events)) + assert result.created == 1700000000 + assert result.choices[0].message.content == "Answer" + + +async def test_responses_non_numeric_created_at_keeps_the_default(): + """A ``created_at`` that is not a number at all is ignored rather than handed + to pydantic, so an odd provider payload cannot void the turn either.""" + events = [ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + GenericEvent(type="response.completed", response={"created_at": "not a time"}), + ] + result, _ = await _drive_responses(_FakeResponsesStream(events)) + assert result.created == 1700000000 + + +# -- the terminal break must not abandon the httpx response ------------------ + +class _AsyncClosable: + """Stands in for the httpx response litellm's iterator holds.""" + + def __init__(self): + self.closed = False + + async def aclose(self): + self.closed = True + + +class _SyncClosable: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +class _ReleasableResponsesStream(_FakeResponsesStream): + """Shaped like litellm's Responses iterator: NO ``aclose`` / ``close`` / + ``__aenter__`` / ``__aexit__`` of its own (verified on litellm 1.72), holding + the live response object on ``.response``.""" + + def __init__(self, events, *, response): + super().__init__(events) + self.response = response + + +async def test_responses_terminal_break_releases_the_underlying_response(): + """The driver BREAKS on the terminal event instead of draining the iterator, + which is the happy path for every run, so nothing ever asks litellm's iterator + to clean up and its httpx response is left open.""" + holder = _AsyncClosable() + stream = _ReleasableResponsesStream(_reasoning_then_text_events(), response=holder) + result, _ = await _drive_responses(stream) + assert result.choices[0].message.content == "Answer" + assert holder.closed is True + + +async def test_responses_release_falls_back_to_a_sync_close_on_failure(): + """The closer is FEATURE-DETECTED, not assumed: a response exposing only a + synchronous ``close`` is released too, and a stream that ends in a failure + still releases before the error propagates.""" + holder = _SyncClosable() + stream = _ReleasableResponsesStream( + [GenericEvent(type="error", code="server_error", message="upstream exploded")], + response=holder, + ) + flow_context.set(_FakeFlow()) + with pytest.raises(RuntimeError, match="upstream exploded"): + await copilotkit_stream(stream) + assert holder.closed is True + + +async def test_responses_release_tolerates_a_stream_with_no_closer(): + """Nothing to close, or a closer that raises, must never void a turn that has + already streamed.""" + + class _NoCloser: + pass + + class _Raising: + def close(self): + raise RuntimeError("already detached") + + for holder in (_NoCloser(), _Raising()): + stream = _ReleasableResponsesStream( + _reasoning_then_text_events(), response=holder + ) + result, _ = await _drive_responses(stream) + assert result.choices[0].message.content == "Answer" + + +# -- the demo forwards parallel_tool_calls on the Responses branch ----------- + +async def _drive_reasoning_demo_with_actions(model, actions): + return _decode_sse(await _collect(ep._run_flow_frame_stream( + flow_copy=AgenticChatReasoningFlow(), + encoder=EventEncoder(), + input_data=_run_input(), + inputs={ + "id": "t-1", + "model": model, + "messages": [], + "copilotkit": {"actions": actions}, + }, + timeout=30.0, + ))) + + +_DEMO_ACTIONS = [ + { + "type": "function", + "function": {"name": "change_background", "description": "", "parameters": {}}, + } +] + + +@requires_stream_frames +@requires_responses_types +async def test_reasoning_demo_disables_parallel_tool_calls_on_responses(monkeypatch): + """The Responses branch must pass ``parallel_tool_calls=False`` like the + chat-completions branch and every other demo, or the default OpenAI path can + emit parallel frontend tool calls.""" + spy = _ChannelSpy(monkeypatch) + await _drive_reasoning_demo_with_actions("OpenAI", _DEMO_ACTIONS) + assert spy.responses_calls, "OpenAI must stream over the Responses channel" + assert spy.responses_calls[0]["parallel_tool_calls"] is False + + +@requires_stream_frames +@requires_responses_types +async def test_reasoning_demo_omits_parallel_tool_calls_without_tools(monkeypatch): + """With no frontend actions there is nothing to serialise, so the flag is not + sent at all (mirrors the chat branch's ``False if tools else None``).""" + spy = _ChannelSpy(monkeypatch) + await _drive_reasoning_demo_with_actions("OpenAI", []) + assert spy.responses_calls + assert "parallel_tool_calls" not in spy.responses_calls[0] + + +async def test_responses_message_id_falls_back_to_the_output_item_id(): + """With ``response.created`` gone, the id comes from the event that actually + carries one. ``output_item.added`` has no ``item_id`` attribute at all, so a + lookup that reads only ``item_id`` mints a uuid and the id the stream gave us + never reaches the wire.""" + result, items = await _drive_responses([_function_call_added(arguments="{}")]) + + chunks = [e for e in items if e.type == EventType.TOOL_CALL_CHUNK] + assert chunks, [e.type for e in items] + assert {c.parent_message_id for c in chunks} == {"fc_1"} + assert result.id == "fc_1" + + +async def test_responses_answer_chunks_share_the_id_from_the_carrying_event(): + """EVERY chunk of one answer (the tool call and both text chunks) carries ONE + stable message id, and that id is the one the stream supplied rather than a + minted uuid. A uuid fallback is stable too, so this pins the SOURCE.""" + result, items = await _drive_responses([ + _function_call_added(), + FunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + item_id="fc_1", output_index=0, delta='{"background":"red"}', + ), + _text_delta("Do"), + _text_delta("ne"), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ]) + + text_events = [e for e in items if e.type == EventType.TEXT_MESSAGE_CHUNK] + assert len(text_events) == 2 + tool_parents = { + e.parent_message_id for e in items if e.type == EventType.TOOL_CALL_CHUNK + } + text_ids = {e.message_id for e in text_events} + assert text_ids == {"fc_1"}, text_ids + assert tool_parents == {"fc_1"}, tool_parents + assert result.id == "fc_1" + + +async def test_responses_message_id_prefers_the_response_created_id(): + """``response.created`` supplies the turn's id whenever it arrives: the driver + records ``response.id`` before any output item, so the per-event lookup only + ever fills in for a stream that skipped it.""" + result, items = await _drive_responses(_reasoning_then_text_events()) + + text_ids = {e.message_id for e in items if e.type == EventType.TEXT_MESSAGE_CHUNK} + assert text_ids == {"resp_1"}, text_ids + assert result.id == "resp_1" + + +async def test_responses_reasoning_text_after_close_opens_a_second_block(): + """The Responses driver keeps the SAME semantics as chat-completions, since the + channel is shared: a reasoning summary delta arriving after the answer text + closed the first block opens a second complete one.""" + _, items = await _drive_responses([ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _summary_delta("first"), + _text_delta("Answer"), + _summary_delta("late"), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ]) + + types = [e.type for e in items] + assert types.count(EventType.REASONING_START) == 2, types + assert types.count(EventType.REASONING_MESSAGE_START) == 2, types + assert types.count(EventType.REASONING_MESSAGE_END) == 2, types + assert types.count(EventType.REASONING_END) == 2, types + content_by_id = {} + for event in items: + if event.type == EventType.REASONING_MESSAGE_CONTENT: + content_by_id.setdefault(event.message_id, []).append(event.delta) + assert sorted(content_by_id.values()) == [["first"], ["late"]], content_by_id + + +async def test_responses_encrypted_only_reasoning_after_close_does_not_reopen(): + """An encrypted reasoning blob whose ``output_item.done`` lands AFTER the answer + text must not reopen the closed reasoning channel: it carries no text, so + reopening mints a SECOND, EMPTY reasoning message inside one turn.""" + _, items = await _drive_responses([ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _summary_delta("Weighing the options."), + _text_delta("Answer"), + _reasoning_item_done(), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ]) + + types = [e.type for e in items] + assert types.count(EventType.REASONING_START) == 1, types + assert types.count(EventType.REASONING_MESSAGE_START) == 1, types + assert types.count(EventType.REASONING_MESSAGE_END) == 1, types + assert types.count(EventType.REASONING_END) == 1, types + # The late blob is dropped rather than reopening the channel. Real OpenAI + # orders the reasoning item BEFORE the message item, where it still surfaces + # (asserted by the companion test below). + assert EventType.REASONING_ENCRYPTED_VALUE not in types, types + + +async def test_responses_encrypted_reasoning_before_text_still_surfaces(): + """The legitimate ordering is untouched: a reasoning item finishing BEFORE the + answer text surfaces its encrypted blob on the one open reasoning message.""" + _, items = await _drive_responses([ + ResponseCreatedEvent( + type="response.created", response=_responses_api_response("in_progress") + ), + _summary_delta("Weighing the options."), + _reasoning_item_done(), + _text_delta("Answer"), + ResponseCompletedEvent( + type="response.completed", response=_responses_api_response() + ), + ]) + + types = [e.type for e in items] + assert types.count(EventType.REASONING_START) == 1, types + assert types.count(EventType.REASONING_END) == 1, types + encrypted = [e for e in items if e.type == EventType.REASONING_ENCRYPTED_VALUE] + assert len(encrypted) == 1, types + assert encrypted[0].encrypted_value == "BLOB" + start = next(e for e in items if e.type == EventType.REASONING_START) + assert encrypted[0].entity_id == start.message_id diff --git a/integrations/crew-ai/python/uv.lock b/integrations/crew-ai/python/uv.lock index f8e0cf5847..a246f4e3e8 100644 --- a/integrations/crew-ai/python/uv.lock +++ b/integrations/crew-ai/python/uv.lock @@ -594,7 +594,7 @@ wheels = [ [[package]] name = "crewai" -version = "1.15.7" +version = "1.15.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -629,14 +629,14 @@ dependencies = [ { name = "tomli" }, { name = "tomli-w" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/aa/3eaf8322c480650ab1bc8dda48fedbdf525d4bddb8ce3aa7ed74ca30313e/crewai-1.15.7.tar.gz", hash = "sha256:6c4c322225aa2e50ebc1c608d57b4fdf62263fb0a1343d7d3f04933fcf5a4daa", size = 7819040, upload-time = "2026-07-26T18:20:44.06Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/73/be2114196484f362dd31c698088a44770c6a18d6c3526b0eb3426e7aa35c/crewai-1.15.11.tar.gz", hash = "sha256:bd2476d7aaa9f52265e68f5b716be740d8cc15e4a64effda9edefbc6a129944b", size = 7859622, upload-time = "2026-08-05T06:30:42.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/69/d375fe2002c539068749f9f966b6ebd943aaf1df133b2e7f33c1ae1f76da/crewai-1.15.7-py3-none-any.whl", hash = "sha256:7357a6035458f7cc8ed168e9d6a621d4e404c25ce9252dbdc4372140427131ef", size = 1090245, upload-time = "2026-07-26T18:20:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9c/29a003f4e1653f65d2e72fc24f6f995ca95de128dab1431e69e71b932d8b/crewai-1.15.11-py3-none-any.whl", hash = "sha256:569cad1a9b333401895bc9a9cd3cdd0448c8ab93be81746e94bff21e81795563", size = 1109258, upload-time = "2026-08-05T06:30:40.712Z" }, ] [[package]] name = "crewai-cli" -version = "1.15.7" +version = "1.15.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -656,14 +656,14 @@ dependencies = [ { name = "tomli-w" }, { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/41/fcf6eb5502d3ff2d9ce2e250e6c9af2170e18b431c2d598411e8b61352a5/crewai_cli-1.15.7.tar.gz", hash = "sha256:cf50dc856694d85d1b2a4609c28918454019b2f5f01ac596a6b293bc6ab19465", size = 220301, upload-time = "2026-07-26T18:20:46.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/ea/0ba7840bfe72183c8631da3d980ece700d62eca611881546f466d48662e7/crewai_cli-1.15.11.tar.gz", hash = "sha256:33a991b303628dd9c36ad8585322538b35fccf80d3c5e92d11d660246da62f2b", size = 223960, upload-time = "2026-08-05T06:30:47.147Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/c2/df15a65b96e9afaac1ae88fa28d8755743d85414cd78d9173a57c5e0beae/crewai_cli-1.15.7-py3-none-any.whl", hash = "sha256:ec80a4cb892fa2fdf826acadfe031125e5f2fb545df6016b031c65dfd6744475", size = 189838, upload-time = "2026-07-26T18:20:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ce/61e84922b83e2540228c0deca787af77ef0e76d5d5f7cab50da644110945/crewai_cli-1.15.11-py3-none-any.whl", hash = "sha256:55fc95d416f42e06f079b50caa2c90aa166d9ede7774d69c12026bd10e8d2023", size = 193577, upload-time = "2026-08-05T06:30:45.391Z" }, ] [[package]] name = "crewai-core" -version = "1.15.7" +version = "1.15.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -679,14 +679,14 @@ dependencies = [ { name = "rich" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/776dd2ebd3709349ec5ecbaf7a0c5bab2e994da1058607992037aeb894ef/crewai_core-1.15.7.tar.gz", hash = "sha256:9eba945043b060455899d80272c7ca6f7a66f0ad25d30be85ba6e7e032434ef6", size = 23435, upload-time = "2026-07-26T18:20:48.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ec/3978c3378b382fbb88a741ec834ed8aed2ea9a0497fe5d128bcc2f4df16d/crewai_core-1.15.11.tar.gz", hash = "sha256:f0963796b5d031154fa332a1a257b449b4bd83bd15d67d44f6a99ecf4d54d6fc", size = 26770, upload-time = "2026-08-05T06:30:49.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/47/c20955de87fd7302d854c48ad9d0c80d047687aa2f07ea1fe038cca34522/crewai_core-1.15.7-py3-none-any.whl", hash = "sha256:e01b80159121ba074edb3f7e5ba55a42e512c7247018d575b4730c8a00dc00a4", size = 30976, upload-time = "2026-07-26T18:20:47.462Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c0/4d59252544a726c43afcbe56e898e0635fbf4c130a24b79a5b8345b954c6/crewai_core-1.15.11-py3-none-any.whl", hash = "sha256:2181ca99158edfd6ed42a92165e18aff45d6827e08f578830870b125baa41e13", size = 34403, upload-time = "2026-08-05T06:30:48.245Z" }, ] [[package]] name = "crewai-tools" -version = "1.15.7" +version = "1.15.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -698,9 +698,9 @@ dependencies = [ { name = "tiktoken" }, { name = "youtube-transcript-api" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/51/92923dd63bc53ad3263d6fc7d52aa38a9e6947113a537a86895673c8159e/crewai_tools-1.15.7.tar.gz", hash = "sha256:d323a7a28403de9ae923becb205decf4f65a0092cdcc59c758b483f3c479f23a", size = 898345, upload-time = "2026-07-26T18:20:52.943Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/8b/c7ed0990dbe8c082bb499654737a5e127e83c5da6f9e74588af081bdb747/crewai_tools-1.15.11.tar.gz", hash = "sha256:eefa1a2e4d93b7fdc5ce896c38f9b7d19f4b2c859000217643452b976fdb2981", size = 922505, upload-time = "2026-08-05T06:30:54.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/ce/114a6ffb6b9003b0843f71005fe9e19ef9dfba77b07bf7a3f3ab84f6d9aa/crewai_tools-1.15.7-py3-none-any.whl", hash = "sha256:fd0db3d1e93492ccb864e719adb5e2524afd833232d9c81425aa0bc9a72eb5c1", size = 811463, upload-time = "2026-07-26T18:20:51.429Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/3a4bed3c5bd4bac9c8e469053f8d03046c849049e26d6fcc0df5925bfd69/crewai_tools-1.15.11-py3-none-any.whl", hash = "sha256:217199d76d458e5fe9c9a4d68044338d41247d097bf6a053a1ba9bb722730cca", size = 826093, upload-time = "2026-08-05T06:30:52.728Z" }, ] [[package]] diff --git a/package.json b/package.json index 35f68b3e69..c56f5a5681 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,9 @@ }, "version": "0.0.1", "pnpm": { + "patchedDependencies": { + "@copilotkit/aimock@1.37.4": "patches/@copilotkit__aimock@1.37.4.patch" + }, "overrides": { "langium": "3.2.0", "@copilotkit/runtime>@ag-ui/a2ui-middleware": "0.0.8", @@ -52,4 +55,4 @@ "hono": "4.12.27" } } -} \ No newline at end of file +} diff --git a/patches/@copilotkit__aimock@1.37.4.patch b/patches/@copilotkit__aimock@1.37.4.patch new file mode 100644 index 0000000000..b68af0e44d --- /dev/null +++ b/patches/@copilotkit__aimock@1.37.4.patch @@ -0,0 +1,67 @@ +diff --git a/dist/responses.js b/dist/responses.js +index 0a9817d6c0a328769077e2baa61760c6cc9a2bca..140342048f16b23ae649bf10eba24017aa5b8ca7 100644 +--- a/dist/responses.js ++++ b/dist/responses.js +@@ -174,5 +174,20 @@ function buildTextStreamEvents(content, model, chunkSize, reasoning, webSearches + model: overrides?.model ?? model, + status: responsesStatus(overrides?.finishReason, "completed"), ++ error: null, ++ incomplete_details: null, ++ instructions: null, ++ metadata: {}, ++ parallel_tool_calls: true, ++ temperature: null, ++ tool_choice: "auto", ++ tools: [], ++ top_p: null, ++ max_output_tokens: null, ++ previous_response_id: null, ++ reasoning: null, ++ text: null, ++ truncation: "disabled", ++ user: null, + output: [...prefixOutputItems, msgItem], + usage: responsesUsage(overrides) + } +@@ -239,5 +254,20 @@ function buildToolCallStreamEvents(toolCalls, model, chunkSize, reasoning, webSe + model: overrides?.model ?? model, + status: responsesStatus(overrides?.finishReason, "completed"), ++ error: null, ++ incomplete_details: null, ++ instructions: null, ++ metadata: {}, ++ parallel_tool_calls: true, ++ temperature: null, ++ tool_choice: "auto", ++ tools: [], ++ top_p: null, ++ max_output_tokens: null, ++ previous_response_id: null, ++ reasoning: null, ++ text: null, ++ truncation: "disabled", ++ user: null, + output: [...prefixOutputItems, ...fcOutputItems], + usage: responsesUsage(overrides) + } +@@ -635,5 +665,20 @@ function buildContentWithToolCallsStreamEvents(content, toolCalls, model, chunkS + model: overrides?.model ?? model, + status: responsesStatus(overrides?.finishReason, "completed"), ++ error: null, ++ incomplete_details: null, ++ instructions: null, ++ metadata: {}, ++ parallel_tool_calls: true, ++ temperature: null, ++ tool_choice: "auto", ++ tools: [], ++ top_p: null, ++ max_output_tokens: null, ++ previous_response_id: null, ++ reasoning: null, ++ text: null, ++ truncation: "disabled", ++ user: null, + output: [...prefixOutputItems, ...orderedOutputItems], + usage: responsesUsage(overrides) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80f75fadb2..5ef2825435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,11 @@ overrides: pnpmfileChecksum: sha256-WWcJIQNfheAErT+vJ0Q+lvKnko5xLX6V4NCBzEPCGH0= +patchedDependencies: + '@copilotkit/aimock@1.37.4': + hash: be80b7fd45d0990337155d9ca98d45e976b108c682e6063c4c7055962e294a76 + path: patches/@copilotkit__aimock@1.37.4.patch + importers: .: @@ -334,7 +339,7 @@ importers: devDependencies: '@copilotkit/aimock': specifier: 1.37.4 - version: 1.37.4(jest@29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)) + version: 1.37.4(patch_hash=be80b7fd45d0990337155d9ca98d45e976b108c682e6063c4c7055962e294a76)(jest@29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)) '@shadcn/ui': specifier: ^0.0.4 version: 0.0.4 @@ -1441,7 +1446,7 @@ importers: version: link:../../../typescript/packages/proto '@copilotkit/aimock': specifier: 1.37.4 - version: 1.37.4(jest@29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)) + version: 1.37.4(patch_hash=be80b7fd45d0990337155d9ca98d45e976b108c682e6063c4c7055962e294a76)(jest@29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)) express: specifier: ^4.19.0 version: 4.21.2 @@ -15475,7 +15480,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@copilotkit/aimock@1.37.4(jest@29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4))': + '@copilotkit/aimock@1.37.4(patch_hash=be80b7fd45d0990337155d9ca98d45e976b108c682e6063c4c7055962e294a76)(jest@29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4))': optionalDependencies: jest: 29.7.0(@types/node@20.19.21)(babel-plugin-macros@3.1.0) vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)