Simplify paired turn routing and prompt builders
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
141
src/message-runtime-prompts.ts
Normal file
141
src/message-runtime-prompts.ts
Normal 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}`;
|
||||
}
|
||||
132
src/message-runtime-rules.test.ts
Normal file
132
src/message-runtime-rules.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user