/** * Background reflection triggered by negative user feedback (thumbs-down). * * Flow: * 1. User thumbs-down -> invoke handler acks immediately * 2. This module runs in the background (fire-and-forget) * 3. Reads recent session context * 4. Sends a synthetic reflection prompt to the agent * 5. Stores the derived learning in session * 6. Optionally sends a proactive follow-up to the user */ import { dispatchReplyFromConfigWithSettledDispatcher, type OpenClawConfig, } from "../runtime-api.js"; import type { StoredConversationReference } from "./conversation-store.js"; import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js"; import { DEFAULT_COOLDOWN_MS, clearReflectionCooldowns, isReflectionAllowed, loadSessionLearnings, recordReflectionTime, storeSessionLearning, } from "./feedback-reflection-store.js"; import type { MSTeamsAdapter } from "./messenger.js"; import { buildConversationReference } from "./messenger.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import { getMSTeamsRuntime } from "./runtime.js"; export type FeedbackEvent = { type: "custom"; event: "feedback"; ts: number; messageId: string; value: "positive" | "negative"; comment?: string; sessionKey: string; agentId: string; conversationId: string; reflectionLearning?: string; }; export function buildFeedbackEvent(params: { messageId: string; value: "positive" | "negative"; comment?: string; sessionKey: string; agentId: string; conversationId: string; }): FeedbackEvent { return { type: "custom", event: "feedback", ts: Date.now(), messageId: params.messageId, value: params.value, comment: params.comment, sessionKey: params.sessionKey, agentId: params.agentId, conversationId: params.conversationId, }; } export type RunFeedbackReflectionParams = { cfg: OpenClawConfig; adapter: MSTeamsAdapter; appId: string; conversationRef: StoredConversationReference; sessionKey: string; agentId: string; conversationId: string; feedbackMessageId: string; thumbedDownResponse?: string; userComment?: string; log: MSTeamsMonitorLogger; }; function buildReflectionContext(params: { cfg: OpenClawConfig; conversationId: string; sessionKey: string; reflectionPrompt: string; }) { const core = getMSTeamsRuntime(); const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg); const body = core.channel.reply.formatAgentEnvelope({ channel: "Teams", from: "system", body: params.reflectionPrompt, envelope: envelopeOptions, }); return { ctxPayload: core.channel.reply.finalizeInboundContext({ Body: body, BodyForAgent: params.reflectionPrompt, RawBody: params.reflectionPrompt, CommandBody: params.reflectionPrompt, From: `msteams:system:${params.conversationId}`, To: `conversation:${params.conversationId}`, SessionKey: params.sessionKey, ChatType: "direct" as const, SenderName: "system", SenderId: "system", Provider: "msteams" as const, Surface: "msteams" as const, Timestamp: Date.now(), WasMentioned: true, CommandAuthorized: false, OriginatingChannel: "msteams" as const, OriginatingTo: `conversation:${params.conversationId}`, }), }; } function createReflectionCaptureDispatcher(params: { cfg: OpenClawConfig; agentId: string; log: MSTeamsMonitorLogger; }) { const core = getMSTeamsRuntime(); let response = ""; const noopTypingCallbacks = { onReplyStart: async () => {}, onIdle: () => {}, onCleanup: () => {}, }; const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({ deliver: async (payload) => { if (payload.text) { response += (response ? "\n" : "") + payload.text; } }, typingCallbacks: noopTypingCallbacks, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), onError: (err) => { params.log.debug?.("reflection reply error", { error: String(err) }); }, }); return { dispatcher, replyOptions, readResponse: () => response, }; } async function sendReflectionFollowUp(params: { adapter: MSTeamsAdapter; appId: string; conversationRef: StoredConversationReference; userMessage: string; }): Promise { const baseRef = buildConversationReference(params.conversationRef); const proactiveRef = { ...baseRef, activityId: undefined }; await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => { await ctx.sendActivity({ type: "message", text: params.userMessage, }); }); } /** * Run a background reflection after negative feedback. * This is designed to be called fire-and-forget (don't await in the invoke handler). */ export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise { const { cfg, log, sessionKey } = params; const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS; if (!isReflectionAllowed(sessionKey, cooldownMs)) { log.debug?.("skipping reflection (cooldown active)", { sessionKey }); return; } const reflectionPrompt = buildReflectionPrompt({ thumbedDownResponse: params.thumbedDownResponse, userComment: params.userComment, }); const runtime = getMSTeamsRuntime(); const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, { agentId: params.agentId, }); const { ctxPayload } = buildReflectionContext({ cfg, conversationId: params.conversationId, sessionKey: params.sessionKey, reflectionPrompt, }); const capture = createReflectionCaptureDispatcher({ cfg, agentId: params.agentId, log, }); try { await dispatchReplyFromConfigWithSettledDispatcher({ ctxPayload, cfg, dispatcher: capture.dispatcher, onSettled: () => {}, replyOptions: capture.replyOptions, }); } catch (err) { log.error("reflection dispatch failed", { error: String(err) }); return; } const reflectionResponse = capture.readResponse().trim(); if (!reflectionResponse) { log.debug?.("reflection produced no output"); return; } const parsedReflection = parseReflectionResponse(reflectionResponse); if (!parsedReflection) { log.debug?.("reflection produced no structured output"); return; } recordReflectionTime(sessionKey, cooldownMs); log.info("reflection complete", { sessionKey, responseLength: reflectionResponse.length, followUp: parsedReflection.followUp, }); try { await storeSessionLearning({ storePath, sessionKey: params.sessionKey, learning: parsedReflection.learning, }); } catch (err) { log.debug?.("failed to store reflection learning", { error: String(err) }); } const conversationType = params.conversationRef.conversation?.conversationType?.toLowerCase(); const shouldNotify = conversationType === "personal" && parsedReflection.followUp && Boolean(parsedReflection.userMessage); if (!shouldNotify) { if (parsedReflection.followUp && conversationType !== "personal") { log.debug?.("skipping reflection follow-up outside direct message", { sessionKey, conversationType, }); } return; } try { await sendReflectionFollowUp({ adapter: params.adapter, appId: params.appId, conversationRef: params.conversationRef, userMessage: parsedReflection.userMessage!, }); log.info("sent reflection follow-up", { sessionKey }); } catch (err) { log.debug?.("failed to send reflection follow-up", { error: String(err) }); } } export { buildReflectionPrompt, clearReflectionCooldowns, isReflectionAllowed, loadSessionLearnings, parseReflectionResponse, recordReflectionTime, };