From dd1e62abaa60308d7e9ee34ecfdbac96585df686 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 16 Feb 2026 20:48:55 -0800 Subject: [PATCH 1/5] fix(ag-ui): preserve feedback metadata in AG-UI streams --- .changeset/wise-pans-arrive.md | 9 ++++ packages/ag-ui/src/voltagent-agent.ts | 69 ++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 .changeset/wise-pans-arrive.md diff --git a/.changeset/wise-pans-arrive.md b/.changeset/wise-pans-arrive.md new file mode 100644 index 000000000..01c258d2b --- /dev/null +++ b/.changeset/wise-pans-arrive.md @@ -0,0 +1,9 @@ +--- +"@voltagent/ag-ui": patch +--- + +fix: preserve assistant feedback metadata across AG-UI streams + +- Map VoltAgent `message-metadata` stream chunks into AG-UI-compatible events so feedback metadata reaches chat clients. +- Carry metadata through a dedicated internal tool-result marker that can be correlated to the assistant message id. +- Prevent those internal metadata marker messages from being sent back to the model on subsequent turns. diff --git a/packages/ag-ui/src/voltagent-agent.ts b/packages/ag-ui/src/voltagent-agent.ts index feb398c78..dad4cffe4 100644 --- a/packages/ag-ui/src/voltagent-agent.ts +++ b/packages/ag-ui/src/voltagent-agent.ts @@ -274,18 +274,28 @@ type VoltUIMessage = { parts?: VoltUIPart[]; }; +const VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX = "__voltagent_message_metadata__:"; + +function isMetadataCarrierToolCallId(toolCallId: string | undefined): boolean { + return ( + typeof toolCallId === "string" && toolCallId.startsWith(VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX) + ); +} + function convertAGUIMessagesToVoltMessages(messages: Message[]): VoltUIMessage[] { const toolNameById = new Map(); + const convertedMessages: VoltUIMessage[] = []; - return messages.map((msg) => { + for (const msg of messages) { const messageId = msg.id || generateId(); if (isUserMessage(msg)) { - return { + convertedMessages.push({ id: messageId, role: "user", content: extractTextContent(msg.content), - }; + }); + continue; } if (isAssistantMessage(msg)) { @@ -306,24 +316,29 @@ function convertAGUIMessagesToVoltMessages(messages: Message[]): VoltUIMessage[] }); } - return { + convertedMessages.push({ id: messageId, role: "assistant", parts, - }; + }); + continue; } if (isSystemMessage(msg) || isDeveloperMessage(msg)) { - return { + convertedMessages.push({ id: messageId, role: "system", content: msg.content, - }; + }); + continue; } if (isToolMessage(msg)) { + if (isMetadataCarrierToolCallId(msg.toolCallId)) { + continue; + } const toolName = msg.toolCallId ? toolNameById.get(msg.toolCallId) : undefined; - return { + convertedMessages.push({ id: messageId, role: "tool", parts: [ @@ -334,16 +349,19 @@ function convertAGUIMessagesToVoltMessages(messages: Message[]): VoltUIMessage[] output: safelyParseJson(msg.content), }, ], - }; + }); + continue; } // activity or any other custom role -> fold into assistant text - return { + convertedMessages.push({ id: messageId, role: "assistant", parts: [{ type: "text", text: safeStringify((msg as any).content ?? "") }], - }; - }); + }); + } + + return convertedMessages; } function extractTextContent(content: UserMessage["content"] | AssistantMessage["content"]): string { @@ -389,6 +407,33 @@ function convertVoltStreamPartToEvents( fallbackMessageId: string, ): StreamConversionResult[] | null { const payload = (part as { payload?: Record }).payload ?? part; + const partType = (part as { type: string }).type; + + if (partType === "message-metadata") { + const messageId = + (part as { messageId?: string }).messageId ?? + (payload as { messageId?: string }).messageId ?? + fallbackMessageId; + const messageMetadata = + (payload as { messageMetadata?: unknown }).messageMetadata ?? + (payload as { metadata?: unknown }).metadata; + + if (!messageMetadata || typeof messageMetadata !== "object") { + return null; + } + + const resultEvent: ToolCallResultEvent = { + type: EventType.TOOL_CALL_RESULT, + toolCallId: `${VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX}${messageId || generateId()}`, + content: safeStringify({ + messageId: messageId || undefined, + metadata: messageMetadata, + }), + messageId: generateId(), + role: "tool", + }; + return [resultEvent]; + } switch (part.type) { case "text-start": { From 81c8de56fbe63dab653d232308bb3830ddf189af Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 16 Feb 2026 20:55:34 -0800 Subject: [PATCH 2/5] refactor(ag-ui): emit custom event for message metadata --- .changeset/wise-pans-arrive.md | 6 +++--- packages/ag-ui/src/voltagent-agent.ts | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.changeset/wise-pans-arrive.md b/.changeset/wise-pans-arrive.md index 01c258d2b..6d149c1f1 100644 --- a/.changeset/wise-pans-arrive.md +++ b/.changeset/wise-pans-arrive.md @@ -4,6 +4,6 @@ fix: preserve assistant feedback metadata across AG-UI streams -- Map VoltAgent `message-metadata` stream chunks into AG-UI-compatible events so feedback metadata reaches chat clients. -- Carry metadata through a dedicated internal tool-result marker that can be correlated to the assistant message id. -- Prevent those internal metadata marker messages from being sent back to the model on subsequent turns. +- Map VoltAgent `message-metadata` stream chunks to AG-UI `CUSTOM` events, which are the protocol-native channel for application-specific metadata. +- Keep emitting a legacy internal tool-result marker for backward compatibility with existing clients. +- Prevent internal metadata marker tool messages from being sent back to the model on subsequent turns. diff --git a/packages/ag-ui/src/voltagent-agent.ts b/packages/ag-ui/src/voltagent-agent.ts index dad4cffe4..721d05f2d 100644 --- a/packages/ag-ui/src/voltagent-agent.ts +++ b/packages/ag-ui/src/voltagent-agent.ts @@ -2,6 +2,7 @@ import { AbstractAgent } from "@ag-ui/client"; import type { AssistantMessage, BaseEvent, + CustomEvent, DeveloperMessage, Message, RunAgentInput, @@ -275,6 +276,7 @@ type VoltUIMessage = { }; const VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX = "__voltagent_message_metadata__:"; +const VOLTAGENT_MESSAGE_METADATA_EVENT_NAME = "voltagent.message_metadata"; function isMetadataCarrierToolCallId(toolCallId: string | undefined): boolean { return ( @@ -397,6 +399,7 @@ type StreamConversionResult = | TextMessageStartEvent | TextMessageChunkEvent | TextMessageEndEvent + | CustomEvent | ToolCallStartEvent | ToolCallEndEvent | ToolCallArgsEvent @@ -422,6 +425,17 @@ function convertVoltStreamPartToEvents( return null; } + // Best-practice carrier for app-specific metadata on AG-UI streams. + const customEvent: CustomEvent = { + type: EventType.CUSTOM, + name: VOLTAGENT_MESSAGE_METADATA_EVENT_NAME, + value: { + messageId: messageId || undefined, + metadata: messageMetadata, + }, + }; + + // Backward compatibility for existing clients that consume metadata from tool messages. const resultEvent: ToolCallResultEvent = { type: EventType.TOOL_CALL_RESULT, toolCallId: `${VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX}${messageId || generateId()}`, @@ -432,7 +446,7 @@ function convertVoltStreamPartToEvents( messageId: generateId(), role: "tool", }; - return [resultEvent]; + return [customEvent, resultEvent]; } switch (part.type) { From 588139cb8a7f32e382185c9a1eb9fedfa66ecfaa Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Tue, 17 Feb 2026 07:24:26 -0800 Subject: [PATCH 3/5] refactor(ag-ui): remove legacy metadata tool-result emit --- .changeset/wise-pans-arrive.md | 4 ++-- packages/ag-ui/src/voltagent-agent.ts | 13 +------------ 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/.changeset/wise-pans-arrive.md b/.changeset/wise-pans-arrive.md index 6d149c1f1..32f1be25c 100644 --- a/.changeset/wise-pans-arrive.md +++ b/.changeset/wise-pans-arrive.md @@ -5,5 +5,5 @@ fix: preserve assistant feedback metadata across AG-UI streams - Map VoltAgent `message-metadata` stream chunks to AG-UI `CUSTOM` events, which are the protocol-native channel for application-specific metadata. -- Keep emitting a legacy internal tool-result marker for backward compatibility with existing clients. -- Prevent internal metadata marker tool messages from being sent back to the model on subsequent turns. +- Stop emitting legacy internal tool-result metadata markers from the adapter. +- Continue filtering legacy metadata marker tool messages from model input for compatibility with existing sessions. diff --git a/packages/ag-ui/src/voltagent-agent.ts b/packages/ag-ui/src/voltagent-agent.ts index 721d05f2d..a5ba3cb0f 100644 --- a/packages/ag-ui/src/voltagent-agent.ts +++ b/packages/ag-ui/src/voltagent-agent.ts @@ -435,18 +435,7 @@ function convertVoltStreamPartToEvents( }, }; - // Backward compatibility for existing clients that consume metadata from tool messages. - const resultEvent: ToolCallResultEvent = { - type: EventType.TOOL_CALL_RESULT, - toolCallId: `${VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX}${messageId || generateId()}`, - content: safeStringify({ - messageId: messageId || undefined, - metadata: messageMetadata, - }), - messageId: generateId(), - role: "tool", - }; - return [customEvent, resultEvent]; + return [customEvent]; } switch (part.type) { From 60cf126fe02b437b1f060442dddc1d1df90efe22 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Tue, 17 Feb 2026 08:24:25 -0800 Subject: [PATCH 4/5] refactor(ag-ui): remove legacy metadata marker filter --- .changeset/wise-pans-arrive.md | 2 +- packages/ag-ui/src/voltagent-agent.ts | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/.changeset/wise-pans-arrive.md b/.changeset/wise-pans-arrive.md index 32f1be25c..97b8527e0 100644 --- a/.changeset/wise-pans-arrive.md +++ b/.changeset/wise-pans-arrive.md @@ -6,4 +6,4 @@ fix: preserve assistant feedback metadata across AG-UI streams - Map VoltAgent `message-metadata` stream chunks to AG-UI `CUSTOM` events, which are the protocol-native channel for application-specific metadata. - Stop emitting legacy internal tool-result metadata markers from the adapter. -- Continue filtering legacy metadata marker tool messages from model input for compatibility with existing sessions. +- Remove the legacy metadata marker filter from model-input message conversion. diff --git a/packages/ag-ui/src/voltagent-agent.ts b/packages/ag-ui/src/voltagent-agent.ts index a5ba3cb0f..7a5e643b6 100644 --- a/packages/ag-ui/src/voltagent-agent.ts +++ b/packages/ag-ui/src/voltagent-agent.ts @@ -275,15 +275,8 @@ type VoltUIMessage = { parts?: VoltUIPart[]; }; -const VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX = "__voltagent_message_metadata__:"; const VOLTAGENT_MESSAGE_METADATA_EVENT_NAME = "voltagent.message_metadata"; -function isMetadataCarrierToolCallId(toolCallId: string | undefined): boolean { - return ( - typeof toolCallId === "string" && toolCallId.startsWith(VOLTAGENT_METADATA_TOOL_CALL_ID_PREFIX) - ); -} - function convertAGUIMessagesToVoltMessages(messages: Message[]): VoltUIMessage[] { const toolNameById = new Map(); const convertedMessages: VoltUIMessage[] = []; @@ -336,9 +329,6 @@ function convertAGUIMessagesToVoltMessages(messages: Message[]): VoltUIMessage[] } if (isToolMessage(msg)) { - if (isMetadataCarrierToolCallId(msg.toolCallId)) { - continue; - } const toolName = msg.toolCallId ? toolNameById.get(msg.toolCallId) : undefined; convertedMessages.push({ id: messageId, From a5e7c7b3af495502619f54b7ed718472029d7f99 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Tue, 17 Feb 2026 09:22:56 -0800 Subject: [PATCH 5/5] chore: update docs --- website/docs/ui/copilotkit.md | 157 ++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/website/docs/ui/copilotkit.md b/website/docs/ui/copilotkit.md index 112c14611..ac533cc93 100644 --- a/website/docs/ui/copilotkit.md +++ b/website/docs/ui/copilotkit.md @@ -197,6 +197,163 @@ export default function App() { } ``` +## CopilotKit feedback -> VoltOps feedback (recommended) + +If you want CopilotKit thumbs up/down to persist as VoltOps trace feedback, wire them explicitly. + +### 1. Enable feedback on the agent + +```ts title="examples/with-copilotkit/server/src/index.ts" +const mathAgent = new Agent({ + name: "MathAgent", + instructions: "You are a concise math tutor.", + model: "openai/gpt-4o-mini", + feedback: { + key: "satisfaction", + feedbackConfig: { + type: "categorical", + categories: [ + { value: 1, label: "Satisfied" }, + { value: 0, label: "Unsatisfied" }, + ], + }, + }, +}); +``` + +### 2. Capture message metadata + submit thumbs feedback + +`@voltagent/ag-ui` emits message metadata as AG-UI `CUSTOM` events with name `voltagent.message_metadata`. +Use that payload to map each assistant `messageId` to its VoltOps feedback URL, then call the URL in `onThumbsUp` / `onThumbsDown`. + +```tsx title="examples/with-copilotkit/client/src/App.tsx" +import { useEffect, useMemo, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { CopilotKit, useCopilotChatInternal } from "@copilotkit/react-core"; +import { CopilotChat } from "@copilotkit/react-ui"; +import type { Message } from "@copilotkit/shared"; +import { safeStringify } from "@voltagent/internal"; +import "@copilotkit/react-ui/styles.css"; + +type VoltFeedbackMetadata = { + url?: string; + provided?: boolean; + providedAt?: string; + feedbackId?: string; +}; + +const VOLTAGENT_MESSAGE_METADATA_EVENT_NAME = "voltagent.message_metadata"; + +function useVoltFeedbackMap() { + const { agent } = useCopilotChatInternal(); + const [feedbackByMessageId, setFeedbackByMessageId] = useState< + Record + >({}); + + useEffect(() => { + if (!agent) return; + + const subscription = agent.subscribe({ + onCustomEvent: ({ event }) => { + if (event.name !== VOLTAGENT_MESSAGE_METADATA_EVENT_NAME) return; + + const payload = event.value as + | { + messageId?: string; + metadata?: { + feedback?: VoltFeedbackMetadata; + }; + } + | undefined; + + const messageId = payload?.messageId; + const feedback = payload?.metadata?.feedback; + + if (!messageId || !feedback?.url) return; + setFeedbackByMessageId((prev) => ({ ...prev, [messageId]: feedback })); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }, [agent]); + + return { feedbackByMessageId, setFeedbackByMessageId }; +} + +function isProvided(feedback?: VoltFeedbackMetadata): boolean { + return Boolean(feedback?.provided || feedback?.providedAt || feedback?.feedbackId); +} + +async function submitVoltFeedback(input: { + message: Message; + type: "thumbsUp" | "thumbsDown"; + feedbackByMessageId: Record; + setFeedbackByMessageId: Dispatch>>; +}) { + const feedback = input.feedbackByMessageId[input.message.id]; + if (!feedback?.url || isProvided(feedback)) return; + + const score = input.type === "thumbsUp" ? 1 : 0; + + const response = await fetch(feedback.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: safeStringify({ + score, + feedback_source_type: "app", + }), + }); + + if (!response.ok) return; + + const data = (await response.json()) as { id?: string }; + setFeedbackByMessageId((prev) => ({ + ...prev, + [input.message.id]: { + ...feedback, + provided: true, + feedbackId: data.id, + }, + })); +} + +function ChatWithFeedback() { + const runtimeUrl = useMemo( + () => import.meta.env.VITE_RUNTIME_URL || "http://localhost:3141/copilotkit", + [] + ); + const { feedbackByMessageId, setFeedbackByMessageId } = useVoltFeedbackMap(); + + return ( + + + void submitVoltFeedback({ + message, + type: "thumbsUp", + feedbackByMessageId, + setFeedbackByMessageId, + }) + } + onThumbsDown={(message) => + void submitVoltFeedback({ + message, + type: "thumbsDown", + feedbackByMessageId, + setFeedbackByMessageId, + }) + } + /> + + ); +} +``` + +For the complete lifecycle (including persisting `provided` state in memory with `agent.markFeedbackProvided(...)`), see [Feedback](/observability-docs/feedback). + ## Tips - CopilotKit DevTools lets you switch agents when multiple are exposed (e.g., `MathAgent` vs `StoryAgent`).