fix: refresh role rules after compaction

This commit is contained in:
ejclaw
2026-05-25 22:57:52 +09:00
parent e4a0e15cf2
commit db5332e81a
9 changed files with 570 additions and 85 deletions

View File

@@ -42,10 +42,13 @@ import {
import { drainIpcInput, shouldClose } from './ipc-input.js'; import { drainIpcInput, shouldClose } from './ipc-input.js';
import { import {
MessageStream, MessageStream,
buildCompactionOutput,
buildMultimodalContent, buildMultimodalContent,
compactBoundaryOutput,
extractAssistantText, extractAssistantText,
normalizeStructuredOutput, normalizeStructuredOutput,
readStdin, readStdin,
type RunnerCompaction,
writeOutput, writeOutput,
} from './output-protocol.js'; } from './output-protocol.js';
import { import {
@@ -90,6 +93,30 @@ function log(message: string): void {
console.error(`[agent-runner] ${message}`); console.error(`[agent-runner] ${message}`);
} }
function compactBoundaryFromMessage(
message: unknown,
): RunnerCompaction | undefined {
if (
!(
typeof message === 'object' &&
message !== null &&
(message as { type?: string }).type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
)
) {
return undefined;
}
const meta = (
message as {
compact_metadata?: { trigger?: string; pre_tokens?: number };
}
).compact_metadata;
log(
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
);
return compactBoundaryOutput(meta?.trigger);
}
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로 // (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로
// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.) // glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.)
@@ -127,6 +154,7 @@ async function runQuery(
newSessionId?: string; newSessionId?: string;
closedDuringQuery: boolean; closedDuringQuery: boolean;
terminalResultObserved: boolean; terminalResultObserved: boolean;
compaction?: RunnerCompaction;
}> { }> {
const stream = new MessageStream((text) => buildMultimodalContent(text, log)); const stream = new MessageStream((text) => buildMultimodalContent(text, log));
stream.push(prompt); stream.push(prompt);
@@ -172,6 +200,7 @@ async function runQuery(
let resultCount = 0; let resultCount = 0;
let terminalResultObserved = false; let terminalResultObserved = false;
let pendingProgressText: string | null = null; let pendingProgressText: string | null = null;
let compaction: RunnerCompaction | undefined;
const trackedAgentTasks = new TopLevelAgentTaskTracker(); const trackedAgentTasks = new TopLevelAgentTaskTracker();
// Discover additional directories // Discover additional directories
@@ -360,19 +389,7 @@ async function runQuery(
log(`Session initialized: ${newSessionId}`); log(`Session initialized: ${newSessionId}`);
} }
if ( compaction = compactBoundaryFromMessage(message) ?? compaction;
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
) {
const meta = (
message as {
compact_metadata?: { trigger?: string; pre_tokens?: number };
}
).compact_metadata;
log(
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
);
}
if ( if (
message.type === 'system' && message.type === 'system' &&
@@ -522,6 +539,7 @@ async function runQuery(
result: textResult || null, result: textResult || null,
newSessionId, newSessionId,
error: errorText || `Agent error: ${message.subtype}`, error: errorText || `Agent error: ${message.subtype}`,
...buildCompactionOutput(compaction),
}); });
} else { } else {
const normalized = normalizeStructuredOutput(textResult || null); const normalized = normalizeStructuredOutput(textResult || null);
@@ -529,6 +547,7 @@ async function runQuery(
status: 'success', status: 'success',
...normalized, ...normalized,
newSessionId, newSessionId,
...buildCompactionOutput(compaction),
}); });
} }
@@ -561,6 +580,7 @@ async function runQuery(
status: 'success', status: 'success',
...normalizeStructuredOutput(textResult), ...normalizeStructuredOutput(textResult),
newSessionId, newSessionId,
...buildCompactionOutput(compaction),
}); });
terminalResultObserved = true; terminalResultObserved = true;
ipcPolling = false; ipcPolling = false;
@@ -600,6 +620,7 @@ async function runQuery(
status: 'success', status: 'success',
...normalizeStructuredOutput(pendingProgressText), ...normalizeStructuredOutput(pendingProgressText),
newSessionId, newSessionId,
...buildCompactionOutput(compaction),
}); });
terminalResultObserved = true; terminalResultObserved = true;
resultCount++; resultCount++;
@@ -609,7 +630,12 @@ async function runQuery(
log( log(
`Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`, `Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`,
); );
return { newSessionId, closedDuringQuery, terminalResultObserved }; return {
newSessionId,
closedDuringQuery,
terminalResultObserved,
compaction,
};
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -698,6 +724,7 @@ async function main(): Promise<void> {
log(`Handling session command: ${trimmedPrompt}`); log(`Handling session command: ${trimmedPrompt}`);
let slashSessionId: string | undefined; let slashSessionId: string | undefined;
let compactBoundarySeen = false; let compactBoundarySeen = false;
let slashCompaction: RunnerCompaction | undefined;
let hadError = false; let hadError = false;
let resultEmitted = false; let resultEmitted = false;
@@ -745,20 +772,10 @@ async function main(): Promise<void> {
log(`Session after slash command: ${slashSessionId}`); log(`Session after slash command: ${slashSessionId}`);
} }
// Observe compact_boundary to confirm compaction completed const observedCompaction = compactBoundaryFromMessage(message);
if ( if (observedCompaction) {
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
) {
compactBoundarySeen = true; compactBoundarySeen = true;
const meta = ( slashCompaction = observedCompaction;
message as {
compact_metadata?: { trigger?: string; pre_tokens?: number };
}
).compact_metadata;
log(
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
);
} }
if (message.type === 'result') { if (message.type === 'result') {
@@ -775,12 +792,14 @@ async function main(): Promise<void> {
result: null, result: null,
error: textResult || 'Session command failed.', error: textResult || 'Session command failed.',
newSessionId: slashSessionId, newSessionId: slashSessionId,
...buildCompactionOutput(slashCompaction),
}); });
} else { } else {
writeOutput({ writeOutput({
status: 'success', status: 'success',
result: textResult || 'Conversation compacted.', result: textResult || 'Conversation compacted.',
newSessionId: slashSessionId, newSessionId: slashSessionId,
...buildCompactionOutput(slashCompaction),
}); });
} }
resultEmitted = true; resultEmitted = true;
@@ -812,6 +831,7 @@ async function main(): Promise<void> {
? 'Conversation compacted.' ? 'Conversation compacted.'
: 'Compaction requested but compact_boundary was not observed.', : 'Compaction requested but compact_boundary was not observed.',
newSessionId: slashSessionId, newSessionId: slashSessionId,
...buildCompactionOutput(slashCompaction),
}); });
} else if (!hadError) { } else if (!hadError) {
// Emit session-only marker so host updates session tracking // Emit session-only marker so host updates session tracking
@@ -819,6 +839,7 @@ async function main(): Promise<void> {
status: 'success', status: 'success',
result: null, result: null,
newSessionId: slashSessionId, newSessionId: slashSessionId,
...buildCompactionOutput(slashCompaction),
}); });
} }
return; return;
@@ -843,7 +864,12 @@ async function main(): Promise<void> {
} }
if (!queryResult.closedDuringQuery && !queryResult.terminalResultObserved) { if (!queryResult.closedDuringQuery && !queryResult.terminalResultObserved) {
writeOutput({ status: 'success', result: null, newSessionId: sessionId }); writeOutput({
status: 'success',
result: null,
newSessionId: sessionId,
...buildCompactionOutput(queryResult.compaction),
});
} else if (queryResult.terminalResultObserved) { } else if (queryResult.terminalResultObserved) {
log('Terminal result already emitted, exiting single-turn runtime'); log('Terminal result already emitted, exiting single-turn runtime');
} else { } else {

View File

@@ -18,6 +18,24 @@ export interface RunnerOutput {
output?: RunnerStructuredOutput; output?: RunnerStructuredOutput;
newSessionId?: string; newSessionId?: string;
error?: string; error?: string;
compaction?: {
completed: boolean;
trigger?: string | null;
};
}
export type RunnerCompaction = NonNullable<RunnerOutput['compaction']>;
export function buildCompactionOutput(
compaction: RunnerCompaction | undefined,
): Pick<RunnerOutput, 'compaction'> {
return compaction ? { compaction } : {};
}
export function compactBoundaryOutput(
trigger: string | null | undefined,
): RunnerCompaction {
return { completed: true, trigger: trigger || null };
} }
type ContentBlock = type ContentBlock =

View File

@@ -63,6 +63,10 @@ interface RunnerOutput {
phase?: 'progress' | 'final'; phase?: 'progress' | 'final';
newSessionId?: string; newSessionId?: string;
error?: string; error?: string;
compaction?: {
completed: boolean;
trigger?: string | null;
};
} }
// ── Constants ────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────
@@ -85,6 +89,12 @@ function writeOutput(output: RunnerOutput): void {
writeProtocolOutput(output); writeProtocolOutput(output);
} }
function compactionOutput(
completed: boolean | undefined,
): Pick<RunnerOutput, 'compaction'> {
return completed ? { compaction: { completed: true, trigger: null } } : {};
}
function normalizeStructuredOutput(result: string | null): { function normalizeStructuredOutput(result: string | null): {
result: string | null; result: string | null;
output?: RunnerOutput['output']; output?: RunnerOutput['output'];
@@ -212,7 +222,11 @@ async function executeAppServerTurn(
threadId: string, threadId: string,
prompt: string, prompt: string,
retryCount = 0, retryCount = 0,
): Promise<{ result: string | null; error?: string }> { ): Promise<{
result: string | null;
error?: string;
compactionCompleted?: boolean;
}> {
let lastProgressMessage: string | null = null; let lastProgressMessage: string | null = null;
const activeTurn = await client.startTurn( const activeTurn = await client.startTurn(
threadId, threadId,
@@ -282,10 +296,10 @@ async function executeAppServerTurn(
try { try {
const { state, result } = await activeTurn.wait(); const { state, result } = await activeTurn.wait();
if (state.status === 'completed') { if (state.status === 'completed') {
return { result }; return { result, compactionCompleted: state.compactionCompleted };
} }
if (state.status === 'interrupted' && consumeCloseSentinel()) { if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result }; return { result, compactionCompleted: state.compactionCompleted };
} }
if (state.status === 'interrupted' && retryCount < 1) { if (state.status === 'interrupted' && retryCount < 1) {
log('Codex turn interrupted, retrying once...'); log('Codex turn interrupted, retrying once...');
@@ -295,6 +309,7 @@ async function executeAppServerTurn(
result, result,
error: error:
state.errorMessage || `Codex turn finished with status ${state.status}`, state.errorMessage || `Codex turn finished with status ${state.status}`,
compactionCompleted: state.compactionCompleted,
}; };
} finally { } finally {
polling = false; polling = false;
@@ -320,6 +335,7 @@ async function runAppServerCompact(
result: null, result: null,
newSessionId: threadId, newSessionId: threadId,
error: state.errorMessage || 'Conversation compaction failed.', error: state.errorMessage || 'Conversation compaction failed.',
...compactionOutput(state.compactionCompleted),
}); });
return; return;
} }
@@ -330,6 +346,7 @@ async function runAppServerCompact(
? 'Conversation compacted.' ? 'Conversation compacted.'
: 'Compaction requested but contextCompaction was not observed.', : 'Compaction requested but contextCompaction was not observed.',
newSessionId: threadId, newSessionId: threadId,
...compactionOutput(state.compactionCompleted),
}); });
} }
@@ -386,7 +403,7 @@ async function runAppServerSession(
} }
log('Starting app-server turn...'); log('Starting app-server turn...');
const { result, error } = await executeAppServerTurn( const { result, error, compactionCompleted } = await executeAppServerTurn(
client, client,
threadId, threadId,
prompt, prompt,
@@ -400,6 +417,7 @@ async function runAppServerSession(
...normalized, ...normalized,
newSessionId: threadId, newSessionId: threadId,
error, error,
...compactionOutput(compactionCompleted),
}); });
} else { } else {
const normalized = normalizeStructuredOutput(result || null); const normalized = normalizeStructuredOutput(result || null);
@@ -408,6 +426,7 @@ async function runAppServerSession(
...normalized, ...normalized,
...(result ? { phase: 'final' as const } : {}), ...(result ? { phase: 'final' as const } : {}),
newSessionId: threadId, newSessionId: threadId,
...compactionOutput(compactionCompleted),
}); });
} }

