Skip to content

Fix-aborts #6604

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed
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
66 changes: 66 additions & 0 deletions gui/src/redux/middleware/toolCallMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Middleware, unwrapResult } from "@reduxjs/toolkit";
import { selectCurrentToolCalls } from "../selectors/selectToolCalls";
import { setToolGenerated } from "../slices/sessionSlice";
import { AppThunkDispatch, RootState } from "../store";
import { callToolById } from "../thunks/callToolById";

/**
* Middleware that automatically executes tool calls when streaming completes.
* This provides a deterministic, race-condition-free approach by executing
* exactly when the Redux state transitions to inactive.
*/
export const toolCallMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action: any) => {
const result = next(action);

// Listen for when streaming becomes inactive
if (action && action.type === "session/setInactive") {
const state = store.getState();
const toolSettings = state.ui.toolSettings;
const allToolCallStates = selectCurrentToolCalls(state);

// Only process tool calls that are in "generating" status (newly created during this streaming session)
const toolCallStates = allToolCallStates.filter(
(toolCallState) => toolCallState.status === "generating",
);

// If no generating tool calls, nothing to process
if (toolCallStates.length === 0) {
return result;
}

// Check if ALL tool calls are auto-approved - if not, wait for user approval
const allAutoApproved = toolCallStates.every(
(toolCallState) =>
toolSettings[toolCallState.toolCall.function.name] ===
"allowedWithoutPermission",
);

// Set all tools as generated first
toolCallStates.forEach((toolCallState) => {
(store.dispatch as AppThunkDispatch)(
setToolGenerated({
toolCallId: toolCallState.toolCallId,
tools: state.config.config.tools,
}),
);
});

// Only run if we have auto-approve for all
if (allAutoApproved && toolCallStates.length > 0) {
// Execute all tool calls in parallel
const toolCallPromises = toolCallStates.map(async (toolCallState) => {
const response = await (store.dispatch as AppThunkDispatch)(
callToolById({ toolCallId: toolCallState.toolCallId }),
);
unwrapResult(response);
});

Promise.all(toolCallPromises).catch((error) => {
console.error("Error executing parallel tool calls:", error);
});
}
}

return result;
};
3 changes: 2 additions & 1 deletion gui/src/redux/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createFilter } from "redux-persist-transform-filter";
import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2";
import storage from "redux-persist/lib/storage";
import { IdeMessenger, IIdeMessenger } from "../context/IdeMessenger";
import { toolCallMiddleware } from "./middleware/toolCallMiddleware";
import configReducer from "./slices/configSlice";
import editModeStateReducer from "./slices/editState";
import indexingReducer from "./slices/indexingSlice";
Expand Down Expand Up @@ -127,7 +128,7 @@ export function setupStore(options: { ideMessenger?: IIdeMessenger }) {
ideMessenger,
},
},
}),
}).concat(toolCallMiddleware),
// This can be uncommented to get detailed Redux logs
// .concat(logger),
});
Expand Down
62 changes: 2 additions & 60 deletions gui/src/redux/thunks/streamNormalInput.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createAsyncThunk, unwrapResult } from "@reduxjs/toolkit";
import { createAsyncThunk } from "@reduxjs/toolkit";
import { LLMFullCompletionOptions, ModelDescription, Tool } from "core";
import { modelSupportsTools } from "core/llm/autodetect";
import { getRuleId } from "core/llm/rules/getSystemMessageWithRules";
Expand All @@ -12,70 +12,14 @@ import {
setActive,
setAppliedRulesAtIndex,
setInactive,
setToolGenerated,
streamUpdate,
} from "../slices/sessionSlice";
import { AppThunkDispatch, RootState, ThunkApiType } from "../store";
import { ThunkApiType } from "../store";
import {
constructMessages,
getBaseSystemMessage,
} from "../util/constructMessages";

import { selectCurrentToolCalls } from "../selectors/selectToolCalls";
import { callToolById } from "./callToolById";

/**
* Handles the execution of tool calls that may be automatically accepted.
* Sets all tools as generated first, then executes auto-approved tool calls.
*/
async function handleToolCallExecution(
dispatch: AppThunkDispatch,
getState: () => RootState,
): Promise<void> {
const newState = getState();
const toolSettings = newState.ui.toolSettings;
const allToolCallStates = selectCurrentToolCalls(newState);

// Only process tool calls that are in "generating" status (newly created during this streaming session)
const toolCallStates = allToolCallStates.filter(
(toolCallState) => toolCallState.status === "generating",
);

// If no generating tool calls, nothing to process
if (toolCallStates.length === 0) {
return;
}

// Check if ALL tool calls are auto-approved - if not, wait for user approval
const allAutoApproved = toolCallStates.every(
(toolCallState) =>
toolSettings[toolCallState.toolCall.function.name] ===
"allowedWithoutPermission",
);

// Set all tools as generated first
toolCallStates.forEach((toolCallState) => {
dispatch(
setToolGenerated({
toolCallId: toolCallState.toolCallId,
tools: newState.config.config.tools,
}),
);
});

// Only run if we have auto-approve for all
if (allAutoApproved && toolCallStates.length > 0) {
const toolCallPromises = toolCallStates.map(async (toolCallState) => {
const response = await dispatch(
callToolById({ toolCallId: toolCallState.toolCallId }),
);
unwrapResult(response);
});

await Promise.all(toolCallPromises);
}
}

/**
* Filters tools based on the selected model's capabilities.
* Returns either the edit file tool or search and replace tool, but not both.
Expand Down Expand Up @@ -236,8 +180,6 @@ export const streamNormalInput = createAsyncThunk<
}
}

await handleToolCallExecution(dispatch, getState);

dispatch(setInactive());
},
);
1 change: 0 additions & 1 deletion gui/src/redux/thunks/streamResponseAfterToolCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ export const streamResponseAfterToolCall = createAsyncThunk<
const toolOutput = toolCallState.output ?? [];

dispatch(resetNextCodeBlockToApplyIndex());
await new Promise((resolve) => setTimeout(resolve, 0));

// Create and dispatch the tool message
createAndDispatchToolMessage(dispatch, toolCallId, toolOutput);
Expand Down
Loading