Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 239 additions & 52 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,49 @@ import type {
import { Usage } from './usage';
import { randomUUID } from './utils/randomUUID';
import { safeParseJson } from './utils/safeParseJson';
import { groupToolCallsForParallelExecution } from './utils/toolGrouping';

const DEFAULT_MAX_TURNS = 50;
const DEFAULT_ERROR_RETRY_TURNS = 10;

const debug = createDebug('neovate:loop');

/**
* Tool execution result with unified type structure
*/
type ToolExecutionResult = {
toolCallId: string;
toolName: string;
input: any;
result: ToolResult;
approved: boolean;
deniedToolUse?: ToolUse;
};

/**
* Tool call input structure from AI model
*/
type ToolCallInput = {
toolCallId: string;
toolName: string;
input: string;
providerMetadata?: any;
};

/**
* Tool call parameters structure
*/
type ToolCallParams = {
file_path?: string;
[key: string]: any;
};

async function exponentialBackoffWithCancellation(
attempt: number,
signal?: AbortSignal,
): Promise<void> {
const baseDelay = 1000;
const delay = baseDelay * Math.pow(2, attempt - 1);
const delay = baseDelay * 2 ** (attempt - 1);
const checkInterval = 100;

const startTime = Date.now();
Expand Down Expand Up @@ -238,19 +269,14 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {

let text = '';
let reasoning = '';
const toolCalls: Array<{
providerMetadata?: any;
toolCallId: string;
toolName: string;
input: string;
}> = [];
const toolCalls: ToolCallInput[] = [];

const requestId = randomUUID();
const m: LanguageModelV2 = await opts.model._mCreator();
const tools = opts.tools.toLanguageV2Tools();

// Get thinking config based on model's reasoning capability
let thinkingConfig: Record<string, any> | undefined = undefined;
let thinkingConfig: Record<string, any> | undefined;
if (shouldThinking && opts.thinking) {
thinkingConfig = getThinkingConfig(opts.model, opts.thinking.effort);
shouldThinking = false;
Expand Down Expand Up @@ -455,7 +481,7 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
toolUse.displayName = displayName;
}
if (toolCall.providerMetadata) {
// @ts-ignore
// @ts-expect-error
toolUse.providerMetadata = toolCall.providerMetadata;
}
assistantContent.push(toolUse);
Expand All @@ -477,8 +503,48 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
break;
}

const toolResults: any[] = [];
for (const toolCall of toolCalls) {
const toolResults: ToolExecutionResult[] = [];

// Helper function to add tool results to history
const addToolResultsToHistory = async (
results: ToolExecutionResult[],
): Promise<void> => {
await history.addMessage({
role: 'tool',
content: results.map((tr) => ({
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: tr.toolName,
input: tr.input,
result: tr.result,
})),
} as any);
};

// Helper function to handle tool denial
const handleToolDenial = async (
results: ToolExecutionResult[],
deniedResult: ToolExecutionResult,
): Promise<LoopResult> => {
await addToolResultsToHistory(results);
return {
success: false,
error: {
type: 'tool_denied',
message: 'Error: Tool execution was denied by user.',
details: {
toolUse: deniedResult.deniedToolUse,
history,
usage: totalUsage,
},
},
};
};

// Helper function to execute a single tool call
const executeSingleToolCall = async (
toolCall: ToolCallInput,
): Promise<ToolExecutionResult> => {
let toolUse: ToolUse = {
name: toolCall.toolName,
params: safeParseJson(toolCall.input),
Expand All @@ -488,7 +554,7 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
toolUse = await opts.onToolUse(toolUse as ToolUse);
}
let approved = true;
let updatedParams: ToolParams | undefined = undefined;
let updatedParams: ToolParams | undefined;

if (opts.onToolApprove) {
const approvalResult = await opts.onToolApprove(toolUse as ToolUse);
Expand All @@ -501,7 +567,6 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
}

if (approved) {
toolCallsCount++;
if (updatedParams) {
toolUse.params = { ...toolUse.params, ...updatedParams };
}
Expand All @@ -512,14 +577,13 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
if (opts.onToolResult) {
toolResult = await opts.onToolResult(toolUse, toolResult, approved);
}
toolResults.push({
return {
toolCallId: toolUse.callId,
toolName: toolUse.name,
input: toolUse.params,
result: toolResult,
});
// Prevent normal turns from being terminated due to exceeding the limit
turnsCount--;
approved: true,
};
} else {
const message = 'Error: Tool execution was denied by user.';
let toolResult: ToolResult = {
Expand All @@ -529,51 +593,174 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
if (opts.onToolResult) {
toolResult = await opts.onToolResult(toolUse, toolResult, approved);
}
toolResults.push({
return {
toolCallId: toolUse.callId,
toolName: toolUse.name,
input: toolUse.params,
result: toolResult,
});
await history.addMessage({
role: 'tool',
content: toolResults.map((tr) => {
approved: false,
deniedToolUse: toolUse,
};
}
};

// Group tool calls for parallel execution
const toolGroups = groupToolCallsForParallelExecution(
toolCalls.map((tc) => ({
...tc,
params: safeParseJson(tc.input) as ToolCallParams,
})),
);

// Debug: Log grouping information
debug(
'Tool calls grouped: %d total calls -> %d groups',
toolCalls.length,
toolGroups.length,
);
toolGroups.forEach((group, index) => {
debug(
'Group %d: %s, %d tools [%s]',
index,
group.canExecuteInParallel
? group.isReadOnly
? 'parallel/read-only'
: 'parallel/write'
: 'sequential',
group.toolCalls.length,
group.toolCalls.map((tc) => tc.toolName).join(', '),
);
});

for (const group of toolGroups) {
// Execute based on canExecuteInParallel flag
// The grouping logic already handles read-only vs write conflicts
if (!group.canExecuteInParallel) {
// Execute sequentially
debug('Executing group sequentially: %d tools', group.toolCalls.length);
for (const toolCall of group.toolCalls) {
const result = await executeSingleToolCall(toolCall);

if (result.approved) {
toolCallsCount++;
toolResults.push(result);
// Prevent normal turns from being terminated due to exceeding the limit
turnsCount--;
} else {
toolResults.push(result);
return handleToolDenial(toolResults, result);
}
}
} else {
// Execute in parallel - use group-level approval
debug(
'Executing group in parallel: %d tools [%s]',
group.toolCalls.length,
group.toolCalls.map((tc) => tc.toolName).join(', '),
);
// First, get approval for the first tool in the group
// This will also handle session-level approval settings (autoEdit, etc.)
const firstToolCall = group.toolCalls[0];
let firstToolUse: ToolUse = {
name: firstToolCall.toolName,
params: safeParseJson(firstToolCall.input),
callId: firstToolCall.toolCallId,
};
if (opts.onToolUse) {
firstToolUse = await opts.onToolUse(firstToolUse as ToolUse);
}

let groupApproved = true;
let updatedParams: ToolParams | undefined;

if (opts.onToolApprove) {
const approvalResult = await opts.onToolApprove(
firstToolUse as ToolUse,
);
if (typeof approvalResult === 'object') {
groupApproved = approvalResult.approved;
updatedParams = approvalResult.params;
} else {
groupApproved = approvalResult;
}
}

// If group is denied, create error result and return
if (!groupApproved) {
const message = 'Error: Tool execution was denied by user.';
let toolResult: ToolResult = {
llmContent: message,
isError: true,
};
if (opts.onToolResult) {
toolResult = await opts.onToolResult(
firstToolUse,
toolResult,
false,
);
}
const deniedResult: ToolExecutionResult = {
toolCallId: firstToolUse.callId,
toolName: firstToolUse.name,
input: firstToolUse.params,
result: toolResult,
approved: false,
deniedToolUse: firstToolUse,
};
toolResults.push(deniedResult);
return handleToolDenial(toolResults, deniedResult);
}

// Group is approved - execute all tools in parallel without individual approval
const groupStartTime = Date.now();
const groupResults = await Promise.all(
group.toolCalls.map(async (toolCall, index) => {
let toolUse: ToolUse = {
name: toolCall.toolName,
params: safeParseJson(toolCall.input),
callId: toolCall.toolCallId,
};
if (opts.onToolUse) {
toolUse = await opts.onToolUse(toolUse as ToolUse);
}

// Apply updated params to first tool if provided
if (index === 0 && updatedParams) {
toolUse.params = { ...toolUse.params, ...updatedParams };
}

// Execute without approval check (group already approved)
let toolResult = await opts.tools.invoke(
toolUse.name,
JSON.stringify(toolUse.params),
);
if (opts.onToolResult) {
toolResult = await opts.onToolResult(toolUse, toolResult, true);
}
return {
type: 'tool-result',
toolCallId: tr.toolCallId,
toolName: tr.toolName,
input: tr.input,
result: tr.result,
toolCallId: toolUse.callId,
toolName: toolUse.name,
input: toolUse.params,
result: toolResult,
approved: true,
};
}),
} as any);
return {
success: false,
error: {
type: 'tool_denied',
message,
details: {
toolUse,
history,
usage: totalUsage,
},
},
};
);

// All tools succeeded
const groupDuration = Date.now() - groupStartTime;
debug(
'Parallel execution completed in %dms: %d tools',
groupDuration,
groupResults.length,
);
toolCallsCount += groupResults.length;
turnsCount -= groupResults.length;
toolResults.push(...groupResults);
}
}
if (toolResults.length) {
await history.addMessage({
role: 'tool',
content: toolResults.map((tr) => {
return {
type: 'tool-result',
toolCallId: tr.toolCallId,
toolName: tr.toolName,
input: tr.input,
result: tr.result,
};
}),
} as any);
await addToolResultsToHistory(toolResults);
}
}
const duration = Date.now() - startTime;
Expand Down
Loading
Loading