View File

@@ -50,6 +50,10 @@ export interface AgentOutput {
agentDone?: boolean; agentDone?: boolean;
newSessionId?: string; newSessionId?: string;
error?: string; error?: string;
compaction?: {
completed: boolean;
trigger?: string | null;
};
} }
function readRoomSkillOverridesForRunner( function readRoomSkillOverridesForRunner(

104
src/compact-refresh.test.ts Normal file
View File

@@ -0,0 +1,104 @@
import fs from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-compact-refresh-test',
}));
import {
clearCompactRefreshIfUnchanged,
markCompactRefreshNeeded,
maybeApplyCompactRefresh,
readCompactRefreshFlag,
} from './compact-refresh.js';
const refreshDir = '/tmp/ejclaw-compact-refresh-test/compact-refresh';
describe('compact-refresh', () => {
beforeEach(() => {
fs.rmSync(refreshDir, { recursive: true, force: true });
});
it('marks and reads compact refresh flags by session folder', () => {
const flag = markCompactRefreshNeeded({
sessionFolder: 'room:reviewer',
sessionId: 'session-1',
trigger: 'auto',
});
expect(readCompactRefreshFlag('room:reviewer')).toMatchObject({
sessionFolder: 'room:reviewer',
sessionId: 'session-1',
trigger: 'auto',
compactedAt: flag.compactedAt,
});
});
it('applies a one-shot compact refresh only for owner and reviewer roles', () => {
const flag = markCompactRefreshNeeded({
sessionFolder: 'room',
sessionId: 'session-1',
});
const applied = maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'owner',
prompt: 'continue task',
});
expect(applied?.flag).toEqual(flag);
expect(applied?.prompt).toContain('EJClaw compact refresh');
expect(applied?.prompt).toContain('continue task');
clearCompactRefreshIfUnchanged({
sessionFolder: 'room',
flag,
});
expect(readCompactRefreshFlag('room')).toBeNull();
});
it('does not apply to arbiter, compact commands, fresh sessions, or stale sessions', () => {
markCompactRefreshNeeded({
sessionFolder: 'room',
sessionId: 'session-1',
});
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'arbiter',
prompt: 'judge',
}),
).toBeNull();
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'owner',
prompt: '/compact',
}),
).toBeNull();
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
role: 'owner',
prompt: 'fresh',
}),
).toBeNull();
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'different-session',
role: 'owner',
prompt: 'stale',
}),
).toBeNull();
expect(readCompactRefreshFlag('room')).toBeNull();
});
});

