feat: auto-continue turns when Codex responds without tool execution
Track whether tool execution occurred during each turn via approval request notifications. If a turn completes with only text (no commands or file changes), automatically start a follow-up turn nudging Codex to execute rather than just describe plans. Max 5 auto-continues per user message to prevent infinite loops. Also strengthen instructions.md with execution-first policy.
This commit is contained in:
@@ -62,6 +62,8 @@ const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
|||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
const IPC_POLL_MS = 500;
|
||||||
const MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
|
const MAX_AUTO_CONTINUES = 5;
|
||||||
|
const AUTO_CONTINUE_PROMPT = 'Continue. Execute the task — don\'t just describe what you\'ll do.';
|
||||||
|
|
||||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||||
@@ -386,11 +388,12 @@ async function executeTurn(
|
|||||||
server: CodexAppServer,
|
server: CodexAppServer,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<{ result: string; error?: string; turnId: string }> {
|
): Promise<{ result: string; error?: string; turnId: string; hadToolExecution: boolean }> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let agentText = '';
|
let agentText = '';
|
||||||
let turnId = '';
|
let turnId = '';
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
let hadToolExecution = false;
|
||||||
let ipcPollTimer: ReturnType<typeof setTimeout> | null = null;
|
let ipcPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -412,6 +415,7 @@ async function executeTurn(
|
|||||||
result: agentText || '',
|
result: agentText || '',
|
||||||
error: 'Turn timed out after 5 minutes',
|
error: 'Turn timed out after 5 minutes',
|
||||||
turnId,
|
turnId,
|
||||||
|
hadToolExecution,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
@@ -474,9 +478,9 @@ async function executeTurn(
|
|||||||
|
|
||||||
if (status === 'failed') {
|
if (status === 'failed') {
|
||||||
const errMsg = (error?.message as string) || 'Turn failed';
|
const errMsg = (error?.message as string) || 'Turn failed';
|
||||||
resolve({ result: agentText || '', error: errMsg, turnId });
|
resolve({ result: agentText || '', error: errMsg, turnId, hadToolExecution });
|
||||||
} else {
|
} else {
|
||||||
resolve({ result: agentText, turnId });
|
resolve({ result: agentText, turnId, hadToolExecution });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -496,6 +500,7 @@ async function executeTurn(
|
|||||||
if (msg.method === 'item/commandExecution/requestApproval' ||
|
if (msg.method === 'item/commandExecution/requestApproval' ||
|
||||||
msg.method === 'item/fileChange/requestApproval' ||
|
msg.method === 'item/fileChange/requestApproval' ||
|
||||||
msg.method === 'item/permissions/requestApproval') {
|
msg.method === 'item/permissions/requestApproval') {
|
||||||
|
hadToolExecution = true;
|
||||||
server.respond(msg.id, { decision: 'accept' });
|
server.respond(msg.id, { decision: 'accept' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -519,6 +524,7 @@ async function executeTurn(
|
|||||||
result: '',
|
result: '',
|
||||||
error: `Failed to start turn: ${err.message}`,
|
error: `Failed to start turn: ${err.message}`,
|
||||||
turnId: '',
|
turnId: '',
|
||||||
|
hadToolExecution: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -569,6 +575,7 @@ async function main(): Promise<void> {
|
|||||||
: await server.startThread();
|
: await server.startThread();
|
||||||
|
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
|
let autoContinueCount = 0;
|
||||||
|
|
||||||
// Main turn loop
|
// Main turn loop
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -583,9 +590,36 @@ async function main(): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Starting turn ${turnCount}/${MAX_TURNS}...`);
|
log(`Starting turn ${turnCount}/${MAX_TURNS} (auto-continue: ${autoContinueCount}/${MAX_AUTO_CONTINUES})...`);
|
||||||
|
|
||||||
const { result, error } = await executeTurn(server, threadId, prompt);
|
const { result, error, hadToolExecution } = await executeTurn(server, threadId, prompt);
|
||||||
|
|
||||||
|
// Check close sentinel
|
||||||
|
if (shouldClose()) {
|
||||||
|
// Flush any pending output before exiting
|
||||||
|
if (result) {
|
||||||
|
writeOutput({ status: 'success', result, newSessionId: threadId });
|
||||||
|
}
|
||||||
|
log('Close sentinel detected, exiting');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-continue: if the turn produced only text (no tool execution),
|
||||||
|
// nudge Codex to actually execute instead of just describing plans.
|
||||||
|
// This mimics `codex exec --full-auto` behavior.
|
||||||
|
if (!error && !hadToolExecution && result && autoContinueCount < MAX_AUTO_CONTINUES) {
|
||||||
|
autoContinueCount++;
|
||||||
|
log(`Turn had no tool execution, auto-continuing (${autoContinueCount}/${MAX_AUTO_CONTINUES})`);
|
||||||
|
// Still emit the intermediate text so user sees progress
|
||||||
|
writeOutput({ status: 'success', result, newSessionId: threadId });
|
||||||
|
prompt = AUTO_CONTINUE_PROMPT;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset auto-continue counter when tools were actually executed
|
||||||
|
if (hadToolExecution) {
|
||||||
|
autoContinueCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
log(`Turn error: ${error}`);
|
log(`Turn error: ${error}`);
|
||||||
@@ -603,12 +637,6 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check close sentinel
|
|
||||||
if (shouldClose()) {
|
|
||||||
log('Close sentinel detected, exiting');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Turn done, waiting for next IPC message...');
|
log('Turn done, waiting for next IPC message...');
|
||||||
|
|
||||||
const nextMessage = await waitForIpcMessage();
|
const nextMessage = await waitForIpcMessage();
|
||||||
@@ -619,6 +647,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
log(`Got new message (${nextMessage.length} chars)`);
|
log(`Got new message (${nextMessage.length} chars)`);
|
||||||
prompt = nextMessage;
|
prompt = nextMessage;
|
||||||
|
autoContinueCount = 0; // Reset on new user message
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
Reference in New Issue
Block a user