Add engagement-only conversation mode

This commit is contained in:
jjbrunton committed 2026-08-14 15:07:54 +01:00
1 parent b7fcc7db05
commit 7af7b211fa
24 files changed
+204 -23

No files matched your search

+34 -1
View File
@@ -29,6 +29,7 @@ import type { ConversationLearningProvider, LearningStage, RetrievedLearningExam
export type ConversationAutomationResultKind =
| 'sent'
| 'engaged'
| 'human-required'
| 'boundary'
| 'image-received'
@@ -111,7 +112,8 @@ const PENDING_REPLY_RETRY_DELAY_MS = 2 * 60_000;
/**
* Sends at most one reply for each newly received user message. Durable state
* makes retries and restarts idempotent. The opener subreddit selects a
* snapshotted legacy, autonomous R4R, hybrid R4R-to-media, or manual policy.
* snapshotted legacy, engagement-only, autonomous R4R, hybrid R4R-to-media,
* or manual policy.
*/
export class ConversationAutomationService {
private processing = false;
@@ -271,6 +273,9 @@ export class ConversationAutomationService {
this.logger.info(`[automation] Paused u/${conversation.username}; an operator-authored message was observed`);
return null;
}
if (state.automation.policyMode === 'engagement' && state.automation.status === 'active') {
return this.completeEngagement(conversation, conversationId, state, incomingKey);
}
const existingClaim = this.claims.getClaim?.(conversationId, incomingKey);
if (existingClaim?.status === 'pending') {
const expectedReplyFingerprint = existingClaim.replyText
@@ -900,6 +905,34 @@ export class ConversationAutomationService {
};
}
/** Records the first verified response as the terminal goal of engagement mode. */
private completeEngagement(
conversation: ActiveChat,
conversationId: string,
state: ConversationState,
incomingKey: string,
): ConversationAutomationResult {
state.automation.status = 'engaged';
state.automation.lastIncomingKey = incomingKey;
state.automation.handoffReason = 'The recipient replied; the engagement goal is complete.';
this.states.save(conversationId, state);
this.claims.markSuperseded?.(conversationId, incomingKey);
try {
this.options.triage?.setStatus(conversationId, 'done', null, this.now());
} catch (error) {
this.logger.warn(
`[automation] Completed engagement with u/${conversation.username} but could not resolve triage state: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.info(`[automation] Engagement complete for u/${conversation.username}; no automated reply will be sent`);
return {
conversationId,
username: conversation.username,
kind: 'engaged',
remainingTurns: 0,
};
}
private finish(
conversation: ActiveChat,
conversationId: string,
+5 -3
View File
@@ -48,8 +48,8 @@ export interface ConversationState {
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 4;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export const CONVERSATION_POLICY_VERSION = 5;
export type ConversationPolicyMode = 'legacy' | 'engagement' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
/**
@@ -60,6 +60,7 @@ export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask'
*/
export type ConversationAutomationStatus =
| 'active'
| 'engaged'
| 'image-received'
| 'human-required'
| 'boundary'
@@ -308,6 +309,7 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
if (!value || typeof value !== 'object') return createConversationAutomationState();
const parsed = value as Partial<ConversationAutomationState>;
const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r'
|| parsed.policyMode === 'engagement'
|| parsed.policyMode === 'r4r-media'
|| parsed.policyMode === 'manual'
? parsed.policyMode
@@ -316,7 +318,7 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
? Math.max(1, Math.min(20, Math.trunc(parsed.maxAutomatedTurns)))
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
const status: ConversationAutomationStatus =
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary'
parsed.status === 'engaged' || parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary'
|| parsed.status === 'user-overridden' || parsed.status === 'killed'
? parsed.status
: 'active';
@@ -514,6 +514,8 @@ export class AccountRuntimeLoop {
type: 'response',
message: result.kind === 'human-required'
? `Human follow-up required for u/${result.username}`
: result.kind === 'engaged'
? `Engagement completed after a reply from u/${result.username}`
: result.kind === 'image-received'
? `Image received from u/${result.username}`
: result.kind === 'killed'