Harden conversation replies and prioritize newest candidates
This commit is contained in:
1 parent
f98c5c732d
commit
b8e4a1bb88
23 files changed
+598
-113
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
remainingAutomationTurns,
|
||||
type ConversationState,
|
||||
} from './conversation-state.js';
|
||||
import { containsMediaRequest } from './conversation-boundaries.js';
|
||||
|
||||
export type ConversationAutomationResultKind =
|
||||
| 'sent'
|
||||
@@ -44,8 +45,14 @@ export interface ConversationAutomationClaims {
|
||||
markAttempt?(conversationId: string, incomingKey: string): boolean;
|
||||
releasePending?(conversationId: string, incomingKey: string): boolean;
|
||||
markSent(conversationId: string, incomingKey: string): boolean;
|
||||
markSuperseded?(conversationId: string, incomingKey: string): boolean;
|
||||
}
|
||||
|
||||
export type ConversationBeforeSendLoader = (
|
||||
conversationId: string,
|
||||
observed: ActiveChat,
|
||||
) => Promise<ActiveChat | undefined>;
|
||||
|
||||
export interface ReplyPriorityStatus {
|
||||
processing: boolean;
|
||||
actionableBacklog: number;
|
||||
@@ -86,6 +93,7 @@ export class ConversationAutomationService {
|
||||
private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [],
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
|
||||
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
|
||||
) {}
|
||||
|
||||
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||
@@ -199,26 +207,21 @@ export class ConversationAutomationService {
|
||||
);
|
||||
}
|
||||
if (this.now() - existingClaim.claimedAt < this.pendingReplyRetryDelayMs) return null;
|
||||
const retryPlan = replyPlan(state);
|
||||
const exhausted = this.advanceStateForReply(
|
||||
state,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
existingClaim.replyText,
|
||||
retryPlan.isLegacy,
|
||||
retryPlan.isHybridMediaAsk,
|
||||
);
|
||||
const requestsMedia = containsMediaRequest(existingClaim.replyText);
|
||||
const isHybridMediaAsk = state.automation.policyMode === 'r4r-media' && requestsMedia;
|
||||
return this.sendClaimedReply(
|
||||
conversation,
|
||||
state,
|
||||
incomingKey,
|
||||
existingClaim.replyText,
|
||||
latest.timestamp,
|
||||
exhausted,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
signal,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (existingClaim?.status === 'sent') return null;
|
||||
if (existingClaim?.status === 'sent' || existingClaim?.status === 'superseded') return null;
|
||||
if (state.automation.lastIncomingKey === incomingKey) return null;
|
||||
if (state.automation.status !== 'active') return null;
|
||||
|
||||
@@ -269,6 +272,16 @@ export class ConversationAutomationService {
|
||||
}
|
||||
|
||||
const { isLegacy, isHybridMediaAsk } = replyPlan(state);
|
||||
if (isLegacy && state.askedForMedia) {
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversationId,
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'A media request has already been made; further autonomous requests are disabled.',
|
||||
);
|
||||
}
|
||||
if (isHybridMediaAsk) {
|
||||
state.automation.hybridPhase = 'media-ask';
|
||||
this.states.save(conversationId, state);
|
||||
@@ -283,7 +296,9 @@ export class ConversationAutomationService {
|
||||
: isHybridMediaAsk
|
||||
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
|
||||
: await this.ai.generateReply(mapped, conversation.username, state, intent);
|
||||
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply);
|
||||
const safeReply = isLegacy || isHybridMediaAsk
|
||||
? isSafeHybridMediaAsk(reply)
|
||||
: isSafeR4RReply(reply);
|
||||
if (!safeReply) {
|
||||
return this.finish(
|
||||
conversation,
|
||||
@@ -291,7 +306,7 @@ export class ConversationAutomationService {
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'The generated R4R reply failed autonomous safety checks.',
|
||||
'The generated reply failed autonomous safety checks.',
|
||||
);
|
||||
}
|
||||
if (this.dryRun) {
|
||||
@@ -301,24 +316,16 @@ export class ConversationAutomationService {
|
||||
|
||||
if (!this.claims.tryClaim(conversationId, incomingKey, reply, latest.timestamp)) return null;
|
||||
|
||||
// Persist the full intended state before the remote send. If the process
|
||||
// dies after Matrix accepts the message, both this state and the durable
|
||||
// event claim make the retry fail closed instead of sending twice.
|
||||
const exhausted = this.advanceStateForReply(
|
||||
state,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
reply,
|
||||
isLegacy,
|
||||
isHybridMediaAsk,
|
||||
);
|
||||
// The durable claim stores the intended body. Send-time validation then
|
||||
// snapshots the intended state immediately before the Matrix request.
|
||||
return this.sendClaimedReply(
|
||||
conversation,
|
||||
state,
|
||||
incomingKey,
|
||||
reply,
|
||||
latest.timestamp,
|
||||
exhausted,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -360,6 +367,7 @@ export class ConversationAutomationService {
|
||||
const claim = this.claims.getClaim?.(conversation.roomId, incomingKey);
|
||||
if (claim?.status === 'pending') return true;
|
||||
if (claim?.status === 'sent') return false;
|
||||
if (claim?.status === 'superseded') return false;
|
||||
// On the first scan after startup, retain Matrix unread as the conservative
|
||||
// bootstrap signal so a deployment cannot revive historical read replies.
|
||||
// Later scans can recover fresh inbound events even if that flag cleared.
|
||||
@@ -376,11 +384,11 @@ export class ConversationAutomationService {
|
||||
conversationId: string,
|
||||
incomingKey: string,
|
||||
reply: string,
|
||||
isLegacy: boolean,
|
||||
requestsMedia: boolean,
|
||||
isHybridMediaAsk: boolean,
|
||||
): boolean {
|
||||
if (state.automation.lastIncomingKey !== incomingKey) {
|
||||
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
|
||||
if (requestsMedia) state.askedForMedia = true;
|
||||
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
|
||||
state.automation.turnsUsed += 1;
|
||||
state.automation.lastIncomingKey = incomingKey;
|
||||
@@ -405,33 +413,126 @@ export class ConversationAutomationService {
|
||||
incomingKey: string,
|
||||
reply: string,
|
||||
incomingAt: number,
|
||||
exhausted: boolean,
|
||||
requestsMedia: boolean,
|
||||
isHybridMediaAsk: boolean,
|
||||
signal?: AbortSignal,
|
||||
resumePending = false,
|
||||
): Promise<ConversationAutomationResult | null> {
|
||||
this.claims.markAttempt?.(conversation.roomId!, incomingKey);
|
||||
const sent = await this.rateLimiter.throttle(
|
||||
() => this.chat.sendChatMessage(conversation.url!, reply),
|
||||
(success) => success,
|
||||
const conversationId = conversation.roomId!;
|
||||
const attempt = await this.rateLimiter.throttle(
|
||||
async () => {
|
||||
const latestConversation = await this.loadConversationBeforeSend(conversationId, conversation);
|
||||
const latest = latestConversation ? latestInboundMessage(latestConversation.messages ?? []) : null;
|
||||
const latestKey = latest ? messageKey(latest) : null;
|
||||
const freshState = latestConversation
|
||||
? mergeConversationState(
|
||||
this.states.get(conversationId),
|
||||
buildConversationState((latestConversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
|
||||
)
|
||||
: this.states.get(conversationId) ?? state;
|
||||
|
||||
const persistedIntentCanResume = resumePending
|
||||
&& freshState.automation.lastIncomingKey === incomingKey
|
||||
&& freshState.automation.status === 'human-required';
|
||||
if (!latestConversation || !latest || latestKey !== incomingKey
|
||||
|| (freshState.automation.status !== 'active' && !persistedIntentCanResume)) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = latestConversation && latest
|
||||
? this.applyFreshHandoff(latestConversation, freshState, latest)
|
||||
: null;
|
||||
if (!result && latestConversation) this.states.save(conversationId, freshState);
|
||||
this.logger.info(
|
||||
`[automation] Superseded queued reply for u/${conversation.username}; a newer conversation state was observed before send`,
|
||||
);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
if (freshState.boundaries.length > 0 || hasReceivedImage(latestConversation.messages ?? [])) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = this.applyFreshHandoff(latestConversation, freshState, latest);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
if (requestsMedia && freshState.askedForMedia
|
||||
&& !(resumePending && freshState.automation.lastIncomingKey === incomingKey)) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = this.finish(
|
||||
latestConversation,
|
||||
conversationId,
|
||||
freshState,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'A media request has already been made; further autonomous requests are disabled.',
|
||||
);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
const exhausted = this.advanceStateForReply(
|
||||
freshState,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
reply,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
);
|
||||
this.claims.markAttempt?.(conversationId, incomingKey);
|
||||
const sent = await this.chat.sendChatMessage(latestConversation.url!, reply);
|
||||
return { kind: sent ? 'sent' as const : 'failed' as const, exhausted, state: freshState };
|
||||
},
|
||||
(result) => result.kind === 'sent',
|
||||
signal,
|
||||
REPLY_SEND_PRIORITY,
|
||||
);
|
||||
if (!sent) return null;
|
||||
if (attempt.kind === 'superseded') return attempt.result;
|
||||
if (attempt.kind === 'failed') return null;
|
||||
const sentAt = this.now();
|
||||
this.claims.markSent(conversation.roomId!, incomingKey);
|
||||
this.lastReplySentAt = sentAt;
|
||||
this.lastReplyLatencyMs = Math.max(0, sentAt - incomingAt);
|
||||
this.logger.info(
|
||||
`[automation] Sent ${state.automation.policyMode} turn ${state.automation.turnsUsed}/${state.automation.maxAutomatedTurns} `
|
||||
`[automation] Sent ${attempt.state.automation.policyMode} turn ${attempt.state.automation.turnsUsed}/${attempt.state.automation.maxAutomatedTurns} `
|
||||
+ `to u/${conversation.username}; reply latency ${Math.round(this.lastReplyLatencyMs / 1_000)}s`,
|
||||
);
|
||||
return {
|
||||
conversationId: conversation.roomId!,
|
||||
username: conversation.username,
|
||||
kind: exhausted ? 'human-required' : 'sent',
|
||||
remainingTurns: remainingAutomationTurns(state),
|
||||
kind: attempt.exhausted ? 'human-required' : 'sent',
|
||||
remainingTurns: remainingAutomationTurns(attempt.state),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadConversationBeforeSend(conversationId: string, observed: ActiveChat): Promise<ActiveChat | undefined> {
|
||||
if (this.beforeSendLoader) return this.beforeSendLoader(conversationId, observed);
|
||||
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
|
||||
return (await this.chat.getCachedConversations()).find((item) => item.roomId === conversationId);
|
||||
}
|
||||
|
||||
private applyFreshHandoff(
|
||||
conversation: ActiveChat,
|
||||
state: ConversationState,
|
||||
latest: ChatMessage,
|
||||
): ConversationAutomationResult | null {
|
||||
if (state.boundaries.length > 0) {
|
||||
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'boundary', state.boundaries.join('; '));
|
||||
}
|
||||
if (hasReceivedImage(conversation.messages ?? [])) {
|
||||
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'image-received', 'The user shared an image.');
|
||||
}
|
||||
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
|
||||
if (sellerSignal) {
|
||||
state.sellerDetected = true;
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversation.roomId!,
|
||||
state,
|
||||
messageKey(latest),
|
||||
'human-required',
|
||||
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] {
|
||||
const configured = subreddit
|
||||
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
|
||||
|
||||
Reference in new issue
Block a user