fix(tools): register get_location in availableTools and refine location UX

This commit is contained in:
Marco Beretta 2026-06-15 19:19:09 +02:00
parent 398661d340
commit f2379f7327
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
9 changed files with 110 additions and 36 deletions

View file

@ -12,7 +12,7 @@ const TraversaalSearch = require('./structured/TraversaalSearch');
const createOpenAIImageTools = require('./structured/OpenAIImageTools');
const TavilySearchResults = require('./structured/TavilySearchResults');
const createGeminiImageTool = require('./structured/GeminiImageGen');
const createLocationTool = require('./structured/GetLocation');
const GetLocation = require('./structured/GetLocation');
module.exports = {
...manifest,
@ -28,5 +28,5 @@ module.exports = {
TavilySearchResults,
createOpenAIImageTools,
createGeminiImageTool,
createLocationTool,
GetLocation,
};

View file

@ -171,7 +171,7 @@
"name": "Get Location",
"pluginKey": "get_location",
"description": "Returns the user's shared location (place, coordinates, timezone) so the assistant can tailor language, units, and regional context.",
"icon": "assets/google-search.svg",
"icon": "assets/logo.svg",
"authConfig": []
}
]

View file

@ -1,4 +1,4 @@
const { tool } = require('@librechat/agents/langchain/tools');
const { Tool } = require('@librechat/agents/langchain/tools');
const { formatLocationToolResult } = require('@librechat/api');
const locationSchema = {
@ -8,22 +8,31 @@ const locationSchema = {
};
/**
* Factory for the `get_location` tool, bound to the current request/user.
* @param {{ userId?: string, req?: import('express').Request }} params
* @returns {Promise<import('@librechat/agents/langchain/tools').DynamicStructuredTool>}
* GetLocation - returns the user's shared location (place, coordinates, timezone).
* Reads the resolved app config (admin feature flag) and the user's stored
* `personalization.location` from the request, and delegates formatting to
* `formatLocationToolResult`. Gracefully reports when disabled or not shared.
*/
module.exports = async function createLocationTool({ req } = {}) {
return tool(
async () => {
const featureEnabled = req?.config?.location?.enabled !== false;
const location = req?.user?.personalization?.location;
return formatLocationToolResult(location, { featureEnabled });
},
{
name: 'get_location',
description:
"Returns the user's current location (place, coordinates, timezone) when they have shared it. Use it to tailor language, regional context, units, or weather lookups.",
schema: locationSchema,
},
);
};
class GetLocation extends Tool {
constructor(fields = {}) {
super();
/** @type {boolean} Used to initialize the Tool without request context. */
this.override = fields.override ?? false;
this.req = fields.req;
this.userId = fields.userId;
this.name = 'get_location';
this.description =
"Returns the user's current location (place, coordinates, timezone) when they have shared it. Use it to tailor language, regional context, units, or weather lookups.";
this.schema = locationSchema;
}
async _call() {
const featureEnabled = this.req?.config?.location?.enabled !== false;
const location = this.req?.user?.personalization?.location;
return formatLocationToolResult(location, { featureEnabled });
}
}
module.exports = GetLocation;

View file

@ -1,14 +1,13 @@
const createLocationTool = require('./GetLocation');
const GetLocation = require('./GetLocation');
const makeReq = ({ location, featureEnabled = true } = {}) => ({
config: { location: { enabled: featureEnabled } },
user: { id: 'user-1', personalization: location ? { location } : {} },
});
describe('createLocationTool', () => {
describe('GetLocation tool', () => {
it('returns the user location when enabled', async () => {
const tool = await createLocationTool({
userId: 'user-1',
const tool = new GetLocation({
req: makeReq({
location: {
enabled: true,
@ -24,17 +23,22 @@ describe('createLocationTool', () => {
});
it('returns a not-shared message when the user has not opted in', async () => {
const tool = await createLocationTool({ userId: 'user-1', req: makeReq({}) });
const tool = new GetLocation({ req: makeReq({}) });
const result = await tool.invoke({});
expect(result).toMatch(/has not shared/i);
});
it('returns a disabled message when the admin flag is off', async () => {
const tool = await createLocationTool({
userId: 'user-1',
const tool = new GetLocation({
req: makeReq({ location: { enabled: true, manual: 'X' }, featureEnabled: false }),
});
const result = await tool.invoke({});
expect(result).toMatch(/disabled/i);
});
it('can be constructed without request context (override) for tool discovery', () => {
const tool = new GetLocation({ override: true });
expect(tool.name).toBe('get_location');
expect(tool.schema).toBeDefined();
});
});

View file

@ -0,0 +1,19 @@
const { Tool } = require('@librechat/agents/langchain/tools');
const GetLocation = require('../GetLocation');
describe('get_location tool registration', () => {
it('GetLocation is a Tool subclass so loadAndFormatTools discovers it', () => {
expect(GetLocation.prototype instanceof Tool).toBe(true);
});
it('can be instantiated with override for discovery without request context', () => {
const tool = new GetLocation({ override: true });
expect(tool.name).toBe('get_location');
expect(tool.schema).toBeDefined();
});
it('has a plain-object schema compatible with loadAndFormatTools (non-Zod)', () => {
const tool = new GetLocation({ override: true });
expect(tool.schema).toEqual({ type: 'object', properties: {}, required: [] });
});
});

View file

@ -34,7 +34,7 @@ const {
TavilySearchResults,
createGeminiImageTool,
createOpenAIImageTools,
createLocationTool,
GetLocation,
} = require('../');
const {
createMCPTool,
@ -182,6 +182,7 @@ const loadTools = async ({
google: GoogleSearchAPI,
open_weather: OpenWeather,
wolfram: StructuredWolfram,
get_location: GetLocation,
'stable-diffusion': StructuredSD,
'azure-ai-search': StructuredACS,
traversaal_search: TraversaalSearch,
@ -231,9 +232,6 @@ const loadTools = async ({
fileStrategy,
});
},
get_location: async () => {
return createLocationTool({ userId: user, req: options.req });
},
};
const requestedTools = {};
@ -264,6 +262,7 @@ const loadTools = async ({
dalle: imageGenOptions,
'stable-diffusion': imageGenOptions,
gemini_image_gen: imageGenOptions,
get_location: { req: options.req },
};
/** @type {Record<string, string>} */

View file

@ -73,7 +73,12 @@ export default function Personalization({
const handleLocationToggle = (checked: boolean) => {
setLocationEnabled(checked);
persistLocation({ enabled: checked, source: 'manual', manual: manualLocation || undefined });
const existing = user?.personalization?.location;
persistLocation({
...(existing ?? {}),
enabled: checked,
manual: manualLocation || existing?.manual,
});
};
const handleManualBlur = () => {

View file

@ -6,9 +6,16 @@ import * as authQueries from '~/data-provider/Auth/queries';
import Personalization from '../Personalization';
const mockMutate = jest.fn();
const mockShowToast = jest.fn();
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useToastContext: () => ({ showToast: mockShowToast }),
}));
beforeEach(() => {
mockMutate.mockClear();
mockShowToast.mockClear();
jest
.spyOn(authQueries, 'useGetUserQuery')
@ -63,4 +70,28 @@ describe('Personalization location section', () => {
expect.objectContaining({ manual: 'Tokyo, Japan', source: 'manual' }),
);
});
it('shows a warning toast when geolocation is denied and does not call mutate with auto source', async () => {
const deniedError = Object.assign(new Error('denied'), {
code: 1,
PERMISSION_DENIED: 1,
});
jest
.spyOn(global.navigator.geolocation, 'getCurrentPosition')
.mockImplementation((_success, error) => error?.(deniedError as GeolocationPositionError));
render(
<Personalization hasMemoryOptOut={false} hasLocationSharing hasAnyPersonalizationFeature />,
);
const button = screen.getByText('Use my device location');
fireEvent.click(button);
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ status: 'warning' }));
expect(mockMutate).not.toHaveBeenCalledWith(expect.objectContaining({ source: 'auto' }));
expect(screen.getByLabelText('Set location manually')).toBeInTheDocument();
});
});

View file

@ -2,6 +2,13 @@ const DEFAULT_ENDPOINT = 'https://api.bigdatacloud.net/data/reverse-geocode-clie
const round = (n: number) => Math.round(n * 100) / 100;
interface BigDataCloudResponse {
city?: string;
locality?: string;
principalSubdivision?: string;
countryName?: string;
}
export interface ResolvedLocation {
place?: string;
coordinates: { latitude: number; longitude: number };
@ -31,9 +38,9 @@ export async function reverseGeocode(
if (!response.ok) {
return { coordinates, timezone };
}
const data = await response.json();
const data = (await response.json()) as BigDataCloudResponse;
const place = [data.city || data.locality, data.principalSubdivision, data.countryName]
.filter((part: unknown): part is string => typeof part === 'string' && part.length > 0)
.filter((part): part is string => typeof part === 'string' && part.length > 0)
.join(', ');
return { place: place || undefined, coordinates, timezone };
} catch {