128
src/compact-refresh.ts Normal file
View File

@@ -0,0 +1,128 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import type { PairedRoomRole } from './types.js';
export interface CompactRefreshFlag {
sessionFolder: string;
sessionId: string;
compactedAt: string;
trigger?: string | null;
}
export interface AppliedCompactRefresh {
flag: CompactRefreshFlag;
prompt: string;
}
const COMPACT_REFRESH_DIR = path.join(DATA_DIR, 'compact-refresh');
const COMPACT_REFRESH_PROMPT = `[EJClaw compact refresh]
The previous run compacted this same SDK session. Continue following the current AGENTS.md/CLAUDE.md role rules exactly: required first-line status, paired-room role boundaries, output-only task context, concise Korean responses, and verification before completion. This is not new user scope.
[/EJClaw compact refresh]`;
function getFlagPath(sessionFolder: string): string {
return path.join(
COMPACT_REFRESH_DIR,
`${encodeURIComponent(sessionFolder)}.json`,
);
}
function isRefreshableRole(role: PairedRoomRole): boolean {
return role === 'owner' || role === 'reviewer';
}
function isSessionCommand(prompt: string): boolean {
return prompt.trim() === '/compact';
}
export function readCompactRefreshFlag(
sessionFolder: string,
): CompactRefreshFlag | null {
const flagPath = getFlagPath(sessionFolder);
if (!fs.existsSync(flagPath)) return null;
try {
const parsed = JSON.parse(
fs.readFileSync(flagPath, 'utf-8'),
) as Partial<CompactRefreshFlag>;
if (
parsed.sessionFolder !== sessionFolder ||
typeof parsed.sessionId !== 'string' ||
parsed.sessionId.length === 0 ||
typeof parsed.compactedAt !== 'string'
) {
fs.rmSync(flagPath, { force: true });
return null;
}
return {
sessionFolder,
sessionId: parsed.sessionId,
compactedAt: parsed.compactedAt,
trigger:
typeof parsed.trigger === 'string' && parsed.trigger.length > 0
? parsed.trigger
: null,
};
} catch {
fs.rmSync(flagPath, { force: true });
return null;
}
}
export function markCompactRefreshNeeded(args: {
sessionFolder: string;
sessionId: string;
trigger?: string | null;
}): CompactRefreshFlag {
const flag: CompactRefreshFlag = {
sessionFolder: args.sessionFolder,
sessionId: args.sessionId,
compactedAt: new Date().toISOString(),
trigger: args.trigger ?? null,
};
fs.mkdirSync(COMPACT_REFRESH_DIR, { recursive: true });
fs.writeFileSync(
getFlagPath(args.sessionFolder),
`${JSON.stringify(flag)}\n`,
);
return flag;
}
export function clearCompactRefreshIfUnchanged(args: {
sessionFolder: string;
flag: CompactRefreshFlag;
}): void {
const current = readCompactRefreshFlag(args.sessionFolder);
if (
current &&
current.sessionId === args.flag.sessionId &&
current.compactedAt === args.flag.compactedAt
) {
fs.rmSync(getFlagPath(args.sessionFolder), { force: true });
}
}
export function maybeApplyCompactRefresh(args: {
sessionFolder: string;
sessionId?: string;
role: PairedRoomRole;
prompt: string;
}): AppliedCompactRefresh | null {
if (!args.sessionId) return null;
if (!isRefreshableRole(args.role)) return null;
if (isSessionCommand(args.prompt)) return null;
const flag = readCompactRefreshFlag(args.sessionFolder);
if (!flag) return null;
if (flag.sessionId !== args.sessionId) {
fs.rmSync(getFlagPath(args.sessionFolder), { force: true });
return null;
}
return {
flag,
prompt: `${COMPACT_REFRESH_PROMPT}\n\n${args.prompt}`,
};
}

