Add hybrid R4R media conversation mode

This commit is contained in:
jjbrunton committed 2026-08-09 11:45:45 +01:00
1 parent 927636ec8b
commit b73e305cf3
15 files changed
+357 -35

No files matched your search

+36 -10
View File
@@ -11,6 +11,7 @@ import {
chooseConversationIntent,
conversationMessageFingerprint,
createConversationAutomationState,
isSafeHybridMediaAsk,
isSafeR4RReply,
LEGACY_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
@@ -43,7 +44,7 @@ export interface ConversationAutomationClaims {
/**
* 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, or manual policy for the conversation.
* snapshotted legacy, autonomous R4R, hybrid R4R-to-media, or manual policy.
*/
export class ConversationAutomationService {
constructor(
@@ -96,10 +97,14 @@ export class ConversationAutomationService {
);
if (!savedState) {
const behaviour = this.resolveConversationBehaviour(opener.subreddit);
const maxTurns = behaviour.mode === 'r4r'
const maxTurns = behaviour.mode === 'r4r' || behaviour.mode === 'r4r-media'
? behaviour.maxAutomatedTurns
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
state.automation = createConversationAutomationState(behaviour.mode, maxTurns);
state.automation = createConversationAutomationState(
behaviour.mode,
maxTurns,
behaviour.rapportTurnsBeforeMediaAsk,
);
// Snapshot before generation so retries, restarts, dry runs, and later
// configuration edits cannot change the policy mid-conversation.
this.states.save(conversationId, state);
@@ -136,7 +141,7 @@ export class ConversationAutomationService {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (state.automation.policyMode === 'r4r') {
if (state.automation.policyMode === 'r4r' || state.automation.policyMode === 'r4r-media') {
const safetyReason = r4rSafetyHandoffReason(latest.text);
if (safetyReason) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
@@ -154,14 +159,26 @@ export class ConversationAutomationService {
}
const isLegacy = state.automation.policyMode === 'legacy';
const intent = chooseConversationIntent(mapped, state, isLegacy ? 'media-ask' : undefined);
const isHybrid = state.automation.policyMode === 'r4r-media';
const isHybridMediaAsk = isHybrid
&& !state.askedForMedia
&& state.automation.turnsUsed >= state.automation.rapportTurnsBeforeMediaAsk;
if (isHybridMediaAsk) {
state.automation.hybridPhase = 'media-ask';
this.states.save(conversationId, state);
}
const requestsMedia = isLegacy || isHybridMediaAsk;
const intent = chooseConversationIntent(mapped, state, requestsMedia ? 'media-ask' : undefined);
if (isLegacy && intent.kind !== 'media-ask') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
}
const reply = isLegacy
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
if (!isLegacy && !isSafeR4RReply(reply)) {
: 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);
if (!safeReply) {
return this.finish(
conversation,
conversationId,
@@ -181,11 +198,16 @@ export class ConversationAutomationService {
// 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.
if (isLegacy) state.askedForMedia = true;
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns;
// Keep the hybrid conversation active after its one media ask even when
// that send consumes the final turn. The next inbound event still needs
// to be classified as an image, a decline, or a normal turn-limit handoff.
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns
&& !isHybridMediaAsk;
if (exhausted) {
state.automation.status = 'human-required';
state.automation.handoffReason = `${state.automation.maxAutomatedTurns} automated turns have been used.`;
@@ -214,7 +236,11 @@ export class ConversationAutomationService {
const configured = subreddit
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
: undefined;
return configured?.conversation ?? { mode: 'legacy', maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS };
return configured?.conversation ?? {
mode: 'legacy',
maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS,
rapportTurnsBeforeMediaAsk: LEGACY_AUTOMATED_CONVERSATION_TURNS,
};
}
private finish(