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'); ).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', () => { it('does not request a generic follow-up after successful reviewer output moved the task back to active', () => {
expect( expect(
resolvePairedFollowUpQueueAction({ resolvePairedFollowUpQueueAction({
@@ -151,4 +162,15 @@ describe('message-agent-executor-rules', () => {
}), }),
).toBe('none'); ).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, isCodexRotationReason,
} from './agent-error-detection.js'; } from './agent-error-detection.js';
import { detectCodexRotationTrigger } from './codex-token-rotation.js'; import { detectCodexRotationTrigger } from './codex-token-rotation.js';
import { resolveNextTurnAction } from './message-runtime-rules.js';
import type { PairedRoomRole, PairedTaskStatus } from './types.js'; import type { PairedRoomRole, PairedTaskStatus } from './types.js';
import { getErrorMessage } from './utils.js'; import { getErrorMessage } from './utils.js';
@@ -123,26 +124,29 @@ export function resolvePairedFollowUpQueueAction(args: {
sawOutput: boolean; sawOutput: boolean;
taskStatus: PairedTaskStatus | null; taskStatus: PairedTaskStatus | null;
}): PairedFollowUpQueueAction { }): PairedFollowUpQueueAction {
const nextTurnAction = resolveNextTurnAction({
taskStatus: args.taskStatus,
lastTurnOutputRole:
args.executionStatus === 'succeeded' && args.sawOutput
? args.completedRole
: null,
});
if (args.executionStatus === 'succeeded' && args.sawOutput) { if (args.executionStatus === 'succeeded' && args.sawOutput) {
if ( if (nextTurnAction.kind === 'reviewer-turn') {
args.completedRole === 'owner' &&
args.taskStatus === 'review_ready'
) {
return 'generic'; return 'generic';
} }
return args.completedRole === 'reviewer' && return args.completedRole === 'reviewer' &&
args.taskStatus === 'merge_ready' nextTurnAction.kind === 'finalize-owner-turn'
? 'skip-inline-finalize' ? 'skip-inline-finalize'
: 'none'; : 'none';
} }
const shouldRequeuePendingPairedTurn = const shouldRequeuePendingPairedTurn =
(args.completedRole === 'reviewer' || args.completedRole === 'arbiter') && (args.completedRole === 'reviewer' || args.completedRole === 'arbiter') &&
(args.taskStatus === 'review_ready' || (nextTurnAction.kind === 'reviewer-turn' ||
args.taskStatus === 'in_review' || nextTurnAction.kind === 'arbiter-turn' ||
args.taskStatus === 'arbiter_requested' || nextTurnAction.kind === 'finalize-owner-turn');
args.taskStatus === 'in_arbitration' ||
args.taskStatus === 'merge_ready');
return shouldRequeuePendingPairedTurn ? 'pending' : 'none'; return shouldRequeuePendingPairedTurn ? 'pending' : 'none';
} }

View File

@@ -32,10 +32,7 @@ import {
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js'; import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
import { import {
resolveActiveRole, resolveExecutionTarget,
resolveConfiguredRoleAgentPlan,
resolveEffectiveAgentType,
resolveSessionFolder,
} from './message-runtime-rules.js'; } from './message-runtime-rules.js';
import { import {
isRetryableClaudeSessionFailureAttempt, isRetryableClaudeSessionFailureAttempt,
@@ -67,7 +64,6 @@ import { readArbiterPrompt } from './platform-prompts.js';
import { import {
activateCodexFailover, activateCodexFailover,
getEffectiveChannelLease, getEffectiveChannelLease,
resolveLeaseServiceId,
} from './service-routing.js'; } from './service-routing.js';
import { import {
evaluateStreamedOutput, evaluateStreamedOutput,
@@ -119,41 +115,33 @@ export async function runAgentForGroup(
const pairedTask = currentLease.reviewer_agent_type const pairedTask = currentLease.reviewer_agent_type
? getLatestOpenPairedTaskForChat(chatJid) ? getLatestOpenPairedTaskForChat(chatJid)
: null; : null;
const inferredRole = resolveActiveRole(pairedTask?.status); const executionTarget = resolveExecutionTarget({
const canHonorForcedRole = Boolean( lease: currentLease,
args.forcedRole === 'owner' || pairedTaskStatus: pairedTask?.status,
(args.forcedRole === 'reviewer' && currentLease.reviewer_agent_type) || groupFolder: group.folder,
(args.forcedRole === 'arbiter' && currentLease.arbiter_agent_type), groupAgentType: group.agentType,
); forcedRole: args.forcedRole,
const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; forcedAgentType: args.forcedAgentType,
const effectiveServiceId = resolveLeaseServiceId(currentLease, activeRole); });
if (!effectiveServiceId) { const {
throw new Error(`Missing runtime service id for ${activeRole} lease`); inferredRole,
} canHonorForcedRole,
activeRole,
effectiveServiceId,
reviewerServiceId,
arbiterServiceId,
roleAgentPlan,
configuredAgentType,
effectiveAgentType,
sessionFolder,
} = executionTarget;
const reviewerMode = activeRole === 'reviewer'; const reviewerMode = activeRole === 'reviewer';
const arbiterMode = activeRole === 'arbiter'; 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 = const effectiveGroup =
effectiveAgentType !== roleAgentPlan.ownerAgentType effectiveAgentType !== roleAgentPlan.ownerAgentType
? { ...group, agentType: effectiveAgentType } ? { ...group, agentType: effectiveAgentType }
: group; : group;
const isClaudeCodeAgent = effectiveAgentType === 'claude-code'; const isClaudeCodeAgent = effectiveAgentType === 'claude-code';
const sessionFolder = resolveSessionFolder(
group.folder,
activeRole,
group.agentType,
);
const unsafeHostPairedMode = const unsafeHostPairedMode =
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1'; process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
const forceFreshClaudeReviewerSession = 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 { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import { isTaskStatusControlMessage } from './task-watch-status.js'; import { isTaskStatusControlMessage } from './task-watch-status.js';
import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from './config.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 { import {
resolveAgentTypeForRole, resolveAgentTypeForRole,
resolveRoleAgentPlan, resolveRoleAgentPlan,
@@ -14,6 +18,8 @@ import {
type AgentType, type AgentType,
type Channel, type Channel,
type NewMessage, type NewMessage,
type PairedRoomRole,
type PairedTaskStatus,
type RegisteredGroup, type RegisteredGroup,
} from './types.js'; } 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. */ /** Cursor key for a role. Owner uses chatJid, others use chatJid:role. */
export function resolveCursorKey( export function resolveCursorKey(
chatJid: string, chatJid: string,
@@ -102,6 +138,69 @@ export function resolveSessionFolder(
return `${groupFolder}:${role}`; 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) { export function createImplicitContinuationTracker(idleTimeout: number) {
const implicitContinuationUntil = new Map<string, number>(); const implicitContinuationUntil = new Map<string, number>();

View File

@@ -33,6 +33,7 @@ import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import { import {
advanceLastAgentCursor, advanceLastAgentCursor,
createImplicitContinuationTracker, createImplicitContinuationTracker,
resolveNextTurnAction,
resolveActiveRole, resolveActiveRole,
resolveCursorKey, resolveCursorKey,
filterLoopingPairedBotMessages, filterLoopingPairedBotMessages,
@@ -40,9 +41,15 @@ import {
hasAllowedTrigger, hasAllowedTrigger,
shouldSkipBotOnlyCollaboration, shouldSkipBotOnlyCollaboration,
} from './message-runtime-rules.js'; } from './message-runtime-rules.js';
import { buildArbiterContextPrompt } from './arbiter-context.js';
import { runAgentForGroup } from './message-agent-executor.js'; import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js'; import { MessageTurnController } from './message-turn-controller.js';
import {
buildArbiterPromptForTask,
buildFinalizePendingPrompt,
buildOwnerPendingPrompt,
buildPairedTurnPrompt,
buildReviewerPendingPrompt,
} from './message-runtime-prompts.js';
import { import {
extractSessionCommand, extractSessionCommand,
handleSessionCommand, handleSessionCommand,
@@ -56,7 +63,6 @@ import {
type PairedRoomRole, type PairedRoomRole,
RegisteredGroup, RegisteredGroup,
type PairedTask, type PairedTask,
type PairedTurnOutput,
} from './types.js'; } from './types.js';
import { createScopedLogger, logger } from './logger.js'; import { createScopedLogger, logger } from './logger.js';
import { resolveGroupIpcPath } from './group-folder.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 = ( const isBotOnlyPairedRoomTurn = (
chatJid: string, chatJid: string,
messages: NewMessage[], messages: NewMessage[],
@@ -370,7 +238,11 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const cursor = const cursor =
pendingCursorSource?.seq ?? pendingCursorSource?.timestamp ?? null; pendingCursorSource?.seq ?? pendingCursorSource?.timestamp ?? null;
if (task.status === 'merge_ready') { const nextTurnAction = resolveNextTurnAction({
taskStatus: task.status,
});
if (nextTurnAction.kind === 'finalize-owner-turn') {
return { return {
kind: 'inline-finalize', kind: 'inline-finalize',
task, task,
@@ -379,18 +251,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
if ( if (
task.status === 'review_ready' || nextTurnAction.kind === 'reviewer-turn' ||
task.status === 'in_review' || nextTurnAction.kind === 'arbiter-turn'
task.status === 'arbiter_requested' ||
task.status === 'in_arbitration'
) { ) {
const nextRole = resolveActiveRole(task.status);
return { return {
kind: 'requeue-pending-turn', kind: 'requeue-pending-turn',
task, task,
cursor, cursor,
cursorKey: resolveCursorKey(chatJid, task.status), 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({ const { deliverySucceeded } = await executeTurn({
group, group,
prompt: buildFinalizePendingPrompt(action.task), prompt: buildFinalizePendingPrompt({
turnOutputs: getPairedTurnOutputs(action.task.id),
}),
chatJid, chatJid,
runId, runId,
channel, channel,
@@ -966,11 +838,27 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1]; const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null; const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null;
const taskStatus = task.status; 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 { return {
prompt: buildReviewerPendingPrompt(task, chatJid, deps.timezone), prompt: buildReviewerPendingPrompt({
chatJid,
timezone: deps.timezone,
turnOutputs,
recentHumanMessages,
lastHumanMessage,
}),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
cursorKey: resolveCursorKey(chatJid, taskStatus), cursorKey: resolveCursorKey(chatJid, taskStatus),
@@ -978,9 +866,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}; };
} }
if (pendingRole === 'arbiter') { if (nextTurnAction.kind === 'arbiter-turn') {
return { return {
prompt: buildArbiterPromptForTask(task, chatJid, deps.timezone), prompt: buildArbiterPromptForTask({
task,
chatJid,
timezone: deps.timezone,
turnOutputs,
recentMessages,
labeledRecentMessages: labelPairedSenders(chatJid, recentMessages),
}),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
cursorKey: resolveCursorKey(chatJid, taskStatus), cursorKey: resolveCursorKey(chatJid, taskStatus),
@@ -988,28 +883,26 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}; };
} }
if (taskStatus === 'merge_ready') { if (nextTurnAction.kind === 'finalize-owner-turn') {
return { return {
prompt: buildFinalizePendingPrompt(task), prompt: buildFinalizePendingPrompt({ turnOutputs }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
}; };
} }
if (taskStatus === 'active') { if (nextTurnAction.kind === 'owner-follow-up') {
const turnOutputs = getPairedTurnOutputs(task.id); return {
const lastTurnOutput = turnOutputs[turnOutputs.length - 1]; prompt: buildOwnerPendingPrompt({
if ( chatJid,
lastTurnOutput && timezone: deps.timezone,
(lastTurnOutput.role === 'reviewer' || turnOutputs,
lastTurnOutput.role === 'arbiter') recentHumanMessages,
) { lastHumanMessage,
return { }),
prompt: buildOwnerPendingPrompt(task, chatJid, deps.timezone), channel: resolveChannel(taskStatus),
channel: resolveChannel(taskStatus), cursor,
cursor, };
};
}
} }
return null; return null;
@@ -1068,7 +961,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}): boolean => }): boolean =>
hasReviewerLease(chatJid) && hasReviewerLease(chatJid) &&
args.deliveryRole === 'reviewer' && args.deliveryRole === 'reviewer' &&
args.pendingTask?.status === 'merge_ready'; resolveNextTurnAction({
taskStatus: args.pendingTask?.status ?? null,
}).kind === 'finalize-owner-turn';
const enqueueGenericFollowUpAfterDeliveryRetry = (args: { const enqueueGenericFollowUpAfterDeliveryRetry = (args: {
deliveryRole: PairedRoomRole; deliveryRole: PairedRoomRole;
@@ -1301,18 +1196,24 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const turnRole = resolveActiveRole(taskStatus); const turnRole = resolveActiveRole(taskStatus);
let prompt: string; let prompt: string;
if (turnRole === 'arbiter' && pendingTaskForChannel) { if (turnRole === 'arbiter' && pendingTaskForChannel) {
prompt = buildArbiterPromptForTask( const recentMessages = getRecentChatMessages(chatJid, 20);
pendingTaskForChannel, prompt = buildArbiterPromptForTask({
task: pendingTaskForChannel,
chatJid, chatJid,
deps.timezone, timezone: deps.timezone,
); turnOutputs: getPairedTurnOutputs(pendingTaskForChannel.id),
recentMessages,
labeledRecentMessages: labelPairedSenders(chatJid, recentMessages),
});
} else if (pendingTaskForChannel) { } else if (pendingTaskForChannel) {
prompt = buildPairedTurnPrompt( prompt = buildPairedTurnPrompt({
pendingTaskForChannel.id, taskId: pendingTaskForChannel.id,
chatJid, chatJid,
deps.timezone, timezone: deps.timezone,
missedMessages, missedMessages,
); labeledFallbackMessages: labelPairedSenders(chatJid, missedMessages),
turnOutputs: getPairedTurnOutputs(pendingTaskForChannel.id),
});
} else { } else {
prompt = formatMessages( prompt = formatMessages(
labelPairedSenders(chatJid, missedMessages), labelPairedSenders(chatJid, missedMessages),
@@ -1538,12 +1439,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
? pendingMessages ? pendingMessages
: processableGroupMessages; : processableGroupMessages;
const formatted = loopPendingTask const formatted = loopPendingTask
? buildPairedTurnPrompt( ? buildPairedTurnPrompt({
loopPendingTask.id, taskId: loopPendingTask.id,
chatJid, chatJid,
deps.timezone, timezone: deps.timezone,
messagesToSend, missedMessages: messagesToSend,
) labeledFallbackMessages: labelPairedSenders(
chatJid,
messagesToSend,
),
turnOutputs: getPairedTurnOutputs(loopPendingTask.id),
})
: formatMessages( : formatMessages(
labelPairedSenders(chatJid, messagesToSend), labelPairedSenders(chatJid, messagesToSend),
deps.timezone, deps.timezone,