View File

@@ -0,0 +1,114 @@
import fs from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-message-compact-refresh-test',
}));
vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(),
}));
vi.mock('./codex-token-rotation.js', () => ({
getCodexAccountCount: vi.fn(() => 1),
}));
vi.mock('./session-recovery.js', () => ({
shouldResetCodexSessionOnAgentFailure: vi.fn(() => false),
shouldResetSessionOnAgentFailure: vi.fn(() => false),
shouldRetryFreshCodexSessionOnAgentFailure: vi.fn(() => false),
}));
import { runAgentProcess, type AgentOutput } from './agent-runner.js';
import {
maybeApplyCompactRefresh,
readCompactRefreshFlag,
} from './compact-refresh.js';
import { runMessageAgentAttempt } from './message-agent-executor-attempt-runner.js';
import type { RegisteredGroup } from './types.js';
const dataDir = '/tmp/ejclaw-message-compact-refresh-test';
const group: RegisteredGroup = {
name: 'Room',
folder: 'room',
trigger: '@Andy',
added_at: new Date().toISOString(),
agentType: 'claude-code',
};
function makeLifecycle() {
return {
updateSummary: vi.fn(),
recordFinalOutputBeforeDelivery: vi.fn(() => true),
};
}
function makeLogger(): any {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
};
logger.child.mockReturnValue(logger);
return logger;
}
describe('message agent compact refresh', () => {
beforeEach(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
vi.resetAllMocks();
});
it('marks compacted owner sessions so the next turn gets one minimal refresh', async () => {
const compactedOutput: AgentOutput = {
status: 'success',
result: 'done',
output: { visibility: 'public', text: 'done' },
newSessionId: 'session-1',
compaction: { completed: true, trigger: 'auto' },
};
vi.mocked(runAgentProcess).mockResolvedValue(compactedOutput);
await runMessageAgentAttempt({
provider: 'claude',
currentSessionId: 'session-1',
isClaudeCodeAgent: true,
canRetryClaudeCredentials: false,
shouldPersistSession: true,
effectiveGroup: group,
agentInput: {
prompt: 'work',
sessionId: 'session-1',
groupFolder: 'room',
chatJid: 'room@test',
runId: 'run-1',
isMain: false,
assistantName: 'Andy',
},
activeRole: 'owner',
effectiveServiceId: 'claude',
effectiveAgentType: 'claude-code',
sessionFolder: 'room',
onPersistSession: vi.fn(),
registerProcess: vi.fn(),
pairedExecutionLifecycle: makeLifecycle(),
log: makeLogger(),
});
expect(readCompactRefreshFlag('room')).toMatchObject({
sessionId: 'session-1',
trigger: 'auto',
});
const applied = maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'owner',
prompt: 'next work',
});
expect(applied?.prompt).toContain('EJClaw compact refresh');
expect(applied?.prompt).toContain('next work');
});
});

