From b62eebc25b9a6e08b2f675ecc5ed2e5a41122a5b Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 6 Apr 2026 22:44:38 +0900 Subject: [PATCH] Simplify paired turn routing and prompt builders --- src/message-agent-executor-rules.test.ts | 22 ++ src/message-agent-executor-rules.ts | 24 +- src/message-agent-executor.ts | 54 ++--- src/message-runtime-prompts.ts | 141 ++++++++++++ src/message-runtime-rules.test.ts | 132 +++++++++++ src/message-runtime-rules.ts | 101 ++++++++- src/message-runtime.ts | 272 ++++++++--------------- 7 files changed, 519 insertions(+), 227 deletions(-) create mode 100644 src/message-runtime-prompts.ts create mode 100644 src/message-runtime-rules.test.ts diff --git a/src/message-agent-executor-rules.test.ts b/src/message-agent-executor-rules.test.ts index a7eb7d5..315461e 100644 --- a/src/message-agent-executor-rules.test.ts +++ b/src/message-agent-executor-rules.test.ts @@ -141,6 +141,17 @@ describe('message-agent-executor-rules', () => { ).toBe('generic'); }); + it('skips generic requeue when reviewer success moved the task to merge_ready for inline finalize', () => { + expect( + resolvePairedFollowUpQueueAction({ + completedRole: 'reviewer', + executionStatus: 'succeeded', + sawOutput: true, + taskStatus: 'merge_ready', + }), + ).toBe('skip-inline-finalize'); + }); + it('does not request a generic follow-up after successful reviewer output moved the task back to active', () => { expect( resolvePairedFollowUpQueueAction({ @@ -151,4 +162,15 @@ describe('message-agent-executor-rules', () => { }), ).toBe('none'); }); + + it('returns none after successful output when no next-turn action is needed', () => { + expect( + resolvePairedFollowUpQueueAction({ + completedRole: 'owner', + executionStatus: 'succeeded', + sawOutput: true, + taskStatus: 'active', + }), + ).toBe('none'); + }); }); diff --git a/src/message-agent-executor-rules.ts b/src/message-agent-executor-rules.ts index 0a250af..de2ca9b 100644 --- a/src/message-agent-executor-rules.ts +++ b/src/message-agent-executor-rules.ts @@ -5,6 +5,7 @@ import { isCodexRotationReason, } from './agent-error-detection.js'; import { detectCodexRotationTrigger } from './codex-token-rotation.js'; +import { resolveNextTurnAction } from './message-runtime-rules.js'; import type { PairedRoomRole, PairedTaskStatus } from './types.js'; import { getErrorMessage } from './utils.js'; @@ -123,26 +124,29 @@ export function resolvePairedFollowUpQueueAction(args: { sawOutput: boolean; taskStatus: PairedTaskStatus | null; }): PairedFollowUpQueueAction { + const nextTurnAction = resolveNextTurnAction({ + taskStatus: args.taskStatus, + lastTurnOutputRole: + args.executionStatus === 'succeeded' && args.sawOutput + ? args.completedRole + : null, + }); + if (args.executionStatus === 'succeeded' && args.sawOutput) { - if ( - args.completedRole === 'owner' && - args.taskStatus === 'review_ready' - ) { + if (nextTurnAction.kind === 'reviewer-turn') { return 'generic'; } return args.completedRole === 'reviewer' && - args.taskStatus === 'merge_ready' + nextTurnAction.kind === 'finalize-owner-turn' ? 'skip-inline-finalize' : 'none'; } const shouldRequeuePendingPairedTurn = (args.completedRole === 'reviewer' || args.completedRole === 'arbiter') && - (args.taskStatus === 'review_ready' || - args.taskStatus === 'in_review' || - args.taskStatus === 'arbiter_requested' || - args.taskStatus === 'in_arbitration' || - args.taskStatus === 'merge_ready'); + (nextTurnAction.kind === 'reviewer-turn' || + nextTurnAction.kind === 'arbiter-turn' || + nextTurnAction.kind === 'finalize-owner-turn'); return shouldRequeuePendingPairedTurn ? 'pending' : 'none'; } diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 01378f3..6bca327 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -32,10 +32,7 @@ import { } from './paired-execution-context.js'; import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js'; import { - resolveActiveRole, - resolveConfiguredRoleAgentPlan, - resolveEffectiveAgentType, - resolveSessionFolder, + resolveExecutionTarget, } from './message-runtime-rules.js'; import { isRetryableClaudeSessionFailureAttempt, @@ -67,7 +64,6 @@ import { readArbiterPrompt } from './platform-prompts.js'; import { activateCodexFailover, getEffectiveChannelLease, - resolveLeaseServiceId, } from './service-routing.js'; import { evaluateStreamedOutput, @@ -119,41 +115,33 @@ export async function runAgentForGroup( const pairedTask = currentLease.reviewer_agent_type ? getLatestOpenPairedTaskForChat(chatJid) : null; - const inferredRole = resolveActiveRole(pairedTask?.status); - const canHonorForcedRole = Boolean( - args.forcedRole === 'owner' || - (args.forcedRole === 'reviewer' && currentLease.reviewer_agent_type) || - (args.forcedRole === 'arbiter' && currentLease.arbiter_agent_type), - ); - const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; - const effectiveServiceId = resolveLeaseServiceId(currentLease, activeRole); - if (!effectiveServiceId) { - throw new Error(`Missing runtime service id for ${activeRole} lease`); - } + const executionTarget = resolveExecutionTarget({ + lease: currentLease, + pairedTaskStatus: pairedTask?.status, + groupFolder: group.folder, + groupAgentType: group.agentType, + forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType, + }); + const { + inferredRole, + canHonorForcedRole, + activeRole, + effectiveServiceId, + reviewerServiceId, + arbiterServiceId, + roleAgentPlan, + configuredAgentType, + effectiveAgentType, + sessionFolder, + } = executionTarget; const reviewerMode = activeRole === 'reviewer'; const arbiterMode = activeRole === 'arbiter'; - const reviewerServiceId = resolveLeaseServiceId(currentLease, 'reviewer'); - const arbiterServiceId = resolveLeaseServiceId(currentLease, 'arbiter'); - const roleAgentPlan = resolveConfiguredRoleAgentPlan( - currentLease.reviewer_agent_type != null, - group.agentType, - ); - - const configuredAgentType = resolveEffectiveAgentType( - activeRole, - group.agentType, - ); - const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; const effectiveGroup = effectiveAgentType !== roleAgentPlan.ownerAgentType ? { ...group, agentType: effectiveAgentType } : group; const isClaudeCodeAgent = effectiveAgentType === 'claude-code'; - const sessionFolder = resolveSessionFolder( - group.folder, - activeRole, - group.agentType, - ); const unsafeHostPairedMode = process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1'; const forceFreshClaudeReviewerSession = diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts new file mode 100644 index 0000000..1903004 --- /dev/null +++ b/src/message-runtime-prompts.ts @@ -0,0 +1,141 @@ +import { buildArbiterContextPrompt } from './arbiter-context.js'; +import { formatMessages } from './router.js'; +import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; + +function turnOutputsToMessages( + outputs: PairedTurnOutput[], + chatJid: string, +): NewMessage[] { + return outputs.map((turnOutput) => ({ + id: `turn-${turnOutput.task_id}-${turnOutput.turn_number}`, + chat_jid: chatJid, + sender: turnOutput.role, + sender_name: turnOutput.role, + content: turnOutput.output_text, + timestamp: turnOutput.created_at, + is_bot_message: true as const, + is_from_me: false as const, + })); +} + +function mergeHumanAndTurnOutputMessages( + chatJid: string, + humanMessages: NewMessage[], + turnOutputs: PairedTurnOutput[], +): NewMessage[] { + return [...humanMessages, ...turnOutputsToMessages(turnOutputs, chatJid)].sort( + (a, b) => a.timestamp.localeCompare(b.timestamp), + ); +} + +export function buildPairedTurnPrompt(args: { + taskId: string; + chatJid: string; + timezone: string; + missedMessages: NewMessage[]; + labeledFallbackMessages: NewMessage[]; + turnOutputs: PairedTurnOutput[]; +}): string { + if (args.turnOutputs.length === 0) { + return formatMessages(args.labeledFallbackMessages, args.timezone); + } + + const humanMessages = args.missedMessages.filter((message) => !message.is_bot_message); + return formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + humanMessages, + args.turnOutputs, + ), + args.timezone, + ); +} + +export function buildReviewerPendingPrompt(args: { + chatJid: string; + timezone: string; + turnOutputs: PairedTurnOutput[]; + recentHumanMessages: NewMessage[]; + lastHumanMessage: string | null | undefined; +}): string { + if (args.turnOutputs.length > 0) { + return formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentHumanMessages, + args.turnOutputs, + ), + args.timezone, + ); + } + + if (!args.lastHumanMessage) { + return 'Review the latest owner changes in the workspace.'; + } + + return `User request:\n---\n${args.lastHumanMessage}\n---\n\nReview the latest owner changes in the workspace.`; +} + +export function buildOwnerPendingPrompt(args: { + chatJid: string; + timezone: string; + turnOutputs: PairedTurnOutput[]; + recentHumanMessages: NewMessage[]; + lastHumanMessage: string | null | undefined; +}): string { + if (args.turnOutputs.length > 0) { + return formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentHumanMessages, + args.turnOutputs, + ), + args.timezone, + ); + } + + if (!args.lastHumanMessage) { + return 'Continue the owner turn using the latest reviewer or arbiter feedback.'; + } + + return `User request:\n---\n${args.lastHumanMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`; +} + +export function buildArbiterPromptForTask(args: { + task: PairedTask; + chatJid: string; + timezone: string; + turnOutputs: PairedTurnOutput[]; + recentMessages: NewMessage[]; + labeledRecentMessages: NewMessage[]; +}): string { + const messages = + args.turnOutputs.length > 0 + ? mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentMessages.filter((message) => !message.is_bot_message), + args.turnOutputs, + ) + : args.labeledRecentMessages; + + return buildArbiterContextPrompt({ + chatJid: args.chatJid, + taskId: args.task.id, + roundTripCount: args.task.round_trip_count, + timezone: args.timezone, + messages, + }); +} + +export function buildFinalizePendingPrompt(args: { + turnOutputs: PairedTurnOutput[]; +}): string { + const lastReviewerOutput = [...args.turnOutputs] + .reverse() + .find((output) => output.role === 'reviewer'); + const reviewerSummary = lastReviewerOutput?.output_text + ? `\n\nReviewer's final assessment:\n${lastReviewerOutput.output_text.slice(0, 2000)}` + : ''; + + return `The reviewer approved your work (DONE). Finalize and report the result.${reviewerSummary}`; +} diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts new file mode 100644 index 0000000..9d44c3a --- /dev/null +++ b/src/message-runtime-rules.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveExecutionTarget, + resolveNextTurnAction, + resolveSessionFolder, +} from './message-runtime-rules.js'; +import { + resolveLeaseServiceId, + type EffectiveChannelLease, +} from './service-routing.js'; + +const baseLease: EffectiveChannelLease = { + chat_jid: 'chat-1', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + owner_service_id: 'svc-owner', + reviewer_service_id: 'svc-reviewer', + arbiter_service_id: 'svc-arbiter', + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: true, +}; + +describe('message-runtime-rules', () => { + it('maps review_ready to a reviewer turn', () => { + expect( + resolveNextTurnAction({ + taskStatus: 'review_ready', + }), + ).toEqual({ kind: 'reviewer-turn' }); + }); + + it('maps arbiter_requested to an arbiter turn', () => { + expect( + resolveNextTurnAction({ + taskStatus: 'arbiter_requested', + }), + ).toEqual({ kind: 'arbiter-turn' }); + }); + + it('maps merge_ready to an inline finalize owner turn', () => { + expect( + resolveNextTurnAction({ + taskStatus: 'merge_ready', + }), + ).toEqual({ kind: 'finalize-owner-turn' }); + }); + + it('maps active tasks with reviewer output to an owner follow-up', () => { + expect( + resolveNextTurnAction({ + taskStatus: 'active', + lastTurnOutputRole: 'reviewer', + }), + ).toEqual({ kind: 'owner-follow-up' }); + }); + + it('returns none when an active task has no reviewer or arbiter handoff output', () => { + expect( + resolveNextTurnAction({ + taskStatus: 'active', + lastTurnOutputRole: 'owner', + }), + ).toEqual({ kind: 'none' }); + }); + + it('resolves reviewer execution target from review_ready task status', () => { + const resolution = resolveExecutionTarget({ + lease: baseLease, + pairedTaskStatus: 'review_ready', + groupFolder: 'group-1', + groupAgentType: 'claude-code', + }); + + expect(resolution).toMatchObject({ + inferredRole: 'reviewer', + activeRole: 'reviewer', + configuredAgentType: 'claude-code', + effectiveAgentType: 'claude-code', + sessionFolder: 'group-1', + }); + expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId); + }); + + it('honors an arbiter forced role only when arbiter is configured', () => { + const resolution = resolveExecutionTarget({ + lease: { ...baseLease, arbiter_agent_type: null, arbiter_service_id: null }, + pairedTaskStatus: 'active', + groupFolder: 'group-1', + groupAgentType: 'claude-code', + forcedRole: 'arbiter', + }); + + expect(resolution).toMatchObject({ + inferredRole: 'owner', + canHonorForcedRole: false, + activeRole: 'owner', + }); + expect(resolution.effectiveServiceId).toBe( + resolveLeaseServiceId(baseLease, 'owner'), + ); + }); + + it('applies forced agent type overrides without changing the configured role target', () => { + const resolution = resolveExecutionTarget({ + lease: baseLease, + pairedTaskStatus: 'review_ready', + groupFolder: 'group-1', + groupAgentType: 'claude-code', + forcedAgentType: 'codex', + }); + + expect(resolution).toMatchObject({ + activeRole: 'reviewer', + configuredAgentType: 'claude-code', + effectiveAgentType: 'codex', + }); + expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId); + }); + + it('always gives arbiter a dedicated session folder', () => { + expect( + resolveSessionFolder('group-1', 'arbiter', 'claude-code'), + ).toBe('group-1:arbiter'); + expect( + resolveSessionFolder('group-1', 'arbiter', 'codex'), + ).toBe('group-1:arbiter'); + }); +}); diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 601e6b7..60d21de 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -4,7 +4,11 @@ import { normalizeStoredSeqCursor } from './message-cursor.js'; import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { isTaskStatusControlMessage } from './task-watch-status.js'; import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from './config.js'; -import { hasReviewerLease } from './service-routing.js'; +import { + hasReviewerLease, + resolveLeaseServiceId, + type EffectiveChannelLease, +} from './service-routing.js'; import { resolveAgentTypeForRole, resolveRoleAgentPlan, @@ -14,6 +18,8 @@ import { type AgentType, type Channel, type NewMessage, + type PairedRoomRole, + type PairedTaskStatus, type RegisteredGroup, } from './types.js'; @@ -55,6 +61,36 @@ export function resolveActiveRole( } } +export type NextTurnAction = + | { kind: 'none' } + | { kind: 'reviewer-turn' } + | { kind: 'arbiter-turn' } + | { kind: 'owner-follow-up' } + | { kind: 'finalize-owner-turn' }; + +export function resolveNextTurnAction(args: { + taskStatus?: PairedTaskStatus | null; + lastTurnOutputRole?: PairedRoomRole | null; +}): NextTurnAction { + switch (args.taskStatus) { + case 'review_ready': + case 'in_review': + return { kind: 'reviewer-turn' }; + case 'arbiter_requested': + case 'in_arbitration': + return { kind: 'arbiter-turn' }; + case 'merge_ready': + return { kind: 'finalize-owner-turn' }; + case 'active': + return args.lastTurnOutputRole === 'reviewer' || + args.lastTurnOutputRole === 'arbiter' + ? { kind: 'owner-follow-up' } + : { kind: 'none' }; + default: + return { kind: 'none' }; + } +} + /** Cursor key for a role. Owner uses chatJid, others use chatJid:role. */ export function resolveCursorKey( chatJid: string, @@ -102,6 +138,69 @@ export function resolveSessionFolder( return `${groupFolder}:${role}`; } +export interface ExecutionTargetResolution { + inferredRole: PairedRoomRole; + canHonorForcedRole: boolean; + activeRole: PairedRoomRole; + effectiveServiceId: string; + reviewerServiceId: string | null; + arbiterServiceId: string | null; + roleAgentPlan: RoleAgentPlan; + configuredAgentType: AgentType; + effectiveAgentType: AgentType; + sessionFolder: string; +} + +export function resolveExecutionTarget(args: { + lease: EffectiveChannelLease; + pairedTaskStatus?: PairedTaskStatus | null; + groupFolder: string; + groupAgentType?: AgentType; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; +}): ExecutionTargetResolution { + const inferredRole = resolveActiveRole(args.pairedTaskStatus); + const canHonorForcedRole = Boolean( + args.forcedRole === 'owner' || + (args.forcedRole === 'reviewer' && args.lease.reviewer_agent_type) || + (args.forcedRole === 'arbiter' && args.lease.arbiter_agent_type), + ); + const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; + const effectiveServiceId = resolveLeaseServiceId(args.lease, activeRole); + if (!effectiveServiceId) { + throw new Error(`Missing runtime service id for ${activeRole} lease`); + } + + const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer'); + const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter'); + const roleAgentPlan = resolveConfiguredRoleAgentPlan( + args.lease.reviewer_agent_type != null, + args.groupAgentType, + ); + const configuredAgentType = resolveEffectiveAgentType( + activeRole, + args.groupAgentType, + ); + const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; + + return { + inferredRole, + canHonorForcedRole, + activeRole, + effectiveServiceId, + reviewerServiceId, + arbiterServiceId, + roleAgentPlan, + configuredAgentType, + effectiveAgentType, + sessionFolder: resolveSessionFolder( + args.groupFolder, + activeRole, + args.groupAgentType, + ), + }; +} + export function createImplicitContinuationTracker(idleTimeout: number) { const implicitContinuationUntil = new Map(); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index d263124..f2733cb 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -33,6 +33,7 @@ import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { advanceLastAgentCursor, createImplicitContinuationTracker, + resolveNextTurnAction, resolveActiveRole, resolveCursorKey, filterLoopingPairedBotMessages, @@ -40,9 +41,15 @@ import { hasAllowedTrigger, shouldSkipBotOnlyCollaboration, } from './message-runtime-rules.js'; -import { buildArbiterContextPrompt } from './arbiter-context.js'; import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; +import { + buildArbiterPromptForTask, + buildFinalizePendingPrompt, + buildOwnerPendingPrompt, + buildPairedTurnPrompt, + buildReviewerPendingPrompt, +} from './message-runtime-prompts.js'; import { extractSessionCommand, handleSessionCommand, @@ -56,7 +63,6 @@ import { type PairedRoomRole, RegisteredGroup, type PairedTask, - type PairedTurnOutput, } from './types.js'; import { createScopedLogger, logger } from './logger.js'; import { resolveGroupIpcPath } from './group-folder.js'; @@ -193,144 +199,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }); }; - /** Convert paired turn outputs to NewMessage format for formatMessages(). */ - const turnOutputsToMessages = ( - outputs: PairedTurnOutput[], - chatJid: string, - ): NewMessage[] => - outputs.map((t) => ({ - id: `turn-${t.task_id}-${t.turn_number}`, - chat_jid: chatJid, - sender: t.role, - sender_name: t.role, - content: t.output_text, - timestamp: t.created_at, - is_bot_message: true as const, - is_from_me: false as const, - })); - - const mergeHumanAndTurnOutputMessages = ( - chatJid: string, - humanMessages: NewMessage[], - turnOutputs: PairedTurnOutput[], - ): NewMessage[] => - [...humanMessages, ...turnOutputsToMessages(turnOutputs, chatJid)].sort( - (a, b) => a.timestamp.localeCompare(b.timestamp), - ); - - /** - * Build a prompt from paired_turn_outputs (Discord-independent) + human messages. - * Falls back to the legacy labelPairedSenders path when no turn outputs exist. - */ - const buildPairedTurnPrompt = ( - taskId: string, - chatJid: string, - timezone: string, - missedMessages: NewMessage[], - ): string => { - const turnOutputs = getPairedTurnOutputs(taskId); - if (turnOutputs.length === 0) { - // No stored outputs yet — fall back to Discord messages - return formatMessages( - labelPairedSenders(chatJid, missedMessages), - timezone, - ); - } - - // Human messages from the missed messages (exclude bot messages) - const humanMessages = missedMessages.filter((m) => !m.is_bot_message); - - return formatMessages( - mergeHumanAndTurnOutputMessages(chatJid, humanMessages, turnOutputs), - timezone, - ); - }; - - const buildReviewerPendingPrompt = ( - task: PairedTask, - chatJid: string, - timezone: string, - ): string => { - const turnOutputs = getPairedTurnOutputs(task.id); - if (turnOutputs.length > 0) { - const humanMessages = getRecentChatMessages(chatJid, 20).filter( - (message) => !message.is_bot_message, - ); - return formatMessages( - mergeHumanAndTurnOutputMessages(chatJid, humanMessages, turnOutputs), - timezone, - ); - } - - const userMessage = getLastHumanMessageContent(chatJid); - if (!userMessage) { - return 'Review the latest owner changes in the workspace.'; - } - - return `User request:\n---\n${userMessage}\n---\n\nReview the latest owner changes in the workspace.`; - }; - - const buildOwnerPendingPrompt = ( - task: PairedTask, - chatJid: string, - timezone: string, - ): string => { - const turnOutputs = getPairedTurnOutputs(task.id); - if (turnOutputs.length > 0) { - const humanMessages = getRecentChatMessages(chatJid, 20).filter( - (message) => !message.is_bot_message, - ); - return formatMessages( - mergeHumanAndTurnOutputMessages(chatJid, humanMessages, turnOutputs), - timezone, - ); - } - - const userMessage = getLastHumanMessageContent(chatJid); - if (!userMessage) { - return 'Continue the owner turn using the latest reviewer or arbiter feedback.'; - } - - return `User request:\n---\n${userMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`; - }; - - const buildArbiterPromptForTask = ( - task: PairedTask, - chatJid: string, - timezone: string, - ): string => { - const turnOutputs = getPairedTurnOutputs(task.id); - const recentMessages = getRecentChatMessages(chatJid, 20); - const arbiterMessages = - turnOutputs.length > 0 - ? mergeHumanAndTurnOutputMessages( - chatJid, - recentMessages.filter((message) => !message.is_bot_message), - turnOutputs, - ) - : labelPairedSenders(chatJid, recentMessages); - - return buildArbiterContextPrompt({ - chatJid, - taskId: task.id, - roundTripCount: task.round_trip_count, - timezone, - messages: arbiterMessages, - }); - }; - - const buildFinalizePendingPrompt = (task: PairedTask): string => { - const turnOutputs = getPairedTurnOutputs(task.id); - const lastReviewerOutput = [...turnOutputs] - .reverse() - .find((output) => output.role === 'reviewer'); - const reviewerSummary = lastReviewerOutput?.output_text - ? `\n\nReviewer's final assessment:\n${lastReviewerOutput.output_text.slice(0, 2000)}` - : ''; - - return `The reviewer approved your work (DONE). Finalize and report the result.${reviewerSummary}`; - }; - const isBotOnlyPairedRoomTurn = ( chatJid: string, messages: NewMessage[], @@ -370,7 +238,11 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const cursor = pendingCursorSource?.seq ?? pendingCursorSource?.timestamp ?? null; - if (task.status === 'merge_ready') { + const nextTurnAction = resolveNextTurnAction({ + taskStatus: task.status, + }); + + if (nextTurnAction.kind === 'finalize-owner-turn') { return { kind: 'inline-finalize', task, @@ -379,18 +251,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { } if ( - task.status === 'review_ready' || - task.status === 'in_review' || - task.status === 'arbiter_requested' || - task.status === 'in_arbitration' + nextTurnAction.kind === 'reviewer-turn' || + nextTurnAction.kind === 'arbiter-turn' ) { - const nextRole = resolveActiveRole(task.status); return { kind: 'requeue-pending-turn', task, cursor, cursorKey: resolveCursorKey(chatJid, task.status), - nextRole: nextRole === 'arbiter' ? 'arbiter' : 'reviewer', + nextRole: + nextTurnAction.kind === 'arbiter-turn' ? 'arbiter' : 'reviewer', }; } @@ -446,7 +316,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); const { deliverySucceeded } = await executeTurn({ group, - prompt: buildFinalizePendingPrompt(action.task), + prompt: buildFinalizePendingPrompt({ + turnOutputs: getPairedTurnOutputs(action.task.id), + }), chatJid, runId, channel, @@ -966,11 +838,27 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const lastRaw = rawMissedMessages[rawMissedMessages.length - 1]; const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null; const taskStatus = task.status; - const pendingRole = resolveActiveRole(taskStatus); + const turnOutputs = getPairedTurnOutputs(task.id); + const lastTurnOutput = turnOutputs[turnOutputs.length - 1]; + const nextTurnAction = resolveNextTurnAction({ + taskStatus, + lastTurnOutputRole: lastTurnOutput?.role ?? null, + }); + const recentMessages = getRecentChatMessages(chatJid, 20); + const recentHumanMessages = recentMessages.filter( + (message) => !message.is_bot_message, + ); + const lastHumanMessage = getLastHumanMessageContent(chatJid); - if (pendingRole === 'reviewer') { + if (nextTurnAction.kind === 'reviewer-turn') { return { - prompt: buildReviewerPendingPrompt(task, chatJid, deps.timezone), + prompt: buildReviewerPendingPrompt({ + chatJid, + timezone: deps.timezone, + turnOutputs, + recentHumanMessages, + lastHumanMessage, + }), channel: resolveChannel(taskStatus), cursor, cursorKey: resolveCursorKey(chatJid, taskStatus), @@ -978,9 +866,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }; } - if (pendingRole === 'arbiter') { + if (nextTurnAction.kind === 'arbiter-turn') { return { - prompt: buildArbiterPromptForTask(task, chatJid, deps.timezone), + prompt: buildArbiterPromptForTask({ + task, + chatJid, + timezone: deps.timezone, + turnOutputs, + recentMessages, + labeledRecentMessages: labelPairedSenders(chatJid, recentMessages), + }), channel: resolveChannel(taskStatus), cursor, cursorKey: resolveCursorKey(chatJid, taskStatus), @@ -988,28 +883,26 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }; } - if (taskStatus === 'merge_ready') { + if (nextTurnAction.kind === 'finalize-owner-turn') { return { - prompt: buildFinalizePendingPrompt(task), + prompt: buildFinalizePendingPrompt({ turnOutputs }), channel: resolveChannel(taskStatus), cursor, }; } - if (taskStatus === 'active') { - const turnOutputs = getPairedTurnOutputs(task.id); - const lastTurnOutput = turnOutputs[turnOutputs.length - 1]; - if ( - lastTurnOutput && - (lastTurnOutput.role === 'reviewer' || - lastTurnOutput.role === 'arbiter') - ) { - return { - prompt: buildOwnerPendingPrompt(task, chatJid, deps.timezone), - channel: resolveChannel(taskStatus), - cursor, - }; - } + if (nextTurnAction.kind === 'owner-follow-up') { + return { + prompt: buildOwnerPendingPrompt({ + chatJid, + timezone: deps.timezone, + turnOutputs, + recentHumanMessages, + lastHumanMessage, + }), + channel: resolveChannel(taskStatus), + cursor, + }; } return null; @@ -1068,7 +961,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }): boolean => hasReviewerLease(chatJid) && args.deliveryRole === 'reviewer' && - args.pendingTask?.status === 'merge_ready'; + resolveNextTurnAction({ + taskStatus: args.pendingTask?.status ?? null, + }).kind === 'finalize-owner-turn'; const enqueueGenericFollowUpAfterDeliveryRetry = (args: { deliveryRole: PairedRoomRole; @@ -1301,18 +1196,24 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const turnRole = resolveActiveRole(taskStatus); let prompt: string; if (turnRole === 'arbiter' && pendingTaskForChannel) { - prompt = buildArbiterPromptForTask( - pendingTaskForChannel, + const recentMessages = getRecentChatMessages(chatJid, 20); + prompt = buildArbiterPromptForTask({ + task: pendingTaskForChannel, chatJid, - deps.timezone, - ); + timezone: deps.timezone, + turnOutputs: getPairedTurnOutputs(pendingTaskForChannel.id), + recentMessages, + labeledRecentMessages: labelPairedSenders(chatJid, recentMessages), + }); } else if (pendingTaskForChannel) { - prompt = buildPairedTurnPrompt( - pendingTaskForChannel.id, + prompt = buildPairedTurnPrompt({ + taskId: pendingTaskForChannel.id, chatJid, - deps.timezone, + timezone: deps.timezone, missedMessages, - ); + labeledFallbackMessages: labelPairedSenders(chatJid, missedMessages), + turnOutputs: getPairedTurnOutputs(pendingTaskForChannel.id), + }); } else { prompt = formatMessages( labelPairedSenders(chatJid, missedMessages), @@ -1538,12 +1439,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ? pendingMessages : processableGroupMessages; const formatted = loopPendingTask - ? buildPairedTurnPrompt( - loopPendingTask.id, + ? buildPairedTurnPrompt({ + taskId: loopPendingTask.id, chatJid, - deps.timezone, - messagesToSend, - ) + timezone: deps.timezone, + missedMessages: messagesToSend, + labeledFallbackMessages: labelPairedSenders( + chatJid, + messagesToSend, + ), + turnOutputs: getPairedTurnOutputs(loopPendingTask.id), + }) : formatMessages( labelPairedSenders(chatJid, messagesToSend), deps.timezone,