Harden conversation replies and prioritize newest candidates

This commit is contained in:
jjbrunton committed 2026-08-09 18:16:57 +01:00
1 parent f98c5c732d
commit b8e4a1bb88
23 files changed
+598 -113

No files matched your search

+137 -36
View File
@@ -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())