View File

@@ -3,6 +3,7 @@ import type { Logger } from 'pino';
import { createEvaluatedOutputHandler } from './agent-attempt.js'; import { createEvaluatedOutputHandler } from './agent-attempt.js';
import type { AttemptStreamedTrigger } from './agent-attempt-retry.js'; import type { AttemptStreamedTrigger } from './agent-attempt-retry.js';
import { runAgentProcess, type AgentOutput } from './agent-runner.js'; import { runAgentProcess, type AgentOutput } from './agent-runner.js';
import { markCompactRefreshNeeded } from './compact-refresh.js';
import { getCodexAccountCount } from './codex-token-rotation.js'; import { getCodexAccountCount } from './codex-token-rotation.js';
import type { PreparedPairedExecutionContext } from './paired-execution-context.js'; import type { PreparedPairedExecutionContext } from './paired-execution-context.js';
import { import {
@@ -34,6 +35,35 @@ interface AgentInput {
roomRoleContext?: RoomRoleContext; roomRoleContext?: RoomRoleContext;
} }
function maybeMarkCompactRefreshForOutput(args: {
output: AgentOutput;
activeRole: string;
sessionFolder: string;
}): void {
if (
(args.activeRole !== 'owner' && args.activeRole !== 'reviewer') ||
args.output.compaction?.completed !== true ||
!args.output.newSessionId
) {
return;
}
markCompactRefreshNeeded({
sessionFolder: args.sessionFolder,
sessionId: args.output.newSessionId,
trigger: args.output.compaction.trigger ?? null,
});
}
function createProviderLog(
log: Logger,
provider: 'claude' | 'codex',
agentType: AgentType,
): Logger {
const providerLog = log.child({ provider, agentType });
providerLog.info('Using provider');
return providerLog;
}
export async function runMessageAgentAttempt(args: { export async function runMessageAgentAttempt(args: {
provider: 'claude' | 'codex'; provider: 'claude' | 'codex';
currentSessionId: string | undefined; currentSessionId: string | undefined;
@@ -111,6 +141,7 @@ export async function runMessageAgentAttempt(args: {
structuredOutput, structuredOutput,
evaluation, evaluation,
}) => { }) => {
maybeMarkCompactRefreshForOutput({ output, activeRole, sessionFolder });
const outputPhase = output.phase ?? 'final'; const outputPhase = output.phase ?? 'final';
if (outputPhase !== 'final') { if (outputPhase !== 'final') {
log.info( log.info(
@@ -252,11 +283,7 @@ export async function runMessageAgentAttempt(args: {
await streamedOutputHandler.handleOutput(output); await streamedOutputHandler.handleOutput(output);
}; };
const providerLog = log.child({ const providerLog = createProviderLog(log, provider, effectiveAgentType);
provider,
agentType: effectiveAgentType,
});
providerLog.info('Using provider');
try { try {
const output = await runAgentProcess( const output = await runAgentProcess(
@@ -273,6 +300,7 @@ export async function runMessageAgentAttempt(args: {
if (output.newSessionId && shouldPersistSession) { if (output.newSessionId && shouldPersistSession) {
onPersistSession(output.newSessionId); onPersistSession(output.newSessionId);
} }
maybeMarkCompactRefreshForOutput({ output, activeRole, sessionFolder });
providerLog.info( providerLog.info(
{ {

View File

@@ -1,5 +1,6 @@
import { AgentOutput } from './agent-runner.js'; import { AgentOutput } from './agent-runner.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import type { Logger } from 'pino';
import type { AgentTriggerReason } from './agent-error-detection.js'; import type { AgentTriggerReason } from './agent-error-detection.js';
import { getMoaConfig } from './config.js'; import { getMoaConfig } from './config.js';
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js'; import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
@@ -12,9 +13,82 @@ import { collectMoaReferences, formatMoaReferencesForPrompt } from './moa.js';
import { readArbiterPrompt } from './platform-prompts.js'; import { readArbiterPrompt } from './platform-prompts.js';
import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js'; import { shouldRetryFreshSessionOnAgentFailure } from './session-recovery.js';
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js'; import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
import {
clearCompactRefreshIfUnchanged,
maybeApplyCompactRefresh,
type AppliedCompactRefresh,
} from './compact-refresh.js';
// ── Main executor ───────────────────────────────────────────────── // ── Main executor ─────────────────────────────────────────────────
function buildPromptWithCompactRefresh(args: {
prompt: string;
sessionFolder: string;
sessionId?: string;
role: PairedRoomRole;
}): { effectivePrompt: string; compactRefresh: AppliedCompactRefresh | null } {
const compactRefresh = maybeApplyCompactRefresh({
sessionFolder: args.sessionFolder,
sessionId: args.sessionId,
role: args.role,
prompt: args.prompt,
});
return {
effectivePrompt: compactRefresh?.prompt ?? args.prompt,
compactRefresh,
};
}
function clearAppliedCompactRefreshAfterSuccess(args: {
result: 'success' | 'error';
sessionFolder: string;
compactRefresh: AppliedCompactRefresh | null;
}): void {
if (args.result !== 'success' || !args.compactRefresh) return;
clearCompactRefreshIfUnchanged({
sessionFolder: args.sessionFolder,
flag: args.compactRefresh.flag,
});
}
async function enrichArbiterPromptWithMoa(args: {
prompt: string;
enabled: boolean;
log: Pick<Logger, 'info' | 'warn'>;
}): Promise<string> {
const moaConfig = getMoaConfig();
if (!args.enabled || !moaConfig.enabled) return args.prompt;
args.log.info(
{ models: moaConfig.referenceModels.map((m) => m.name) },
'MoA: collecting reference opinions before arbiter',
);
const systemPrompt =
readArbiterPrompt(process.cwd()) || 'You are an arbiter.';
try {
const references = await collectMoaReferences({
config: moaConfig,
systemPrompt,
contextPrompt: args.prompt,
});
const moaSection = formatMoaReferencesForPrompt(references);
if (!moaSection) return args.prompt;
args.log.info(
{
successCount: references.filter((r) => !r.error).length,
totalCount: references.length,
},
'MoA: injected reference opinions into arbiter prompt',
);
return args.prompt + '\n' + moaSection;
} catch (err) {
args.log.warn(
{ err, models: moaConfig.referenceModels.map((m) => m.name) },
'MoA: failed to collect reference opinions; continuing without enrichment',
);
return args.prompt;
}
}
export interface MessageAgentExecutorDeps { export interface MessageAgentExecutorDeps {
assistantName: string; assistantName: string;
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'> & queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'> &
@@ -81,54 +155,18 @@ export async function runAgentForGroup(
clearRoleSdkSessions(); clearRoleSdkSessions();
} }
// ── MoA prompt enrichment ───────────────────────────────────── const moaEnrichedPrompt = await enrichArbiterPromptWithMoa({
// When MoA is enabled and we're in arbiter mode, query external API prompt,
// models (Kimi, GLM, etc.) in parallel for their opinions, then inject enabled: arbiterMode && Boolean(pairedExecutionContext),
// those opinions into the arbiter's prompt. The SDK-based arbiter log,
// agent naturally aggregates all perspectives.
let moaEnrichedPrompt = prompt;
const moaConfig = getMoaConfig();
if (arbiterMode && moaConfig.enabled && pairedExecutionContext) {
log.info(
{
models: moaConfig.referenceModels.map((m) => m.name),
},
'MoA: collecting reference opinions before arbiter',
);
const systemPrompt =
readArbiterPrompt(process.cwd()) || 'You are an arbiter.';
try {
const references = await collectMoaReferences({
config: moaConfig,
systemPrompt,
contextPrompt: prompt,
}); });
const moaSection = formatMoaReferencesForPrompt(references); const { effectivePrompt, compactRefresh } = buildPromptWithCompactRefresh({
if (moaSection) { prompt: moaEnrichedPrompt,
moaEnrichedPrompt = prompt + '\n' + moaSection; sessionFolder,
log.info( sessionId: currentSessionId,
{ role: activeRole,
successCount: references.filter((r) => !r.error).length, });
totalCount: references.length,
},
'MoA: injected reference opinions into arbiter prompt',
);
}
} catch (err) {
log.warn(
{
err,
models: moaConfig.referenceModels.map((m) => m.name),
},
'MoA: failed to collect reference opinions; continuing without enrichment',
);
}
}
const effectivePrompt = moaEnrichedPrompt;
const pairedExecutionLifecycle = createPairedExecutionLifecycle({ const pairedExecutionLifecycle = createPairedExecutionLifecycle({
pairedExecutionContext, pairedExecutionContext,
pairedTurnIdentity: runtimePairedTurnIdentity, pairedTurnIdentity: runtimePairedTurnIdentity,
@@ -237,7 +275,7 @@ export async function runAgentForGroup(
}); });
try { try {
return await executeMessageAgentAttemptLifecycle({ const result = await executeMessageAgentAttemptLifecycle({
provider, provider,
runAttempt, runAttempt,
isClaudeCodeAgent, isClaudeCodeAgent,
@@ -260,6 +298,12 @@ export async function runAgentForGroup(
}, },
log, log,
}); });
clearAppliedCompactRefreshAfterSuccess({
result,
sessionFolder,
compactRefresh,
});
return result;
} finally { } finally {
await pairedExecutionLifecycle.asyncFinalize(); await pairedExecutionLifecycle.asyncFinalize();
} }