Simplify paired turn routing and prompt builders

This commit is contained in:
ejclaw
2026-04-06 22:44:38 +09:00
parent f22947ba61
commit b62eebc25b
7 changed files with 519 additions and 227 deletions

View File

@@ -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');
});
});

View File

@@ -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';
}

View File

@@ -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 =

View File

@@ -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}`;
}

View File

@@ -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');
});
});

View File

@@ -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<string, number>();

View File

@@ -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,