diff --git a/.changeset/friendly-feedback-state.md b/.changeset/friendly-feedback-state.md deleted file mode 100644 index 03481f0ca..000000000 --- a/.changeset/friendly-feedback-state.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@voltagent/core": patch ---- - -fix: preserve request correlation with root workflow and agent spans - -- Keep workflow and agent root spans as real roots when no explicit parent is provided. -- Add OpenTelemetry span links to the active ambient span so request-level correlation is preserved without reintroducing pending/running root-state issues. -- Keep existing workflow resume links and combine them with the new ambient link when both are available. diff --git a/.changeset/friendly-feedback-world.md b/.changeset/friendly-feedback-world.md new file mode 100644 index 000000000..8bc9b6598 --- /dev/null +++ b/.changeset/friendly-feedback-world.md @@ -0,0 +1,27 @@ +--- +"@voltagent/core": patch +"@voltagent/server-core": patch +--- + +feat: add persisted feedback-provided markers for message feedback metadata + +- `AgentFeedbackMetadata` now supports `provided`, `providedAt`, and `feedbackId`. +- Added `Agent.isFeedbackProvided(...)` and `Agent.isMessageFeedbackProvided(...)` helpers. +- Added `agent.markFeedbackProvided(...)` to persist a feedback-submitted marker on a stored message so feedback UI can stay hidden after memory reloads. +- Added `result.feedback.markFeedbackProvided(...)` and `result.feedback.isProvided()` helper methods for SDK usage. +- Updated server response schema to include the new feedback metadata fields. + +```ts +const result = await agent.generateText("How was this answer?", { + userId: "user-1", + conversationId: "conv-1", + feedback: true, +}); + +if (result.feedback && !result.feedback.isProvided()) { + // call after your feedback ingestion succeeds + await result.feedback.markFeedbackProvided({ + feedbackId: "fb_123", // optional + }); +} +``` diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index a8568ef96..38f234bde 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -128,6 +128,13 @@ import { TOOL_ROUTING_SEARCHED_TOOLS_CONTEXT_KEY, } from "./context-keys"; import { ConversationBuffer } from "./conversation-buffer"; +import { + createFeedbackHandle as createFeedbackHandleHelper, + findFeedbackMessageId as findFeedbackMessageIdHelper, + isFeedbackProvided as isFeedbackProvidedHelper, + isMessageFeedbackProvided as isMessageFeedbackProvidedHelper, + markFeedbackProvided as markFeedbackProvidedHelper, +} from "./feedback"; import { type NormalizedInputGuardrail, type NormalizedOutputGuardrail, @@ -161,10 +168,12 @@ import type { VoltAgentTextStreamPart } from "./subagent/types"; import type { AgentEvalConfig, AgentEvalOperationType, + AgentFeedbackHandle, AgentFeedbackMetadata, AgentFeedbackOptions, AgentFullState, AgentGuardrailState, + AgentMarkFeedbackProvidedInput, AgentModelConfig, AgentModelValue, AgentOptions, @@ -429,7 +438,7 @@ export type StreamTextResultWithContext< // Additional context field context: Map; // Feedback metadata for the trace, if enabled - feedback?: AgentFeedbackMetadata | null; + feedback?: AgentFeedbackHandle | null; } & Record; /** @@ -471,7 +480,7 @@ export interface GenerateTextResultWithContext< experimental_output: OutputValue; output: OutputValue; // Feedback metadata for the trace, if enabled - feedback?: AgentFeedbackMetadata | null; + feedback?: AgentFeedbackHandle | null; } type LLMOperation = @@ -1196,12 +1205,27 @@ export class Agent { await persistQueue.flush(buffer, oc); } + const feedbackValue = (() => { + if (!feedbackMetadata) { + return null; + } + const metadata = feedbackMetadata; + return createFeedbackHandleHelper({ + metadata, + defaultUserId: oc.userId, + defaultConversationId: oc.conversationId, + resolveMessageId: () => + findFeedbackMessageIdHelper(buffer.getAllMessages(), metadata), + markFeedbackProvided: (input) => this.markFeedbackProvided(input), + }); + })(); + return cloneGenerateTextResultWithContext(result, { text: finalText, context: oc.context, toolCalls: aggregatedToolCalls, toolResults: aggregatedToolResults, - feedback: feedbackMetadata, + feedback: feedbackValue, }); } catch (error) { if (this.shouldRetryMiddleware(error, middlewareRetryCount, maxMiddlewareRetries)) { @@ -1335,7 +1359,8 @@ export class Agent { const feedbackDeferred = feedbackOptions ? createDeferred() : null; - let feedbackValue: AgentFeedbackMetadata | null = null; + let feedbackMetadataValue: AgentFeedbackMetadata | null = null; + let feedbackValue: AgentFeedbackHandle | null = null; let feedbackResolved = false; let feedbackFinalizeRequested = false; let feedbackApplied = false; @@ -1385,7 +1410,17 @@ export class Agent { if (feedbackPromise) { feedbackPromise .then((metadata) => { - feedbackValue = metadata; + feedbackMetadataValue = metadata; + feedbackValue = metadata + ? createFeedbackHandleHelper({ + metadata, + defaultUserId: oc.userId, + defaultConversationId: oc.conversationId, + resolveMessageId: () => + findFeedbackMessageIdHelper(buffer.getAllMessages(), metadata), + markFeedbackProvided: (input) => this.markFeedbackProvided(input), + }) + : null; resolveFeedbackDeferred(metadata); if (feedbackFinalizeRequested) { scheduleFeedbackPersist(metadata); @@ -1862,8 +1897,8 @@ export class Agent { await feedbackDeferred.promise; } - if (feedbackResolved && feedbackValue) { - scheduleFeedbackPersist(feedbackValue); + if (feedbackResolved && feedbackMetadataValue) { + scheduleFeedbackPersist(feedbackMetadataValue); } else if (shouldDeferPersist) { void persistQueue.flush(buffer, oc).catch((error) => { oc.logger?.debug?.("Failed to persist deferred messages", { error }); @@ -2173,11 +2208,11 @@ export class Agent { if (feedbackDeferred) { await feedbackDeferred.promise; } - if (feedbackResolved && feedbackValue) { + if (feedbackResolved && feedbackMetadataValue) { controller.enqueue({ type: "message-metadata", messageMetadata: { - feedback: feedbackValue, + feedback: feedbackMetadataValue, }, } as UIStreamChunk); } @@ -7011,6 +7046,32 @@ export class Agent { return voltOpsClient !== undefined; } + /** + * Check whether feedback has already been provided for a feedback metadata object. + */ + public static isFeedbackProvided(feedback?: AgentFeedbackMetadata | null): boolean { + return isFeedbackProvidedHelper(feedback); + } + + /** + * Check whether a message already has feedback marked as provided. + */ + public static isMessageFeedbackProvided(message?: UIMessage | null): boolean { + return isMessageFeedbackProvidedHelper(message); + } + + /** + * Persist a "feedback provided" marker into assistant message metadata. + */ + public async markFeedbackProvided( + input: AgentMarkFeedbackProvidedInput, + ): Promise { + return await markFeedbackProvidedHelper({ + memory: this.memoryManager.getMemory(), + input, + }); + } + /** * Get memory manager */ diff --git a/packages/core/src/agent/feedback.spec.ts b/packages/core/src/agent/feedback.spec.ts new file mode 100644 index 000000000..b66af1492 --- /dev/null +++ b/packages/core/src/agent/feedback.spec.ts @@ -0,0 +1,314 @@ +import type { UIMessage } from "ai"; +import { describe, expect, it, vi } from "vitest"; +import { Memory } from "../memory"; +import { InMemoryStorageAdapter } from "../memory/adapters/storage/in-memory"; +import { + createFeedbackHandle, + findFeedbackMessageId, + isFeedbackProvided, + isMessageFeedbackProvided, + markFeedbackProvided, +} from "./feedback"; +import type { AgentFeedbackMetadata } from "./types"; + +describe("feedback helpers", () => { + it("detects provided feedback metadata", () => { + expect( + isFeedbackProvided({ + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + }), + ).toBe(false); + + expect( + isFeedbackProvided({ + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + provided: true, + }), + ).toBe(true); + + expect( + isFeedbackProvided({ + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + providedAt: "2026-02-12T00:00:00.000Z", + }), + ).toBe(true); + }); + + it("detects provided feedback from message metadata", () => { + const message: UIMessage = { + id: "msg-1", + role: "assistant", + parts: [{ type: "text", text: "hello" }], + metadata: { + feedback: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + provided: true, + }, + }, + }; + + expect(isMessageFeedbackProvided(message)).toBe(true); + }); + + it("finds feedback message id by token id and by fallback fields", () => { + const byToken: UIMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "a" }], + metadata: { + feedback: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + tokenId: "token-1", + }, + }, + }, + ]; + + const feedbackWithToken: AgentFeedbackMetadata = { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + tokenId: "token-1", + }; + expect(findFeedbackMessageId(byToken, feedbackWithToken)).toBe("assistant-1"); + + const byFields: UIMessage[] = [ + { + id: "assistant-2", + role: "assistant", + parts: [{ type: "text", text: "b" }], + metadata: { + feedback: { + traceId: "trace-2", + key: "quality", + url: "https://example.com/fb-2", + }, + }, + }, + ]; + const feedbackByFields: AgentFeedbackMetadata = { + traceId: "trace-2", + key: "quality", + url: "https://example.com/fb-2", + }; + expect(findFeedbackMessageId(byFields, feedbackByFields)).toBe("assistant-2"); + }); + + it("does not throw when fallback feedback fields are not strings", () => { + const messages: UIMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "a" }], + metadata: { + feedback: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + }, + }, + }, + ]; + + const malformedFeedback = { + traceId: undefined, + key: null, + url: 42, + } as unknown as AgentFeedbackMetadata; + + expect(() => findFeedbackMessageId(messages, malformedFeedback)).not.toThrow(); + expect(findFeedbackMessageId(messages, malformedFeedback)).toBeUndefined(); + }); + + it("marks feedback provided and persists in memory", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + }); + + const userId = "user-1"; + const conversationId = "conv-feedback"; + const messageId = "assistant-msg-1"; + + await memory.createConversation({ + id: conversationId, + userId, + resourceId: "agent-1", + title: "Feedback test", + metadata: {}, + }); + + await memory.addMessage( + { + id: messageId, + role: "assistant", + parts: [{ type: "text", text: "hello" }], + metadata: { + feedback: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + tokenId: "token-1", + }, + }, + } as UIMessage, + userId, + conversationId, + ); + + const updated = await markFeedbackProvided({ + memory, + input: { + userId, + conversationId, + messageId, + feedbackId: "feedback-1", + providedAt: "2026-02-12T00:00:00.000Z", + }, + }); + + expect(updated?.provided).toBe(true); + expect(updated?.providedAt).toBe("2026-02-12T00:00:00.000Z"); + expect(updated?.feedbackId).toBe("feedback-1"); + + const storedMessages = await memory.getMessages(userId, conversationId); + const stored = storedMessages.find((message) => message.id === messageId); + const storedMetadata = + stored && typeof stored.metadata === "object" && stored.metadata !== null + ? (stored.metadata as Record) + : undefined; + const storedFeedback = + storedMetadata && + typeof storedMetadata.feedback === "object" && + storedMetadata.feedback !== null + ? (storedMetadata.feedback as Record) + : undefined; + + expect(storedFeedback?.provided).toBe(true); + expect(storedFeedback?.feedbackId).toBe("feedback-1"); + }); + + it("preserves existing providedAt when re-marking without an explicit timestamp", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + }); + + const userId = "user-1"; + const conversationId = "conv-feedback"; + const messageId = "assistant-msg-1"; + const initialProvidedAt = "2026-02-12T00:00:00.000Z"; + + await memory.createConversation({ + id: conversationId, + userId, + resourceId: "agent-1", + title: "Feedback test", + metadata: {}, + }); + + await memory.addMessage( + { + id: messageId, + role: "assistant", + parts: [{ type: "text", text: "hello" }], + metadata: { + feedback: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + tokenId: "token-1", + }, + }, + } as UIMessage, + userId, + conversationId, + ); + + const first = await markFeedbackProvided({ + memory, + input: { + userId, + conversationId, + messageId, + providedAt: initialProvidedAt, + }, + }); + + expect(first?.providedAt).toBe(initialProvidedAt); + + const second = await markFeedbackProvided({ + memory, + input: { + userId, + conversationId, + messageId, + }, + }); + + expect(second?.providedAt).toBe(initialProvidedAt); + }); + + it("creates feedback handle with mark helper and updates local state", async () => { + const mark = vi.fn(async () => ({ + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + tokenId: "token-1", + provided: true, + providedAt: "2026-02-12T00:00:00.000Z", + feedbackId: "feedback-2", + })); + + const handle = createFeedbackHandle({ + metadata: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + tokenId: "token-1", + }, + defaultUserId: "user-1", + defaultConversationId: "conv-1", + resolveMessageId: () => "msg-1", + markFeedbackProvided: mark, + }); + + expect(handle.isProvided()).toBe(false); + + const updated = await handle.markFeedbackProvided({ feedbackId: "feedback-2" }); + expect(updated?.provided).toBe(true); + expect(handle.isProvided()).toBe(true); + + expect(mark).toHaveBeenCalledWith({ + userId: "user-1", + conversationId: "conv-1", + messageId: "msg-1", + providedAt: undefined, + feedbackId: "feedback-2", + }); + }); + + it("throws when feedback handle cannot resolve required values", async () => { + const handle = createFeedbackHandle({ + metadata: { + traceId: "trace-1", + key: "satisfaction", + url: "https://example.com/fb", + }, + markFeedbackProvided: async () => null, + }); + + await expect(handle.markFeedbackProvided()).rejects.toThrow( + "feedback.markFeedbackProvided is missing required values", + ); + }); +}); diff --git a/packages/core/src/agent/feedback.ts b/packages/core/src/agent/feedback.ts new file mode 100644 index 000000000..0416f060a --- /dev/null +++ b/packages/core/src/agent/feedback.ts @@ -0,0 +1,269 @@ +import type { UIMessage } from "ai"; +import type { Memory } from "../memory"; +import type { + AgentFeedbackHandle, + AgentFeedbackMarkProvidedInput, + AgentFeedbackMetadata, + AgentMarkFeedbackProvidedInput, +} from "./types"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const hasNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +export function isFeedbackProvided(feedback?: AgentFeedbackMetadata | null): boolean { + if (!feedback) { + return false; + } + + if (feedback.provided === true) { + return true; + } + + if (typeof feedback.providedAt === "string" && feedback.providedAt.trim().length > 0) { + return true; + } + + if (typeof feedback.feedbackId === "string" && feedback.feedbackId.trim().length > 0) { + return true; + } + + return false; +} + +export function isMessageFeedbackProvided(message?: UIMessage | null): boolean { + if (!message || !isRecord(message.metadata)) { + return false; + } + + const rawFeedback = (message.metadata as Record).feedback; + if (!isRecord(rawFeedback)) { + return false; + } + + return isFeedbackProvided(rawFeedback as AgentFeedbackMetadata); +} + +function resolveFeedbackProvidedAt(value?: Date | string): string { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + throw new Error("markFeedbackProvided received an invalid Date value"); + } + return value.toISOString(); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return new Date().toISOString(); + } + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) { + throw new Error("markFeedbackProvided received an invalid providedAt timestamp"); + } + return parsed.toISOString(); + } + + return new Date().toISOString(); +} + +export async function markFeedbackProvided(params: { + memory?: Memory; + input: AgentMarkFeedbackProvidedInput; +}): Promise { + const { memory, input } = params; + const userId = typeof input.userId === "string" ? input.userId.trim() : ""; + const conversationId = + typeof input.conversationId === "string" ? input.conversationId.trim() : ""; + const messageId = typeof input.messageId === "string" ? input.messageId.trim() : ""; + + if (!userId) { + throw new Error("markFeedbackProvided requires a non-empty userId"); + } + if (!conversationId) { + throw new Error("markFeedbackProvided requires a non-empty conversationId"); + } + if (!messageId) { + throw new Error("markFeedbackProvided requires a non-empty messageId"); + } + if (!memory) { + throw new Error("Cannot mark feedback as provided because memory is not configured"); + } + + const messages = await memory.getMessages(userId, conversationId); + const target = messages.find((message) => message.id === messageId); + if (!target) { + return null; + } + + const messageMetadata = + typeof target.metadata === "object" && target.metadata !== null + ? (target.metadata as Record) + : {}; + const rawFeedback = messageMetadata.feedback; + if (!isRecord(rawFeedback)) { + return null; + } + + const existingFeedback = rawFeedback as AgentFeedbackMetadata; + const existingProvidedAt = + typeof existingFeedback.providedAt === "string" && existingFeedback.providedAt.trim().length > 0 + ? existingFeedback.providedAt.trim() + : undefined; + const providedAt = + input.providedAt !== undefined + ? resolveFeedbackProvidedAt(input.providedAt) + : (existingProvidedAt ?? resolveFeedbackProvidedAt(undefined)); + const providedFeedbackId = + typeof input.feedbackId === "string" && input.feedbackId.trim().length > 0 + ? input.feedbackId.trim() + : existingFeedback.feedbackId; + + const updatedFeedback: AgentFeedbackMetadata = { + ...existingFeedback, + provided: true, + providedAt, + ...(providedFeedbackId ? { feedbackId: providedFeedbackId } : {}), + }; + + await memory.addMessage( + { + ...target, + metadata: { + ...messageMetadata, + feedback: updatedFeedback, + }, + }, + userId, + conversationId, + ); + + return updatedFeedback; +} + +export function findFeedbackMessageId( + messages: ReadonlyArray, + feedback: AgentFeedbackMetadata, +): string | undefined { + if (messages.length === 0) { + return undefined; + } + + const feedbackTokenId = + typeof feedback.tokenId === "string" ? feedback.tokenId.trim() : undefined; + const feedbackTraceId = typeof feedback.traceId === "string" ? feedback.traceId.trim() : ""; + const feedbackKey = typeof feedback.key === "string" ? feedback.key.trim() : ""; + const feedbackUrl = typeof feedback.url === "string" ? feedback.url.trim() : ""; + + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message.role !== "assistant") { + continue; + } + + if (!hasNonEmptyString(message.id)) { + continue; + } + + if (!isRecord(message.metadata)) { + continue; + } + + const rawFeedback = (message.metadata as Record).feedback; + if (!isRecord(rawFeedback)) { + continue; + } + + const messageTokenId = + typeof rawFeedback.tokenId === "string" ? rawFeedback.tokenId.trim() : undefined; + if (feedbackTokenId && messageTokenId === feedbackTokenId) { + return message.id; + } + + const messageTraceId = + typeof rawFeedback.traceId === "string" ? rawFeedback.traceId.trim() : ""; + const messageKey = typeof rawFeedback.key === "string" ? rawFeedback.key.trim() : ""; + const messageUrl = typeof rawFeedback.url === "string" ? rawFeedback.url.trim() : ""; + + if ( + messageTraceId === feedbackTraceId && + messageKey === feedbackKey && + messageUrl === feedbackUrl + ) { + return message.id; + } + } + + return undefined; +} + +export function createFeedbackHandle(params: { + metadata: AgentFeedbackMetadata; + defaultUserId?: string; + defaultConversationId?: string; + resolveMessageId?: () => string | undefined; + markFeedbackProvided: ( + input: AgentMarkFeedbackProvidedInput, + ) => Promise; +}): AgentFeedbackHandle { + const { metadata, defaultUserId, defaultConversationId, resolveMessageId, markFeedbackProvided } = + params; + + const feedbackHandle = { + ...metadata, + } as AgentFeedbackHandle; + + Object.defineProperty(feedbackHandle, "isProvided", { + value: () => isFeedbackProvided(feedbackHandle), + enumerable: false, + configurable: true, + writable: true, + }); + + Object.defineProperty(feedbackHandle, "markFeedbackProvided", { + value: async (input?: AgentFeedbackMarkProvidedInput) => { + const userId = input?.userId?.trim() || defaultUserId?.trim() || ""; + const conversationId = input?.conversationId?.trim() || defaultConversationId?.trim() || ""; + const messageId = input?.messageId?.trim() || resolveMessageId?.() || ""; + + if (!userId || !conversationId || !messageId) { + const missing: string[] = []; + if (!userId) missing.push("userId"); + if (!conversationId) missing.push("conversationId"); + if (!messageId) missing.push("messageId"); + throw new Error( + `feedback.markFeedbackProvided is missing required values: ${missing.join(", ")}`, + ); + } + + const updated = await markFeedbackProvided({ + userId, + conversationId, + messageId, + providedAt: input?.providedAt, + feedbackId: input?.feedbackId, + }); + + if (updated) { + feedbackHandle.traceId = updated.traceId; + feedbackHandle.key = updated.key; + feedbackHandle.url = updated.url; + feedbackHandle.tokenId = updated.tokenId; + feedbackHandle.expiresAt = updated.expiresAt; + feedbackHandle.feedbackConfig = updated.feedbackConfig; + feedbackHandle.provided = updated.provided; + feedbackHandle.providedAt = updated.providedAt; + feedbackHandle.feedbackId = updated.feedbackId; + } + + return updated; + }, + enumerable: false, + configurable: true, + writable: true, + }); + + return feedbackHandle; +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index acd5f5aea..9c4399efa 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -88,6 +88,32 @@ export type AgentFeedbackMetadata = { tokenId?: string; expiresAt?: string; feedbackConfig?: VoltOpsFeedbackConfig | null; + provided?: boolean; + providedAt?: string; + feedbackId?: string; +}; + +export type AgentFeedbackMarkProvidedInput = { + userId?: string; + conversationId?: string; + messageId?: string; + providedAt?: Date | string; + feedbackId?: string; +}; + +export type AgentFeedbackHandle = AgentFeedbackMetadata & { + isProvided: () => boolean; + markFeedbackProvided: ( + input?: AgentFeedbackMarkProvidedInput, + ) => Promise; +}; + +export type AgentMarkFeedbackProvidedInput = { + userId: string; + conversationId: string; + messageId: string; + providedAt?: Date | string; + feedbackId?: string; }; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 211f7bfcd..552dbbd78 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,7 +180,10 @@ export type { AgentModelConfig, AgentModelValue, AgentFeedbackOptions, + AgentFeedbackHandle, + AgentFeedbackMarkProvidedInput, AgentFeedbackMetadata, + AgentMarkFeedbackProvidedInput, WorkspaceToolkitOptions, AgentResponse, AgentFullState, diff --git a/packages/server-core/src/schemas/agent.schemas.ts b/packages/server-core/src/schemas/agent.schemas.ts index 1ecf4e224..ee78c72d8 100644 --- a/packages/server-core/src/schemas/agent.schemas.ts +++ b/packages/server-core/src/schemas/agent.schemas.ts @@ -293,6 +293,9 @@ export const TextResponseSchema = z.object({ tokenId: z.string().optional(), expiresAt: z.string().optional(), feedbackConfig: FeedbackConfigSchema.nullish().optional(), + provided: z.boolean().optional(), + providedAt: z.string().optional(), + feedbackId: z.string().optional(), }) .nullable() .optional() diff --git a/website/docs/agents/message-types.md b/website/docs/agents/message-types.md index fafa731ab..37d954d65 100644 --- a/website/docs/agents/message-types.md +++ b/website/docs/agents/message-types.md @@ -156,14 +156,26 @@ When feedback is enabled, VoltAgent attaches feedback metadata to assistant UI m ```ts const feedback = message.metadata?.feedback as - | { traceId?: string; key?: string; url?: string } + | { + traceId?: string; + key?: string; + url?: string; + expiresAt?: string; + provided?: boolean; + providedAt?: string; + feedbackId?: string; + } | undefined; -if (feedback?.url) { +const alreadyProvided = Boolean(feedback?.provided || feedback?.providedAt || feedback?.feedbackId); + +if (feedback?.url && !alreadyProvided) { console.log("Submit feedback to:", feedback.url); } ``` +After submitting feedback successfully, persist the provided-state in memory with `agent.markFeedbackProvided(...)` so the UI still hides feedback controls after conversation reload. + See [Feedback](/observability-docs/feedback) for the full flow and API examples. ### 3. VoltAgentTextStreamPart (Streaming Extension) diff --git a/website/docs/agents/overview.md b/website/docs/agents/overview.md index cbde3b09c..c719f1d5c 100644 --- a/website/docs/agents/overview.md +++ b/website/docs/agents/overview.md @@ -142,6 +142,8 @@ const result = await agent.generateText("Summarize this trace", { console.log(result.feedback?.url); ``` +`result.feedback` may also include `provided`, `providedAt`, and `feedbackId` so UI clients can hide feedback controls once submitted. + If the feedback key is already registered, you can pass only `key` and let the stored config populate the token. ```ts @@ -150,6 +152,22 @@ const result = await agent.generateText("Quick rating?", { }); ``` +To persist "already submitted" state across memory reloads, you can use the result helper: + +```ts +const feedbackId = "feedback-id-from-ingestion-response"; // returned by your feedback ingestion API response + +if (result.feedback && !result.feedback.isProvided()) { + await result.feedback.markFeedbackProvided({ + feedbackId, // optional + }); +} +``` + +You can still call `agent.markFeedbackProvided(...)` directly if you prefer explicit control. + +These helpers require memory-backed conversations (`userId` and `conversationId`) and are typically called from your backend after feedback ingestion succeeds. + For end-to-end examples (SDK, API, and useChat), see [Feedback](/observability-docs/feedback). ### Structured Data Generation diff --git a/website/observability/feedback.md b/website/observability/feedback.md index 72d6155d9..399265f8c 100644 --- a/website/observability/feedback.md +++ b/website/observability/feedback.md @@ -30,6 +30,9 @@ Feedback metadata shape: "url": "https://api.voltagent.dev/api/public/feedback/ingest/...", "tokenId": "...", "expiresAt": "2026-01-06T18:25:26.005Z", + "provided": true, + "providedAt": "2026-01-06T18:30:00.000Z", + "feedbackId": "feedback-id", "feedbackConfig": { "type": "categorical", "categories": [ @@ -40,6 +43,16 @@ Feedback metadata shape: } ``` +## Best-practice state model + +For production apps, use a hybrid approach: + +- Keep feedback records in VoltOps as the source of truth. +- Use message metadata (`provided`, `providedAt`, `feedbackId`) as fast UI state for hiding feedback controls. +- After a successful feedback submit, persist the metadata state on the stored assistant message with `agent.markFeedbackProvided(...)`. + +This keeps UX responsive and prevents feedback controls from reappearing after conversation reloads. + ## Feedback keys (registry) Feedback keys let you register a reusable schema for a signal (numeric, boolean, or categorical). The system stores keys per project and uses them to resolve `feedbackConfig` when you only pass `key`. @@ -88,6 +101,20 @@ const result = await agent.generateText("Help me reset my password", { const feedback = result.feedback; ``` +If you want to persist feedback-submitted state in memory after ingestion, use the helper on the returned feedback object: + +```ts +const feedbackId = "feedback-id-from-ingestion-response"; // returned by your feedback ingestion API response + +if (result.feedback && !result.feedback.isProvided()) { + await result.feedback.markFeedbackProvided({ + feedbackId, // optional + }); +} +``` + +Use this helper only for memory-backed conversations (requests that include `userId` and `conversationId`). If those IDs are missing, messages are not persisted, so there is nothing to mark as provided. + ### Use a registered key If the key is already registered, you can omit `feedbackConfig` and the stored config is used. @@ -193,6 +220,11 @@ When you use the `/agents/:id/chat` endpoint (AI SDK useChat compatible), the as import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; +const apiUrl = "http://localhost:3141"; // your VoltAgent server base URL +const agentId = "support-agent-id"; // from route param or app config +const userId = "user-1"; // from your auth/session layer +const conversationId = "conv-1"; // current conversation id from your app state + const transport = new DefaultChatTransport({ api: `${apiUrl}/agents/${agentId}/chat`, prepareSendMessagesRequest({ messages }) { @@ -219,11 +251,28 @@ const transport = new DefaultChatTransport({ const { messages } = useChat({ transport }); +const isFeedbackProvided = (feedback: any): boolean => + Boolean(feedback?.provided || feedback?.providedAt || feedback?.feedbackId); + +const isFeedbackExpired = (feedback: any): boolean => + typeof feedback?.expiresAt === "string" && new Date(feedback.expiresAt).getTime() <= Date.now(); + +const shouldShowFeedback = (message: any): boolean => { + const feedback = message?.metadata?.feedback; + if (!feedback?.url) return false; + if (isFeedbackExpired(feedback)) return false; + if (isFeedbackProvided(feedback)) return false; + return true; +}; + +// Example usage while rendering messages: +// {messages.filter(shouldShowFeedback).map(renderFeedbackButtons)} + async function submitFeedback(message: any, score: number) { const feedback = message?.metadata?.feedback; - if (!feedback?.url) return; + if (!feedback?.url || isFeedbackProvided(feedback)) return; - await fetch(feedback.url, { + const response = await fetch(feedback.url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -232,9 +281,51 @@ async function submitFeedback(message: any, score: number) { feedback_source_type: "app", }), }); + + if (!response.ok) return; + const { id: feedbackId } = (await response.json()) as { id?: string }; + + // Persist "already submitted" state for reloads + await markFeedbackProvided({ + agentId, + userId, + conversationId, + messageId: message.id, + feedbackId, // optional: feedback id returned by ingestion API + }); +} + +async function markFeedbackProvided(input: { + agentId: string; + userId: string; + conversationId: string; + messageId: string; + feedbackId?: string; +}) { + // This is a custom app endpoint you implement. + await fetch(`/api/agents/${input.agentId}/feedback/provided`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); } ``` +### Persist provided-state on the server + +Call `agent.markFeedbackProvided(...)` after feedback submit succeeds. This is not a built-in HTTP route; expose it from your own backend endpoint. + +```ts +const updated = await agent.markFeedbackProvided({ + userId, + conversationId, + messageId, + feedbackId, // optional +}); +``` + +This updates the stored assistant message metadata so reloaded conversations still show feedback as completed. + ## API usage Use the API directly when you are not calling the SDK or when you want a custom feedback flow.