Files
openclaw/extensions/msteams/src/feedback-reflection.ts
2026-03-24 10:02:49 -07:00

280 lines
7.8 KiB
TypeScript

/**
* 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<void> {
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<void> {
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,
};