Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app/src/services/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ class ChatService {
}, 30000);
this.pendingRequests.set(requestId, { resolve: resolve as any, reject, timer });

const payload: any = { type: 'send_message', message: content, requestId };
const payload: any = { type: 'send_message', message: content, requestId, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
if (conversationId) {
// Defense-in-depth: validate conversationId format on the client side too
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(conversationId)) {
Expand Down
14 changes: 7 additions & 7 deletions backend/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ When the user asks to create, update, or delete events, use the corresponding to

Always confirm what you're about to do before taking action. Be concise and helpful. All times to be displayed to the user must be in their local time always unless they specify otherwise.`;

export async function createCalendarAgent(accessToken: string) {
export async function createCalendarAgent(accessToken: string, timezone?: string) {
const llm = createLLM();

const tools = [
createGetCurrentDateTimeTool(),
createGetCurrentDateTimeTool(timezone),
createListCalendarsTool(accessToken),
createListEventsTool(accessToken),
createSearchEventsTool(accessToken),
createListEventsTool(accessToken, timezone),
createSearchEventsTool(accessToken, timezone),
createGetFreeBusyTool(accessToken),
createCreateEventTool(),
createUpdateEventTool(),
createCreateEventTool(timezone),
createUpdateEventTool(timezone),
createDeleteEventTool(),
createDateTimeMathTool(),
createDateTimeMathTool(timezone),
];

const agent = createReactAgent({
Expand Down
5 changes: 3 additions & 2 deletions backend/src/agent/tools/createEventTool.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';

export function createCreateEventTool() {
export function createCreateEventTool(clientTimezone?: string) {
return tool(
async (input) => {
return JSON.stringify({
pendingAction: true,
actionType: 'create_event',
payload: input,
payload: { ...input, timeZone: input.timeZone || clientTimezone },
});
},
{
Expand All @@ -20,6 +20,7 @@ export function createCreateEventTool() {
calendarId: z.string().describe('Calendar ID. Use "primary" for default calendar.'),
description: z.string().optional().describe('Event description'),
location: z.string().optional().describe('Event location'),
timeZone: z.string().optional().describe('IANA timezone for the event (e.g. "America/New_York"). Defaults to user\'s browser timezone.'),
}),
}
);
Expand Down
7 changes: 5 additions & 2 deletions backend/src/agent/tools/dateTimeMathTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { DateTime, Duration } from 'luxon';

export function createDateTimeMathTool() {
export function createDateTimeMathTool(clientTimezone?: string) {
return tool(
async (input) => {
const { operation } = input;

switch (operation) {
case 'add':
case 'subtract': {
const dt = DateTime.fromISO(input.datetime!);
let dt = DateTime.fromISO(input.datetime!);
if (clientTimezone && !input.datetime!.match(/[+-]\d{2}:\d{2}$|Z$/)) {
dt = DateTime.fromISO(input.datetime!, { zone: clientTimezone });
}
if (!dt.isValid) return JSON.stringify({ error: `Invalid datetime: ${input.datetime}` });

const dur = Duration.fromObject({
Expand Down
6 changes: 4 additions & 2 deletions backend/src/agent/tools/getCurrentDateTimeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { DateTime } from 'luxon';

export function createGetCurrentDateTimeTool() {
export function createGetCurrentDateTimeTool(clientTimezone?: string) {
return tool(
async () => {
const now = DateTime.now();
const now = clientTimezone
? DateTime.now().setZone(clientTimezone)
: DateTime.now();

return JSON.stringify({
date: now.toFormat('yyyy-MM-dd'),
Expand Down
12 changes: 6 additions & 6 deletions backend/src/agent/tools/listEventsTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ import { DateTime } from 'luxon';
import { calendarService } from '../../services/calendar.service';
import { CalendarEvent } from '../../types';

function convertEventTimesToLocal(events: CalendarEvent[]): CalendarEvent[] {
const localZone = DateTime.now().zoneName;
function convertEventTimesToLocal(events: CalendarEvent[], clientTimezone?: string): CalendarEvent[] {
const zone = clientTimezone || DateTime.now().zoneName;
return events.map((event) => ({
...event,
start: event.start?.dateTime
? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(localZone).toISO()! }
? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(zone).toISO()! }
: event.start,
end: event.end?.dateTime
? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(localZone).toISO()! }
? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(zone).toISO()! }
: event.end,
}));
}

export function createListEventsTool(accessToken: string) {
export function createListEventsTool(accessToken: string, clientTimezone?: string) {
const oauth2Client = new google.auth.OAuth2();
oauth2Client.setCredentials({ access_token: accessToken });

Expand All @@ -28,7 +28,7 @@ export function createListEventsTool(accessToken: string) {
timeMin: input.timeMin,
timeMax: input.timeMax,
});
return JSON.stringify(convertEventTimesToLocal(events));
return JSON.stringify(convertEventTimesToLocal(events, clientTimezone));
},
{
name: 'list_events',
Expand Down
12 changes: 6 additions & 6 deletions backend/src/agent/tools/searchEventsTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ import { DateTime } from 'luxon';
import { calendarService } from '../../services/calendar.service';
import { CalendarEvent } from '../../types';

function convertEventTimesToLocal(events: CalendarEvent[]): CalendarEvent[] {
const localZone = DateTime.now().zoneName;
function convertEventTimesToLocal(events: CalendarEvent[], clientTimezone?: string): CalendarEvent[] {
const zone = clientTimezone || DateTime.now().zoneName;
return events.map((event) => ({
...event,
start: event.start?.dateTime
? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(localZone).toISO()! }
? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(zone).toISO()! }
: event.start,
end: event.end?.dateTime
? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(localZone).toISO()! }
? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(zone).toISO()! }
: event.end,
}));
}

export function createSearchEventsTool(accessToken: string) {
export function createSearchEventsTool(accessToken: string, clientTimezone?: string) {
const oauth2Client = new google.auth.OAuth2();
oauth2Client.setCredentials({ access_token: accessToken });

Expand All @@ -33,7 +33,7 @@ export function createSearchEventsTool(accessToken: string) {
timeMax: input.timeMax,
}
);
return JSON.stringify(convertEventTimesToLocal(events));
return JSON.stringify(convertEventTimesToLocal(events, clientTimezone));
},
{
name: 'search_events',
Expand Down
5 changes: 3 additions & 2 deletions backend/src/agent/tools/updateEventTool.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';

export function createUpdateEventTool() {
export function createUpdateEventTool(clientTimezone?: string) {
return tool(
async (input) => {
return JSON.stringify({
pendingAction: true,
actionType: 'update_event',
payload: input,
payload: { ...input, timeZone: input.timeZone || clientTimezone },
});
},
{
Expand All @@ -21,6 +21,7 @@ export function createUpdateEventTool() {
endDateTime: z.string().optional().describe('New end date/time in ISO 8601 format with timezone offset (e.g. "2026-01-30T10:00:00-05:00"). Use the utcOffset from get_current_datetime.'),
description: z.string().optional().describe('New event description'),
location: z.string().optional().describe('New event location'),
timeZone: z.string().optional().describe('IANA timezone for the event (e.g. "America/New_York"). Defaults to user\'s browser timezone.'),
}),
}
);
Expand Down
10 changes: 6 additions & 4 deletions backend/src/services/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ const MUTATING_ACTIONS: Record<string, ActionType> = {
*/
async function callAgent(
langchainMessages: BaseMessage[],
accessToken: string
accessToken: string,
timezone?: string
): Promise<{
reply: string;
toolCalls: { name: string; params: Record<string, any>; description: string }[];
}> {
const agent = await createCalendarAgent(accessToken);
const agent = await createCalendarAgent(accessToken, timezone);

const result = await agent.invoke({ messages: langchainMessages });

Expand Down Expand Up @@ -62,7 +63,8 @@ export class ChatService {
userId: string,
conversationId: string | null,
message: string,
accessToken: string
accessToken: string,
timezone?: string
): Promise<ChatResponse> {
// Load or create conversation
let convId = conversationId;
Expand Down Expand Up @@ -92,7 +94,7 @@ export class ChatService {
const langchainMessages = await chatHistory.getMessages();

// Call agent
const agentResult = await callAgent(langchainMessages, accessToken);
const agentResult = await callAgent(langchainMessages, accessToken, timezone);

// Save assistant reply (manual persistence — no duplication since agent
// has no checkpointer and does not auto-persist)
Expand Down
4 changes: 4 additions & 0 deletions backend/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,16 @@ export function setupWebSocket(server: HttpServer): WebSocketServer {
return;
}

// Client timezone (IANA, e.g. "America/New_York")
const timezone = typeof parsed.timezone === 'string' ? parsed.timezone : undefined;

try {
const result = await chatService.sendMessage(
userId,
conversationId,
message,
accessToken,
timezone,
);
const data: any = { ...result };
if (result.pendingAction) {
Expand Down