diff --git a/Cargo.lock b/Cargo.lock index 59301fe34c4..859158836ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12251,6 +12251,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "reminders" version = "0.1.0" dependencies = [ + "ai_toolset", + "anyhow", + "async-trait", "aws-sdk-sqs", "axum", "chrono", @@ -12268,6 +12271,7 @@ dependencies = [ "model_user", "notification", "rootcause", + "schemars 1.2.1", "serde", "serde_json", "sqlx", diff --git a/apps/web/src/lib/core/component/AI/component/tool/Reminders.tsx b/apps/web/src/lib/core/component/AI/component/tool/Reminders.tsx new file mode 100644 index 00000000000..666db2c8455 --- /dev/null +++ b/apps/web/src/lib/core/component/AI/component/tool/Reminders.tsx @@ -0,0 +1,213 @@ +import { formatDateAndTime } from '@entity'; +import BellSimple from '@phosphor-icons/core/regular/bell-simple.svg'; +import Check from '@phosphor-icons/core/regular/check.svg'; +import Trash from '@phosphor-icons/core/regular/trash.svg'; +import type { NamedTool } from '@service-cognition/generated/tools/tool'; +import type { + ListReminders as ListRemindersTool, + ReminderEntityType, + UpdateReminder as UpdateReminderTool, +} from '@service-cognition/generated/tools/types'; +import { createSignal, For, Show } from 'solid-js'; +import { BaseTool } from './BaseTool'; +import { Tool } from './Tool'; +import { createToolRenderer } from './ToolRenderer'; + +type ToolReminder = NamedTool< + 'ListReminders', + 'response' +>['data']['reminders'][number]; + +const ENTITY_TYPE_LABELS: Record = { + document: 'a document', + ai_chat: 'a chat', + project: 'a project', + email: 'an email thread', + channel: 'a channel', + call: 'a call', + calendar_event: 'a calendar event', +}; + +/** What the list call asked for, in the same voice as the notification tools. */ +const formatReminderFilters = (filters: ListRemindersTool) => { + if (filters.reminderIds?.length) { + const count = filters.reminderIds.length; + return `${count} reminder${count === 1 ? '' : 's'} by id`; + } + + const parts = [filters.completed ? 'done' : 'not done']; + if (filters.overdue != null) { + parts.push(filters.overdue ? 'overdue' : 'upcoming'); + } + + let text = `filtered by ${parts.join(' and ')}`; + if (filters.entityType) { + text += ` for ${ENTITY_TYPE_LABELS[filters.entityType]}`; + } + return text; +}; + +/** + * What an update actually changed, so the row is readable without expanding + * the arguments. Reads off the request rather than the response because the + * response is the merged reminder and no longer says which fields moved. + */ +const formatReminderUpdate = (update: UpdateReminderTool) => { + const changes: string[] = []; + if (update.completed === true) changes.push('mark done'); + if (update.completed === false) changes.push('reopen'); + if (update.remindAt) + changes.push(`move to ${formatDateAndTime(update.remindAt)}`); + if (update.description != null) changes.push('reword'); + return changes.length > 0 ? changes.join(', ') : 'update'; +}; + +const ReminderList = (props: { reminders: ToolReminder[] }) => ( + +
+ + {(reminder) => ( + }> +
+ + {reminder.description} + + + {reminder.overdue ? 'Overdue · ' : ''} + {formatDateAndTime(reminder.nextRunAt)} + +
+
+ )} +
+
+
+); + +const listRemindersHandler = createToolRenderer({ + name: 'ListReminders', + render: (ctx) => { + const [isExpanded, setIsExpanded] = createSignal(false); + const reminders = () => ctx.response?.data.reminders ?? []; + const hasResults = () => reminders().length > 0; + const statusText = () => { + if (!ctx.response) return undefined; + const count = reminders().length; + if (count === 0) return 'No Results'; + return `${count} reminder${count === 1 ? '' : 's'}`; + }; + + return ( + + ) : undefined + } + > +
+
+ Read reminders + setIsExpanded((expanded) => !expanded)} + showToggle={hasResults()} + status={statusText()} + /> +
+
+ {formatReminderFilters(ctx.tool.data)} +
+
+
+ ); + }, +}); + +const createReminderHandler = createToolRenderer({ + name: 'CreateReminder', + render: (ctx) => ( + +
+
+ + {ctx.response ? 'Created reminder' : 'Create reminder'} + + + {ctx.response?.data.description ?? ctx.tool.data.description} + +
+
+ {formatDateAndTime( + ctx.response?.data.nextRunAt ?? ctx.tool.data.remindAt + )} + + {(entityType) => <> · about {ENTITY_TYPE_LABELS[entityType()]}} + +
+
+
+ ), +}); + +const updateReminderHandler = createToolRenderer({ + name: 'UpdateReminder', + render: (ctx) => ( + +
+
+ + {ctx.response ? 'Updated reminder' : 'Update reminder'} + + + {(description) => ( + {description()} + )} + +
+
+ {formatReminderUpdate(ctx.tool.data)} + + {(nextRunAt) => <> · fires {formatDateAndTime(nextRunAt())}} + +
+
+
+ ), +}); + +const deleteReminderHandler = createToolRenderer({ + name: 'DeleteReminder', + render: (ctx) => ( + + {ctx.response ? 'Deleted reminder' : 'Delete reminder'} + + ), +}); + +export { + createReminderHandler, + deleteReminderHandler, + listRemindersHandler, + updateReminderHandler, +}; diff --git a/apps/web/src/lib/core/component/AI/component/tool/handler.tsx b/apps/web/src/lib/core/component/AI/component/tool/handler.tsx index 568772869e5..c8f54eb5a29 100644 --- a/apps/web/src/lib/core/component/AI/component/tool/handler.tsx +++ b/apps/web/src/lib/core/component/AI/component/tool/handler.tsx @@ -48,6 +48,12 @@ import { readContentHandler } from './ReadContent'; import { readMetadataHandler } from './ReadMetadata'; import { readProjectHandler } from './ReadProject'; import { readThreadHandler } from './ReadThread'; +import { + createReminderHandler, + deleteReminderHandler, + listRemindersHandler, + updateReminderHandler, +} from './Reminders'; import { renameDocumentHandler } from './RenameDocument'; import { contentSearchHandler, nameSearchHandler } from './Search'; import { listSkillsHandler, searchSkillsHandler } from './SearchSkills'; @@ -81,6 +87,7 @@ const toolHandlers: ToolHandlerMap = { ListLabels: listLabelsHandler, ListSkills: listSkillsHandler, ListNotifications: listNotificationsHandler, + ListReminders: listRemindersHandler, ListTags: listTagsHandler, ListTeamMembers: listTeamMembersHandler, LoadTools: loadToolsHandler, @@ -92,7 +99,9 @@ const toolHandlers: ToolHandlerMap = { ContentSearch: contentSearchHandler, CreateDocument: createDocumentHandler, CreateProject: createProjectHandler, + CreateReminder: createReminderHandler, CreateTag: createTagHandler, + DeleteReminder: deleteReminderHandler, DeleteTag: deleteTagHandler, EditDocument: editDocumentHandler, EditTag: editTagHandler, @@ -117,6 +126,7 @@ const toolHandlers: ToolHandlerMap = { BulkSetEntityPropertyOptions: bulkSetEntityPropertyOptionsHandler, Subagent: subagentHandler, TextEditorCodeExecution: textEditorCodeExecutionHandler, + UpdateReminder: updateReminderHandler, UpdateThreadLabels: updateThreadLabelsHandler, WebFetch: webFetchHandler, WebSearch: webSearchHandler, diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts b/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts index 9bc6a670b42..a9b1b453212 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts @@ -772,6 +772,85 @@ export const CreateProjectResponse = z.object({ projectName: z.string(), }); +export const CreateReminder = z.object({ + description: z.string(), + entityId: z.union([z.string().uuid(), z.null()]).optional(), + entityType: z + .union([ + z.any().superRefine((x, ctx) => { + const schemas = [ + z.literal('document'), + z.literal('ai_chat'), + z.literal('project'), + z.literal('email'), + z.literal('channel'), + z.literal('call'), + z.literal('calendar_event'), + ]; + const errors = schemas.reduce( + (errors, schema) => + ((result) => (result.error ? [...errors, result.error] : errors))( + schema.safeParse(x) + ), + [] + ); + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }); + } + }), + z.null(), + ]) + .optional(), + remindAt: z.string().datetime({ offset: true }), +}); + +export const ToolReminder = z.object({ + completed: z.boolean(), + description: z.string(), + enabled: z.boolean(), + entityId: z.union([z.string(), z.null()]).optional(), + entityType: z + .union([ + z.any().superRefine((x, ctx) => { + const schemas = [ + z.literal('document'), + z.literal('ai_chat'), + z.literal('project'), + z.literal('email'), + z.literal('channel'), + z.literal('call'), + z.literal('calendar_event'), + ]; + const errors = schemas.reduce( + (errors, schema) => + ((result) => (result.error ? [...errors, result.error] : errors))( + schema.safeParse(x) + ), + [] + ); + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }); + } + }), + z.null(), + ]) + .optional(), + id: z.string().uuid(), + nextRunAt: z.string().datetime({ offset: true }), + overdue: z.boolean(), + recurrence: z.union([z.string(), z.null()]).optional(), +}); + export const CreateTag = z.object({ color: z.any().superRefine((x, ctx) => { const schemas = [ @@ -863,6 +942,13 @@ export const DeleteImportEntityResponse = z.object({ message: z.string(), }); +export const DeleteReminder = z.object({ reminderId: z.string().uuid() }); + +export const DeleteReminderResponse = z.object({ + reminderId: z.string().uuid(), + summary: z.string(), +}); + export const DeleteTag = z.object({ id: z.string().uuid(), property_definition_id: z.string().uuid(), @@ -1751,6 +1837,93 @@ export const ListNotificationsResponse = z.object({ ), }); +export const ListReminders = z.object({ + completed: z.union([z.boolean(), z.null()]).optional(), + entityId: z.union([z.string().uuid(), z.null()]).optional(), + entityType: z + .union([ + z.any().superRefine((x, ctx) => { + const schemas = [ + z.literal('document'), + z.literal('ai_chat'), + z.literal('project'), + z.literal('email'), + z.literal('channel'), + z.literal('call'), + z.literal('calendar_event'), + ]; + const errors = schemas.reduce( + (errors, schema) => + ((result) => (result.error ? [...errors, result.error] : errors))( + schema.safeParse(x) + ), + [] + ); + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }); + } + }), + z.null(), + ]) + .optional(), + limit: z.union([z.number().int().gte(0), z.null()]).optional(), + overdue: z.union([z.boolean(), z.null()]).optional(), + reminderIds: z.union([z.array(z.string().uuid()), z.null()]).optional(), +}); + +export const ListRemindersResponse = z.object({ + reminders: z.array( + z.object({ + completed: z.boolean(), + description: z.string(), + enabled: z.boolean(), + entityId: z.union([z.string(), z.null()]).optional(), + entityType: z + .union([ + z.any().superRefine((x, ctx) => { + const schemas = [ + z.literal('document'), + z.literal('ai_chat'), + z.literal('project'), + z.literal('email'), + z.literal('channel'), + z.literal('call'), + z.literal('calendar_event'), + ]; + const errors = schemas.reduce( + (errors, schema) => + ((result) => + result.error ? [...errors, result.error] : errors)( + schema.safeParse(x) + ), + [] + ); + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }); + } + }), + z.null(), + ]) + .optional(), + id: z.string().uuid(), + nextRunAt: z.string().datetime({ offset: true }), + overdue: z.boolean(), + recurrence: z.union([z.string(), z.null()]).optional(), + }) + ), + summary: z.string(), +}); + export const ListSkills = z.record(z.any()); export const ListSkillsResponse = z.object({ @@ -3402,6 +3575,15 @@ export const TextEditorCodeExecutionResponse = z.object({ tool_use_id: z.string(), }); +export const UpdateReminder = z.object({ + completed: z.union([z.boolean(), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + remindAt: z + .union([z.string().datetime({ offset: true }), z.null()]) + .optional(), + reminderId: z.string().uuid(), +}); + export const UpdateThreadLabels = z.object({ add: z.boolean(), label_id: z.string().uuid(), diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/tools/tool.ts b/apps/web/src/lib/service-clients/service-cognition/generated/tools/tool.ts index 6c9b1be31f8..984841b7649 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/tools/tool.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/tools/tool.ts @@ -32,11 +32,16 @@ type ToolParserMap = { call: types.CreateProject; response: types.CreateProjectResponse; }; + CreateReminder: { call: types.CreateReminder; response: types.ToolReminder }; CreateTag: { call: types.CreateTag; response: types.CreateTagResponse }; DeleteImportEntity: { call: types.DeleteImportEntity; response: types.DeleteImportEntityResponse; }; + DeleteReminder: { + call: types.DeleteReminder; + response: types.DeleteReminderResponse; + }; DeleteTag: { call: types.DeleteTag; response: types.DeleteTagResponse }; DisplayResults: { call: types.DisplayResults; @@ -75,6 +80,10 @@ type ToolParserMap = { call: types.ListNotifications; response: types.ListNotificationsResponse; }; + ListReminders: { + call: types.ListReminders; + response: types.ListRemindersResponse; + }; ListSkills: { call: types.ListSkills; response: types.ListSkillsResponse }; ListTags: { call: types.ListTags; response: types.ListTagsResponse }; ListTeamMembers: { @@ -146,6 +155,7 @@ type ToolParserMap = { call: types.TextEditorCodeExecution; response: types.TextEditorCodeExecutionResponse; }; + UpdateReminder: { call: types.UpdateReminder; response: types.ToolReminder }; UpdateThreadLabels: { call: types.UpdateThreadLabels; response: types.UpdateThreadLabelsResponse; @@ -179,11 +189,19 @@ const toolParserMap = { call: schemas.CreateProject, response: schemas.CreateProjectResponse, }, + CreateReminder: { + call: schemas.CreateReminder, + response: schemas.ToolReminder, + }, CreateTag: { call: schemas.CreateTag, response: schemas.CreateTagResponse }, DeleteImportEntity: { call: schemas.DeleteImportEntity, response: schemas.DeleteImportEntityResponse, }, + DeleteReminder: { + call: schemas.DeleteReminder, + response: schemas.DeleteReminderResponse, + }, DeleteTag: { call: schemas.DeleteTag, response: schemas.DeleteTagResponse }, DisplayResults: { call: schemas.DisplayResults, @@ -231,6 +249,10 @@ const toolParserMap = { call: schemas.ListNotifications, response: schemas.ListNotificationsResponse, }, + ListReminders: { + call: schemas.ListReminders, + response: schemas.ListRemindersResponse, + }, ListSkills: { call: schemas.ListSkills, response: schemas.ListSkillsResponse, @@ -317,6 +339,10 @@ const toolParserMap = { call: schemas.TextEditorCodeExecution, response: schemas.TextEditorCodeExecutionResponse, }, + UpdateReminder: { + call: schemas.UpdateReminder, + response: schemas.ToolReminder, + }, UpdateThreadLabels: { call: schemas.UpdateThreadLabels, response: schemas.UpdateThreadLabelsResponse, @@ -358,11 +384,16 @@ type ToolDataMap = { call: types.CreateProject; response: types.CreateProjectResponse; }; + CreateReminder: { call: types.CreateReminder; response: types.ToolReminder }; CreateTag: { call: types.CreateTag; response: types.CreateTagResponse }; DeleteImportEntity: { call: types.DeleteImportEntity; response: types.DeleteImportEntityResponse; }; + DeleteReminder: { + call: types.DeleteReminder; + response: types.DeleteReminderResponse; + }; DeleteTag: { call: types.DeleteTag; response: types.DeleteTagResponse }; DisplayResults: { call: types.DisplayResults; @@ -401,6 +432,10 @@ type ToolDataMap = { call: types.ListNotifications; response: types.ListNotificationsResponse; }; + ListReminders: { + call: types.ListReminders; + response: types.ListRemindersResponse; + }; ListSkills: { call: types.ListSkills; response: types.ListSkillsResponse }; ListTags: { call: types.ListTags; response: types.ListTagsResponse }; ListTeamMembers: { @@ -472,6 +507,7 @@ type ToolDataMap = { call: types.TextEditorCodeExecution; response: types.TextEditorCodeExecutionResponse; }; + UpdateReminder: { call: types.UpdateReminder; response: types.ToolReminder }; UpdateThreadLabels: { call: types.UpdateThreadLabels; response: types.UpdateThreadLabelsResponse; diff --git a/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts b/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts index f8961b88e3d..79829db79dd 100644 --- a/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts +++ b/apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts @@ -145,6 +145,21 @@ export type CreateImportStatus = 'staged' | 'imported'; * Lifecycle of one import entity. */ export type ImportStatus = 'staged' | 'importing' | 'imported' | 'discarded'; +/** + * Entity types a reminder can be attached to. + * + * Deliberately narrower than [`EntityType`], which covers plenty of things a + * reminder has no business pointing at. The names match the ones `ListEntities` + * uses so the model sees one vocabulary across tools. + */ +export type ReminderEntityType = + | 'document' + | 'ai_chat' + | 'project' + | 'email' + | 'channel' + | 'call' + | 'calendar_event'; /** * A tag color from the fixed palette. */ @@ -1351,6 +1366,56 @@ export interface CreateProjectResponse { */ projectName: string; } +/** + * Schedule a reminder for the current user. At `remindAt` it is delivered to their Macro inbox as a notification and stays there until they mark it done. + * + * A reminder is either attached to one Macro item — so clicking it opens that item — or standalone. Attached is the common case ("remind me to reply to this email tomorrow"); standalone is for everything else ("remind me to book a flight"). + * + * Reminders are private: one is only ever delivered to its owner, and there is no way to set one for somebody else. Only one-off reminders can be created — if the user asks for a repeating one, say so rather than creating a single reminder and implying it repeats. + * + * ## Times are UTC — convert both ways + * + * Timestamps are absolute instants, in and out, while the user asks in their own timezone. Getting this wrong silently sets the reminder to the wrong hour. + * + * - **In:** resolve their wording against their local time, then convert. For America/New_York (UTC-4 in August), "3pm tomorrow" on 2026-08-12 is `"2026-08-13T19:00:00Z"`, not `"2026-08-13T15:00:00Z"`. + * - **Out:** report the response's UTC value back in their timezone — `"2026-08-13T19:00:00Z"` is "3:00 PM tomorrow". + * + * Ask for their timezone rather than assuming UTC. + * + * ## Attaching to an item + * + * Pass `entityType` and `entityId` together, using ids from ListEntities, GetThread, or search. The user must already have access to what you attach. `entityType` accepts exactly these values, and a type not on the list cannot be attached even if ListEntities returns it: + * + * - `document` — a Macro document + * - `ai_chat` — an AI chat conversation + * - `project` — a project, shown as a folder in the app + * - `email` — an email thread + * - `channel` — a chat channel + * - `call` — a call record + * - `calendar_event` — a calendar event + * + * **A channel thread needs its parent channel's id.** `channel` is on the list; `channel_thread` is not. For a thread row, pass `entityType: "channel"` with the row's `channelId` — never the thread's own `id`, which will not resolve. Put what the thread is about in the description, since that is what tells two reminders on the same channel apart. + * + * For any other unattachable type, create a standalone reminder naming the thing in the description rather than guessing at a type. + */ +export interface CreateReminder { + /** + * What to remind the user about, written as the reminder text they will read — e.g. "Reply to Dana about the Q3 budget". Max 2000 characters. + */ + description: string; + /** + * Id of the thing the reminder is about, as a UUID. Must be the id of an entity of entityType — for a channel_thread row that means its channelId, not its own id. Requires entityType. + */ + entityId?: string | null; + /** + * Type of the thing the reminder is about — one of document, ai_chat, project, email, channel, call, calendar_event. Requires entityId; omit both for a standalone reminder. + */ + entityType?: ReminderEntityType | null; + /** + * When to fire, as an RFC 3339 timestamp in UTC (e.g. "2026-08-08T14:00:00Z"). Must be in the future. Seconds are dropped, so a reminder fires on the minute. Convert from the user's local timezone before sending — see "Times are UTC" in the tool description. + */ + remindAt: string; +} /** * Create a new tag — a colored label the user can apply to documents, emails, tasks, AI chats, and projects — in the user's personal set or their team's shared set. The set is provisioned automatically the first time a tag is created. Tags are matched by label, so call ListTags first and avoid creating one whose label duplicates an existing tag in the same set. Returns the new tag's id and its set's propertyDefinitionId, which you can pass straight to SetEntityProperty (add_option_ids) to apply the tag to an item. Use this only to create a brand-new tag; to apply an existing tag to an item, use ListTags then SetEntityProperty instead. */ @@ -1473,6 +1538,30 @@ export interface DeleteImportEntityResponse { */ message: string; } +/** + * Permanently delete one of the current user's reminders, along with any notification it already produced. Get the `reminderId` from ListReminders or CreateReminder. + * + * This cannot be undone, and it is not the usual way to clear a reminder. When the user has simply dealt with one, use UpdateReminder with `completed: true` instead: that takes it off their active list but keeps it, still readable with ListReminders `completed: true` and restorable with `completed: false`. Delete is for reminders they want gone rather than finished — one set by mistake, or for something that is no longer happening. If it is not clear which they mean, mark it done. + */ +export interface DeleteReminder { + /** + * The id of the reminder to delete. + */ + reminderId: string; +} +/** + * Response from the DeleteReminder tool. + */ +export interface DeleteReminderResponse { + /** + * The id of the reminder that was deleted. + */ + reminderId: string; + /** + * A human-readable summary of the operation. + */ + summary: string; +} /** * Permanently delete a tag from the user's personal set or their team's shared set. This removes the tag from every item it is currently applied to, so it is destructive and cannot be undone — confirm with the user first. Both ids come from a ListTags result: `id` is the tag's option id, and `property_definition_id` is the propertyDefinitionId of the set that contains it. To simply remove a tag from a single item without deleting the tag itself, use SetEntityProperty with remove_option_ids instead. */ @@ -2404,6 +2493,109 @@ export interface NotificationItem { */ senderId?: string | null; } +/** + * Read the current user's reminders, soonest first. **Filtered by default: only reminders the user has not marked done**, which is what "what are my reminders" means. Pass `completed: true` for the ones they have dealt with. To re-read a reminder you already have the id for, pass it in `reminderIds`. + * + * Filters: + * - `overdue: true` / `false` — already fired and waiting on the user, or still upcoming + * - `completed: true` / `false` — dealt with, or still outstanding + * - `entityType` + `entityId` — reminders about one specific thing. `entityType` takes the same values CreateReminder accepts: document, ai_chat, project, email, channel, call, calendar_event + * + * The two flags are independent and compose: firing does not complete a reminder, so overdue and not completed is the needs-attention case, and a completed reminder never fires whether or not its time has passed. + * + * Each reminder comes back with its `id` (pass to UpdateReminder or DeleteReminder), `description`, `nextRunAt`, `overdue`, and what it is attached to. `nextRunAt` is UTC, so convert before quoting it: for America/New_York (UTC-4 in August), `"2026-08-13T19:00:00Z"` is "3:00 PM tomorrow". + * + * A `recurrence` field means the reminder repeats — rare, and currently broken: nothing in the app creates one and the dispatcher never fires them, so it sits at its `nextRunAt` without arriving. Say that rather than implying it is scheduled. + */ +export interface ListReminders { + /** + * Filter on whether the user has marked the reminder done. Defaults to false — only reminders still outstanding. Set true for ones already dealt with. + */ + completed?: boolean | null; + /** + * Return only reminders attached to the thing with this id. Requires entityType. + */ + entityId?: string | null; + /** + * Return only reminders attached to a thing of this type. Requires entityId. + */ + entityType?: ReminderEntityType | null; + /** + * Maximum number of reminders to return. Defaults to 20, capped at 100. + */ + limit?: number | null; + /** + * Filter on whether the reminder has already fired. True returns only reminders past their time, false only ones still upcoming. Omit for both. + */ + overdue?: boolean | null; + /** + * Return only these reminders, by id. Use this to re-read a reminder you already know the id of. Omit to list all of them. + */ + reminderIds?: string[] | null; +} +/** + * Response from the ListReminders tool. + */ +export interface ListRemindersResponse { + /** + * The matching reminders, soonest firing first. + */ + reminders: ToolReminder[]; + /** + * A human-readable summary of what came back. + */ + summary: string; +} +/** + * A reminder as the model sees it. + */ +export interface ToolReminder { + /** + * Whether the user has marked the reminder as dealt with. + */ + completed: boolean; + /** + * What the user wanted to be reminded about. + */ + description: string; + /** + * Whether the reminder will fire at all. A disabled reminder keeps its + * schedule but is skipped by the dispatcher. + */ + enabled: boolean; + /** + * The id of the thing the reminder is about. + */ + entityId?: string | null; + /** + * The type of thing the reminder is about, when it is about something and + * that type is one these tools name. The app can attach a reminder to + * kinds of thing this list does not cover, so `entityId` may be present + * with no `entityType` beside it — the reminder is about something, but + * not something these tools can name or filter on. + */ + entityType?: ReminderEntityType | null; + /** + * The reminder's id. Pass this to UpdateReminder or DeleteReminder. + */ + id: string; + /** + * When the reminder fires next, RFC 3339 in UTC. The user thinks in their + * own timezone — convert before quoting this back to them. + */ + nextRunAt: string; + /** + * Whether `nextRunAt` has already passed, evaluated against the server + * clock. An overdue reminder is one the user has been notified about and + * has not dealt with yet. + */ + overdue: boolean; + /** + * For a repeating reminder, its cron expression and timezone. Absent on a + * one-shot, which is everything this toolset can create. + */ + recurrence?: string | null; +} /** * List the skills the user can access, most recently updated first. Skills are markdown documents containing instructions for AI to read and follow; after finding a relevant skill, read its instructions with ReadContent using the returned document id. Use this to discover what skills exist; when looking for a specific skill by name, prefer SearchSkills. */ @@ -3790,6 +3982,47 @@ export interface TextEditorCodeExecutionResponse { content: TextEditorCodeExecutionContent; tool_use_id: string; } +/** + * Change one of the current user's reminders: reword it, move when it fires, or mark it done. Get the `reminderId` from ListReminders or CreateReminder. + * + * Pass only the fields you are changing; anything omitted is left alone. At least one must be given. + * + * - Snooze or reschedule: set `remindAt` + * - Mark done: `completed: true` — the user has dealt with it and it leaves their active list + * - Reopen: `completed: false` + * - Reword: set `description` + * + * Marking done is the normal way to clear a reminder the user has handled, and it is reversible: the reminder drops out of the default ListReminders results but is still there, readable with `completed: true` and restorable with `completed: false`. Reach for DeleteReminder only when the user wants the reminder not to exist; that cannot be undone. + * + * Two things this tool will not do. It cannot change what a reminder is attached to — create a new reminder and delete this one instead. And setting `remindAt` on a repeating reminder replaces the repetition with that single firing, so only do it if the user asked to stop it repeating. + * + * ## Times are UTC — convert both ways + * + * Timestamps are absolute instants, in and out, while the user asks in their own timezone. Getting this wrong silently sets the reminder to the wrong hour. + * + * - **In:** resolve their wording against their local time, then convert. For America/New_York (UTC-4 in August), "3pm tomorrow" on 2026-08-12 is `"2026-08-13T19:00:00Z"`, not `"2026-08-13T15:00:00Z"`. + * - **Out:** report the response's UTC value back in their timezone — `"2026-08-13T19:00:00Z"` is "3:00 PM tomorrow". + * + * Ask for their timezone rather than assuming UTC. + */ +export interface UpdateReminder { + /** + * Mark the reminder as dealt with (true) or put it back on the active list (false). + */ + completed?: boolean | null; + /** + * Replacement reminder text. Max 2000 characters. + */ + description?: string | null; + /** + * Reschedule to this RFC 3339 timestamp in UTC (e.g. "2026-08-08T14:00:00Z"). Must be in the future — to move a reminder that has already fired, give it a new future time. Convert from the user's local timezone before sending; see "Times are UTC" in the tool description. + */ + remindAt?: string | null; + /** + * The id of the reminder to change. + */ + reminderId: string; +} /** * Add or remove a single label from every message in a Gmail thread. In Gmail, nearly all inbox operations are just label add/remove operations, so this tool is the primitive for archiving, marking read/unread, starring, trashing, marking important/spam, and applying or removing custom labels. * diff --git a/crates/ai_tools/Cargo.toml b/crates/ai_tools/Cargo.toml index 451f6b014a8..822d1b66608 100644 --- a/crates/ai_tools/Cargo.toml +++ b/crates/ai_tools/Cargo.toml @@ -66,7 +66,7 @@ models_soup = { path = "../models_soup" } rand.workspace = true reqwest.workspace = true readonly_pool = { path = "../readonly_pool" } -reminders = { path = "../reminders", default-features = false, features = ["ports"] } +reminders = { path = "../reminders", default-features = false, features = ["ai_tools", "postgres"] } rootcause = { workspace = true } schemars.workspace = true search_service_client = { path = "../search_service_client" } diff --git a/crates/ai_tools/src/build_context.rs b/crates/ai_tools/src/build_context.rs index 3957b4aaa76..5f773aab82f 100644 --- a/crates/ai_tools/src/build_context.rs +++ b/crates/ai_tools/src/build_context.rs @@ -398,6 +398,10 @@ pub async fn build_tool_service_context_from_env( email_tool_context, call_tool_context, notification_tool_context, + reminders_tool_context: crate::tool_context::build_reminders_tool_context( + pool.clone(), + entity_access_service.clone(), + ), import_tool_context: ToolImportToolContext::unwired(), chat_tool_context, channel_tool_context, diff --git a/crates/ai_tools/src/lib.rs b/crates/ai_tools/src/lib.rs index 602944c139d..1864b70eeb9 100644 --- a/crates/ai_tools/src/lib.rs +++ b/crates/ai_tools/src/lib.rs @@ -29,6 +29,7 @@ use import::inbound::toolset::import_toolset; use notification::inbound::ai_tool::notification_toolset; use projects::inbound::toolset::project_toolset; use properties::inbound::toolset::properties_toolset; +use reminders::inbound::toolset::reminders_toolset; use schemas::read; use search_tools::{LoadTools, SearchTools}; use self_knowledge::SelfKnowledge; @@ -55,12 +56,13 @@ pub use tool_context::{ ToolEntityCreator, ToolForeignEntityService, ToolFrecencyService, ToolImportService, ToolImportToolContext, ToolNotificationQueue, ToolNotificationService, ToolNotificationToolContext, ToolProjectService, ToolProjectToolContext, ToolPropertiesService, - ToolPropertiesToolContext, ToolServiceContext, ToolSkillService, ToolSkillToolContext, - ToolSoupService, ToolSystemPropertiesService, ToolTeamService, ToolTeamToolContext, - ToolUserEmailService, build_channel_tool_context_with_dispatcher, - build_channel_tool_context_with_side_effects, build_channel_tool_context_without_side_effects, - build_crm_tool_context, build_project_tool_context, build_properties_service, - build_properties_tool_context, build_skill_tool_context, build_task_properties_adapter, + ToolPropertiesToolContext, ToolRemindersService, ToolRemindersToolContext, ToolServiceContext, + ToolSkillService, ToolSkillToolContext, ToolSoupService, ToolSystemPropertiesService, + ToolTeamService, ToolTeamToolContext, ToolUserEmailService, + build_channel_tool_context_with_dispatcher, build_channel_tool_context_with_side_effects, + build_channel_tool_context_without_side_effects, build_crm_tool_context, + build_project_tool_context, build_properties_service, build_properties_tool_context, + build_reminders_tool_context, build_skill_tool_context, build_task_properties_adapter, build_team_repository, build_team_tool_context, }; pub type AiToolSet = AsyncToolCollection; @@ -102,6 +104,7 @@ pub(crate) fn subagent_toolset() -> AiToolSet { pub fn all_tools() -> ToolSetWithPrompt { let toolset = subagent_toolset() .add_subtoolset::(notification_toolset()) + .add_subtoolset::(reminders_toolset()) .add_subtoolset::(email_toolset()) .add_subtoolset::(import_toolset()) .add_tool::() @@ -130,6 +133,7 @@ pub fn all_tool_frontend_schemas() -> FrontendSchemas { pub fn mcp_tools() -> ToolSetWithPrompt { let toolset = subagent_toolset() .add_subtoolset::(notification_toolset()) + .add_subtoolset::(reminders_toolset()) .add_subtoolset::(email_mcp_toolset()) .add_subtoolset::(import_toolset()) .add_tool::(); diff --git a/crates/ai_tools/src/tool_context.rs b/crates/ai_tools/src/tool_context.rs index 7ae2ca6f723..914d0729d75 100644 --- a/crates/ai_tools/src/tool_context.rs +++ b/crates/ai_tools/src/tool_context.rs @@ -40,6 +40,7 @@ use notification::domain::service::SqsNotificationIngress; use notification::inbound::ai_tool::NotificationToolContext; use projects::inbound::toolset::ProjectToolContext; use properties::inbound::toolset::PropertiesToolContext; +use reminders::inbound::toolset::RemindersToolContext; use skills::inbound::toolset::SkillToolContext; use soup::{domain::service::SoupImpl, inbound::toolset::SoupToolContext}; use std::sync::Arc; @@ -733,6 +734,31 @@ pub type ToolNotificationService = notification::domain::service::NotificationRe /// Type alias for the notification tool context. pub type ToolNotificationToolContext = NotificationToolContext; +/// Type alias for the reminders service implementation used by AI tools. +pub type ToolRemindersService = reminders::domain::service::RemindersServiceImpl< + reminders::outbound::pg_reminders_repo::PgRemindersRepo, +>; + +/// Type alias for the reminders tool context. +pub type ToolRemindersToolContext = + RemindersToolContext; + +/// Build the reminders tool context from a database pool. +/// +/// The reminder tools go through the same access receipts the HTTP API does, +/// so this needs the entity access service as well as the repository. +pub fn build_reminders_tool_context( + pool: sqlx::PgPool, + entity_access_service: Arc, +) -> ToolRemindersToolContext { + RemindersToolContext::new( + reminders::domain::service::RemindersServiceImpl::new( + reminders::outbound::pg_reminders_repo::PgRemindersRepo::new(pool), + ), + entity_access_service, + ) +} + /// Type alias for the chat service implementation used by AI tools. /// Uses an empty toolset — the read-only tool never invokes tool execution. pub type ToolChatService = ChatServiceImpl; @@ -1120,6 +1146,7 @@ pub struct ToolServiceContext { pub email_tool_context: ToolEmailToolContext, pub call_tool_context: ToolCallToolContext, pub notification_tool_context: ToolNotificationToolContext, + pub reminders_tool_context: ToolRemindersToolContext, /// Import staging/tracking tools. `unwired` in hosts that can't build /// the import service — calls there fail with a clear error. pub import_tool_context: ToolImportToolContext, diff --git a/crates/entity_access/src/domain/service.rs b/crates/entity_access/src/domain/service.rs index 0553905e405..0112b1e8a8d 100644 --- a/crates/entity_access/src/domain/service.rs +++ b/crates/entity_access/src/domain/service.rs @@ -528,6 +528,18 @@ where .await?; channel_role_result_to_permission(result) } + // Ownership is the whole access model, so the only level this can + // yield is `Owner` — a caller who is not the owner gets no row. + // `ReminderAccessExtractor` builds the same receipt straight from + // `get_access_level`; this arm is what lets a non-axum caller (an + // AI tool) mint one without reimplementing that. + EntityType::Reminder => { + let access = self.repo.get_reminder_access(entity_id, user_id).await?; + match access { + Some(access_level) => Ok(EntityPermission::AccessLevel { access_level }), + None => Err(AccessError::Unauthorized), + } + } _ => Err(AccessError::BadRequest("Unsupported entity type")), } } diff --git a/crates/entity_access/src/domain/service/test.rs b/crates/entity_access/src/domain/service/test.rs index df6d44b2d95..34bdfbdb2b6 100644 --- a/crates/entity_access/src/domain/service/test.rs +++ b/crates/entity_access/src/domain/service/test.rs @@ -116,6 +116,11 @@ impl MockRepo { self } + fn with_reminder_access(mut self, level: AccessLevel) -> Self { + self.reminder_access = Arc::new(Mutex::new(Some(level))); + self + } + fn with_team_entity_access(mut self, level: AccessLevel) -> Self { self.team_entity_access = Arc::new(Mutex::new(Some(level))); self @@ -705,6 +710,84 @@ async fn test_get_entity_permission_foreign_entity_returns_view_access_level() { )); } +/// Reminders reach `get_entity_permission` from AI tools, which mint their own +/// receipts rather than going through `ReminderAccessExtractor`. +#[tokio::test] +async fn test_get_entity_permission_reminder_returns_owner() { + let repo = MockRepo::new().with_reminder_access(AccessLevel::Owner); + let service = EntityAccessServiceImpl::new(repo); + let user_id = test_user_id(); + + let result = service + .get_entity_permission( + Some(&user_id), + "11111111-1111-1111-1111-111111111111", + EntityType::Reminder, + None, + ) + .await + .unwrap(); + + assert!(matches!( + result, + EntityPermission::AccessLevel { + access_level: AccessLevel::Owner + } + )); +} + +/// Somebody else's reminder and a reminder that does not exist are the same +/// answer, which is what keeps an id from leaking. +#[tokio::test] +async fn test_get_entity_permission_reminder_not_owned_is_unauthorized() { + let repo = MockRepo::new(); + let service = EntityAccessServiceImpl::new(repo); + let user_id = test_user_id(); + + let result = service + .get_entity_permission( + Some(&user_id), + "11111111-1111-1111-1111-111111111111", + EntityType::Reminder, + None, + ) + .await; + + assert!(matches!(result, Err(AccessError::Unauthorized))); +} + +/// The receipt an AI tool actually asks for. `OwnerAccessLevel` is the only +/// requirement a reminder can satisfy, so this is the whole gate. +#[tokio::test] +async fn test_generate_reminder_owner_receipt() { + let repo = MockRepo::new().with_reminder_access(AccessLevel::Owner); + let service = EntityAccessServiceImpl::new(repo); + let user_id = test_user_id(); + + let receipt = service + .generate_entity_access_receipt::( + &user_id, + None, + "11111111-1111-1111-1111-111111111111", + EntityType::Reminder, + ) + .await + .expect("owner should get a receipt"); + + assert_eq!(receipt.entity().entity_type, EntityType::Reminder); + assert_eq!( + receipt.entity().entity_id, + "11111111-1111-1111-1111-111111111111" + ); + assert_eq!( + receipt + .get_authenticated_user() + .expect("receipt is authenticated") + .as_ref(), + user_id.as_ref() + ); +} + #[tokio::test] async fn test_get_entity_permission_channel_returns_role() { let repo = MockRepo::new().with_channel_role(ChannelRoleResult::Role(ParticipantRole::Admin)); diff --git a/crates/memory/src/context.rs b/crates/memory/src/context.rs index 536415d1550..28ad51d3699 100644 --- a/crates/memory/src/context.rs +++ b/crates/memory/src/context.rs @@ -238,6 +238,10 @@ pub async fn build_tool_service_context( email_tool_context, call_tool_context, notification_tool_context, + reminders_tool_context: ai_tools::build_reminders_tool_context( + pool.clone(), + entity_access_service.clone(), + ), import_tool_context: ToolImportToolContext::unwired(), chat_tool_context, channel_tool_context: ai_tools::build_channel_tool_context( diff --git a/crates/reminders/Cargo.toml b/crates/reminders/Cargo.toml index cf8348bc924..7eb64fd4fc1 100644 --- a/crates/reminders/Cargo.toml +++ b/crates/reminders/Cargo.toml @@ -6,6 +6,15 @@ version = "0.1.0" [features] default = [] +# AI tools. Driving adapter like `inbound`, but for the agent loop rather than +# HTTP, so it needs neither axum nor utoipa. +ai_tools = [ + "dep:ai_toolset", + "dep:anyhow", + "dep:async-trait", + "dep:schemars", + "ports", +] # The queue worker. Transport-agnostic: it drives the domain through the # dispatch ports, so it needs an async runtime but no AWS SDK. dispatch = ["dep:serde_json", "dep:tokio", "dep:tokio-util", "ports"] @@ -25,6 +34,9 @@ ports = ["dep:entity_access", "dep:tracing"] postgres = ["dep:macro_uuid", "dep:sqlx", "outbound"] [dependencies] +ai_toolset = { path = "../ai_toolset", optional = true } +anyhow = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } aws-sdk-sqs = { workspace = true, optional = true } axum = { workspace = true, optional = true } chrono = { workspace = true, features = ["serde"] } @@ -36,6 +48,7 @@ macro_user_id = { path = "../macro_user_id" } macro_uuid = { path = "../macro_uuid", optional = true } model-entity = { path = "../model-entity" } rootcause = { workspace = true } +schemars = { workspace = true, optional = true } model-error-response = { path = "../model-error-response", optional = true } model_notifications = { path = "../model_notifications", optional = true } notification = { path = "../notification", optional = true } diff --git a/crates/reminders/src/inbound.rs b/crates/reminders/src/inbound.rs index 6c5cfe2b213..87398e18859 100644 --- a/crates/reminders/src/inbound.rs +++ b/crates/reminders/src/inbound.rs @@ -5,3 +5,6 @@ pub mod axum_router; #[cfg(feature = "dispatch")] pub mod dispatch_worker; + +#[cfg(feature = "ai_tools")] +pub mod toolset; diff --git a/crates/reminders/src/inbound/toolset/create_reminder.rs b/crates/reminders/src/inbound/toolset/create_reminder.rs new file mode 100644 index 00000000000..6a079c94d84 --- /dev/null +++ b/crates/reminders/src/inbound/toolset/create_reminder.rs @@ -0,0 +1,145 @@ +//! CreateReminder tool for scheduling a nudge for the current user. + +use ai_toolset::{AsyncTool, RequestContext, ServiceContext, ToolResult}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use entity_access::domain::ports::EntityAccessService; +use schemars::JsonSchema; +use serde::Deserialize; +use uuid::Uuid; + +use super::{ + ReminderEntityType, RemindersToolContext, ToolReminder, build_entity, reminder_error, + utc_conversion_note, +}; +use crate::domain::models::{CreateReminder as CreateReminderRequest, ReminderSchedule}; +use crate::domain::ports::RemindersService; + +/// Schedule a reminder for the current user. +#[derive(Debug, Deserialize, JsonSchema, Clone)] +#[serde(rename_all = "camelCase")] +#[schemars( + title = "CreateReminder", + description = concat!( + "\ +Schedule a reminder for the current user. At `remindAt` it is delivered to their Macro inbox \ +as a notification and stays there until they mark it done.\n\ +\n\ +A reminder is either attached to one Macro item — so clicking it opens that item — or \ +standalone. Attached is the common case (\"remind me to reply to this email tomorrow\"); \ +standalone is for everything else (\"remind me to book a flight\").\n\ +\n\ +Reminders are private: one is only ever delivered to its owner, and there is no way to set \ +one for somebody else. Only one-off reminders can be created — if the user asks for a \ +repeating one, say so rather than creating a single reminder and implying it repeats.\n\ +\n", + utc_conversion_note!(), + "\n\ +\n\ +## Attaching to an item\n\ +\n\ +Pass `entityType` and `entityId` together, using ids from ListEntities, GetThread, or search. \ +The user must already have access to what you attach. `entityType` accepts exactly these \ +values, and a type not on the list cannot be attached even if ListEntities returns it:\n\ +\n\ +- `document` — a Macro document\n\ +- `ai_chat` — an AI chat conversation\n\ +- `project` — a project, shown as a folder in the app\n\ +- `email` — an email thread\n\ +- `channel` — a chat channel\n\ +- `call` — a call record\n\ +- `calendar_event` — a calendar event\n\ +\n\ +**A channel thread needs its parent channel's id.** `channel` is on the list; \ +`channel_thread` is not. For a thread row, pass `entityType: \"channel\"` with the row's \ +`channelId` — never the thread's own `id`, which will not resolve. Put what the thread is \ +about in the description, since that is what tells two reminders on the same channel apart.\n\ +\n\ +For any other unattachable type, create a standalone reminder naming the thing in the \ +description rather than guessing at a type." + ) +)] +pub struct CreateReminder { + /// What to remind the user about. + #[schemars( + description = "What to remind the user about, written as the reminder text they will \ + read — e.g. \"Reply to Dana about the Q3 budget\". Max 2000 characters." + )] + pub description: String, + + /// When the reminder fires. + #[schemars(description = "When to fire, as an RFC 3339 timestamp in UTC (e.g. \ + \"2026-08-08T14:00:00Z\"). Must be in the future. Seconds are dropped, \ + so a reminder fires on the minute. Convert from the user's local \ + timezone before sending — see \"Times are UTC\" in the tool \ + description.")] + pub remind_at: DateTime, + + /// Type of the entity to attach the reminder to. Requires `entityId`. + #[schemars( + description = "Type of the thing the reminder is about — one of document, ai_chat, \ + project, email, channel, call, calendar_event. Requires entityId; omit \ + both for a standalone reminder." + )] + #[serde(default)] + pub entity_type: Option, + + /// Id of the entity to attach the reminder to. Requires `entityType`. + #[schemars( + description = "Id of the thing the reminder is about, as a UUID. Must be the id of an \ + entity of entityType — for a channel_thread row that means its \ + channelId, not its own id. Requires entityType." + )] + #[serde(default)] + pub entity_id: Option, +} + +#[async_trait] +impl AsyncTool> for CreateReminder +where + S: RemindersService, + E: EntityAccessService, +{ + type Output = ToolReminder; + + #[tracing::instrument(skip_all, fields( + user_id = ?request_context.user_id, + remind_at = %self.remind_at, + entity_type = ?self.entity_type, + ), err)] + async fn call( + &self, + service_context: ServiceContext>, + request_context: RequestContext, + ) -> ToolResult { + tracing::info!("Create reminder"); + + let user_id = &request_context.user_id; + let entity = build_entity(self.entity_type, self.entity_id)?; + + // A standalone reminder points at nothing, so there is no access to + // prove. When there is an entity, the receipt is the only way its id + // reaches the service — the request itself cannot name one. + let entity_receipt = match &entity { + Some(entity) => Some(service_context.entity_receipt(user_id, entity).await?), + None => None, + }; + + let reminder = service_context + .service + .create_reminder( + user_id, + CreateReminderRequest { + description: self.description.clone(), + schedule: ReminderSchedule::Once { + remind_at: self.remind_at, + }, + }, + entity_receipt, + ) + .await + .map_err(reminder_error)?; + + Ok(ToolReminder::new(reminder, Utc::now())) + } +} diff --git a/crates/reminders/src/inbound/toolset/delete_reminder.rs b/crates/reminders/src/inbound/toolset/delete_reminder.rs new file mode 100644 index 00000000000..4e4ecee7a42 --- /dev/null +++ b/crates/reminders/src/inbound/toolset/delete_reminder.rs @@ -0,0 +1,79 @@ +//! DeleteReminder tool for removing one of the current user's reminders. + +use ai_toolset::{AsyncTool, RequestContext, ServiceContext, ToolResult}; +use async_trait::async_trait; +use entity_access::domain::ports::EntityAccessService; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{RemindersToolContext, reminder_error}; +use crate::domain::ports::RemindersService; + +/// Delete one of the current user's reminders. +#[derive(Debug, Deserialize, JsonSchema, Clone)] +#[serde(rename_all = "camelCase")] +#[schemars( + title = "DeleteReminder", + description = "\ +Permanently delete one of the current user's reminders, along with any notification it \ +already produced. Get the `reminderId` from ListReminders or CreateReminder.\n\ +\n\ +This cannot be undone, and it is not the usual way to clear a reminder. When the user has \ +simply dealt with one, use UpdateReminder with `completed: true` instead: that takes it off \ +their active list but keeps it, still readable with ListReminders `completed: true` and \ +restorable with `completed: false`. Delete is for reminders they want gone rather than \ +finished — one set by mistake, or for something that is no longer happening. If it is not \ +clear which they mean, mark it done." +)] +pub struct DeleteReminder { + /// The reminder to delete. + #[schemars(description = "The id of the reminder to delete.")] + pub reminder_id: Uuid, +} + +/// Response from the DeleteReminder tool. +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeleteReminderResponse { + /// The id of the reminder that was deleted. + pub reminder_id: Uuid, + /// A human-readable summary of the operation. + pub summary: String, +} + +#[async_trait] +impl AsyncTool> for DeleteReminder +where + S: RemindersService, + E: EntityAccessService, +{ + type Output = DeleteReminderResponse; + + #[tracing::instrument(skip_all, fields( + user_id = ?request_context.user_id, + reminder_id = %self.reminder_id, + ), err)] + async fn call( + &self, + service_context: ServiceContext>, + request_context: RequestContext, + ) -> ToolResult { + tracing::info!("Delete reminder"); + + let receipt = service_context + .owner_receipt(&request_context.user_id, self.reminder_id) + .await?; + + service_context + .service + .delete_reminder(receipt) + .await + .map_err(reminder_error)?; + + Ok(DeleteReminderResponse { + reminder_id: self.reminder_id, + summary: "Reminder deleted.".to_string(), + }) + } +} diff --git a/crates/reminders/src/inbound/toolset/list_reminders.rs b/crates/reminders/src/inbound/toolset/list_reminders.rs new file mode 100644 index 00000000000..371ac96fdce --- /dev/null +++ b/crates/reminders/src/inbound/toolset/list_reminders.rs @@ -0,0 +1,200 @@ +//! ListReminders tool for reading the current user's reminders. + +use ai_toolset::{AsyncTool, RequestContext, ServiceContext, ToolResult}; +use async_trait::async_trait; +use chrono::Utc; +use entity_access::domain::ports::EntityAccessService; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ReminderEntityType, RemindersToolContext, ToolReminder, build_entity, reminder_error}; +use crate::domain::models::{SoupOrder, SoupReminderQuery, entity_token}; +use crate::domain::ports::RemindersService; + +/// How many reminders come back when the caller does not say. +const DEFAULT_LIMIT: u32 = 20; + +/// The most any one call will return, however large a `limit` is asked for. +const MAX_LIMIT: u32 = 100; + +/// Read the current user's reminders. +#[derive(Debug, Deserialize, JsonSchema, Clone, Default)] +#[serde(rename_all = "camelCase")] +#[schemars( + title = "ListReminders", + description = "\ +Read the current user's reminders, soonest first. **Filtered by default: only reminders the \ +user has not marked done**, which is what \"what are my reminders\" means. Pass \ +`completed: true` for the ones they have dealt with. To re-read a reminder you already have \ +the id for, pass it in `reminderIds`.\n\ +\n\ +Filters:\n\ +- `overdue: true` / `false` — already fired and waiting on the user, or still upcoming\n\ +- `completed: true` / `false` — dealt with, or still outstanding\n\ +- `entityType` + `entityId` — reminders about one specific thing. `entityType` takes the same \ +values CreateReminder accepts: document, ai_chat, project, email, channel, call, \ +calendar_event\n\ +\n\ +The two flags are independent and compose: firing does not complete a reminder, so overdue \ +and not completed is the needs-attention case, and a completed reminder never fires whether \ +or not its time has passed.\n\ +\n\ +Each reminder comes back with its `id` (pass to UpdateReminder or DeleteReminder), \ +`description`, `nextRunAt`, `overdue`, and what it is attached to. `nextRunAt` is UTC, so \ +convert before quoting it: for America/New_York (UTC-4 in August), `\"2026-08-13T19:00:00Z\"` \ +is \"3:00 PM tomorrow\".\n\ +\n\ +A `recurrence` field means the reminder repeats — rare, and currently broken: nothing in the \ +app creates one and the dispatcher never fires them, so it sits at its `nextRunAt` without \ +arriving. Say that rather than implying it is scheduled." +)] +pub struct ListReminders { + /// Restrict to these reminder ids. + #[schemars( + description = "Return only these reminders, by id. Use this to re-read a reminder you \ + already know the id of. Omit to list all of them." + )] + #[serde(default)] + pub reminder_ids: Option>, + + /// Restrict to reminders attached to an entity of this type. Requires + /// `entity_id`. + #[schemars( + description = "Return only reminders attached to a thing of this type. Requires \ + entityId." + )] + #[serde(default)] + pub entity_type: Option, + + /// Restrict to reminders attached to this entity. Requires `entity_type`. + #[schemars( + description = "Return only reminders attached to the thing with this id. Requires \ + entityType." + )] + #[serde(default)] + pub entity_id: Option, + + /// Filter on whether the owner marked the reminder done. Defaults to + /// outstanding reminders only. + #[schemars( + description = "Filter on whether the user has marked the reminder done. Defaults to \ + false — only reminders still outstanding. Set true for ones already \ + dealt with." + )] + #[serde(default)] + pub completed: Option, + + /// Filter on whether the reminder has come due. `None` returns both. + #[schemars( + description = "Filter on whether the reminder has already fired. True returns only \ + reminders past their time, false only ones still upcoming. Omit for \ + both." + )] + #[serde(default)] + pub overdue: Option, + + /// Page size, clamped into range. + #[schemars( + description = "Maximum number of reminders to return. Defaults to 20, capped at 100." + )] + #[serde(default)] + pub limit: Option, +} + +/// Response from the ListReminders tool. +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListRemindersResponse { + /// The matching reminders, soonest firing first. + pub reminders: Vec, + /// A human-readable summary of what came back. + pub summary: String, +} + +#[async_trait] +impl AsyncTool> for ListReminders +where + S: RemindersService, + E: EntityAccessService, +{ + type Output = ListRemindersResponse; + + #[tracing::instrument(skip_all, fields( + user_id = ?request_context.user_id, + completed = ?self.completed, + overdue = ?self.overdue, + ), err)] + async fn call( + &self, + service_context: ServiceContext>, + request_context: RequestContext, + ) -> ToolResult { + tracing::info!("List reminders"); + + // No access check on the entity filter: this only narrows the caller's + // own reminders, so an id they cannot see simply matches nothing. + let entity = build_entity(self.entity_type, self.entity_id)?; + let entities: Vec = entity.iter().map(entity_token).collect(); + let ids = self.reminder_ids.clone().unwrap_or_default(); + let limit = self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + + let reminders = service_context + .service + .list_reminders_for_soup( + &request_context.user_id, + SoupReminderQuery { + ids: &ids, + entities: &entities, + // Outstanding reminders are what the question almost always + // means, so default to those rather than to everything. + completed: Some(self.completed.unwrap_or(false)), + fired: self.overdue, + // Soonest first, and with no cursor this picks the rows + // rather than merely arranging them: the other direction + // would return the furthest-future reminders and never an + // overdue one. + order: SoupOrder::SoonestFirst, + limit: i64::from(limit), + }, + ) + .await + .map_err(reminder_error)?; + + let now = Utc::now(); + let reminders: Vec = reminders + .into_iter() + .map(|r| ToolReminder::new(r.reminder, now)) + .collect(); + + let summary = build_summary(&reminders, limit); + Ok(ListRemindersResponse { reminders, summary }) + } +} + +/// Say what came back, and say when it was cut short. +/// +/// A full page is indistinguishable from a complete list otherwise, and a model +/// that cannot tell will happily report "you have 20 reminders" when there are +/// eighty. +pub(super) fn build_summary(reminders: &[ToolReminder], limit: u32) -> String { + if reminders.is_empty() { + return "No reminders match.".to_string(); + } + + let count = reminders.len(); + let overdue = reminders.iter().filter(|r| r.overdue).count(); + let plural = if count == 1 { "" } else { "s" }; + + let mut summary = if overdue > 0 { + format!("Found {count} reminder{plural}, {overdue} of them overdue.") + } else { + format!("Found {count} reminder{plural}.") + }; + + if count as u32 >= limit { + summary.push_str(" This is the maximum for one call; there may be more."); + } + + summary +} diff --git a/crates/reminders/src/inbound/toolset/mod.rs b/crates/reminders/src/inbound/toolset/mod.rs new file mode 100644 index 00000000000..d92ab651f0a --- /dev/null +++ b/crates/reminders/src/inbound/toolset/mod.rs @@ -0,0 +1,354 @@ +//! Toolset inbound adapter for reminders. +//! +//! A driving adapter like [`axum_router`](super::axum_router), but for the +//! agent loop. It goes through the same [`RemindersService`] port and the same +//! access receipts, so a tool can reach exactly what the HTTP API can and +//! nothing more. +//! +//! Only one-shot reminders are creatable here. Recurring schedules are modelled +//! and stored but never dispatched (see +//! [`DeliveryOutcome::SkippedRecurring`](crate::domain::models::DeliveryOutcome::SkippedRecurring)), +//! so a tool that accepted a cron would let the model promise a reminder that +//! silently never fires. Recurring reminders that already exist are still +//! listed, and say so. + +mod create_reminder; +mod delete_reminder; +mod list_reminders; +mod update_reminder; + +#[cfg(test)] +mod test; + +/// The timezone rule, shared verbatim by every tool that takes a timestamp. +/// +/// A macro rather than a `const` because `#[schemars(description = ...)]` is +/// built at compile time from literals, and `concat!` only concatenates +/// literals. One definition is the point: the same rule stated three ways is +/// how the three drift apart. +/// +/// It is repeated into each tool's description rather than shared at runtime +/// because a tool schema has nowhere else to put it — descriptions are +/// independent fields, and tool search can load one of these tools without the +/// others, so a cross-reference could dangle. +macro_rules! utc_conversion_note { + () => { + "## Times are UTC — convert both ways\n\ + \n\ + Timestamps are absolute instants, in and out, while the user asks in their own \ + timezone. Getting this wrong silently sets the reminder to the wrong hour.\n\ + \n\ + - **In:** resolve their wording against their local time, then convert. For \ + America/New_York (UTC-4 in August), \"3pm tomorrow\" on 2026-08-12 is \ + `\"2026-08-13T19:00:00Z\"`, not `\"2026-08-13T15:00:00Z\"`.\n\ + - **Out:** report the response's UTC value back in their timezone — \ + `\"2026-08-13T19:00:00Z\"` is \"3:00 PM tomorrow\".\n\ + \n\ + Ask for their timezone rather than assuming UTC." + }; +} + +pub(crate) use utc_conversion_note; + +use std::sync::Arc; + +use ai_toolset::{AsyncToolCollection, ToolCallError}; +use chrono::{DateTime, Utc}; +use entity_access::domain::{ + models::{AccessError, AnyEntityPermission, EntityAccessReceipt, OwnerAccessLevel}, + ports::EntityAccessService, +}; +use macro_user_id::user_id::MacroUserIdStr; +use model_entity::{Entity, EntityType}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::models::{Reminder, ReminderError, ReminderSchedule}; +use crate::domain::ports::RemindersService; + +pub use create_reminder::CreateReminder; +pub use delete_reminder::{DeleteReminder, DeleteReminderResponse}; +pub use list_reminders::{ListReminders, ListRemindersResponse}; +pub use update_reminder::UpdateReminder; + +/// Service context for reminder AI tools. +pub struct RemindersToolContext { + /// The reminders service instance. + pub service: Arc, + /// Mints the access receipts every reminder operation is gated on. + pub entity_access_service: Arc, +} + +impl Clone for RemindersToolContext { + fn clone(&self) -> Self { + Self { + service: self.service.clone(), + entity_access_service: self.entity_access_service.clone(), + } + } +} + +impl RemindersToolContext { + /// Create a new reminders tool context. + pub fn new(service: S, entity_access_service: Arc) -> Self { + Self { + service: Arc::new(service), + entity_access_service, + } + } + + /// Prove the caller owns `reminder_id`. + /// + /// A reminder is never shared, so ownership is the whole access model and + /// `Owner` is the only level this can come back with. Somebody else's + /// reminder and one that does not exist give the same answer, which is what + /// keeps an id from leaking. + async fn owner_receipt( + &self, + user_id: &MacroUserIdStr<'_>, + reminder_id: Uuid, + ) -> Result, ToolCallError> { + self.entity_access_service + .generate_entity_access_receipt::( + user_id, + None, + &reminder_id.to_string(), + EntityType::Reminder, + ) + .await + .map_err(|e| ToolCallError { + description: format!( + "No reminder with id {reminder_id} belongs to this user. \ + Call ListReminders to see the user's reminders and their ids." + ), + internal_error: e.into(), + }) + } + + /// Prove the caller may attach a reminder to `entity`. + /// + /// [`AnyEntityPermission`], not view access: entity permissions come in two + /// shapes and a channel resolves to a role rather than an access level, so + /// requiring view would reject every channel including ones the caller + /// owns. Holding any permission at all is the bar, and no permission is an + /// error rather than a value. + async fn entity_receipt( + &self, + user_id: &MacroUserIdStr<'_>, + entity: &Entity<'_>, + ) -> Result, ToolCallError> { + self.entity_access_service + .generate_entity_access_receipt::( + user_id, + None, + entity.entity_id.as_ref(), + entity.entity_type, + ) + .await + .map_err(|e| { + // Say what actually went wrong. Collapsing these into one + // message told a model with a wrong id that it lacked access, + // which sends it looking in the wrong place. + let description = match &e { + AccessError::NotFound(_) => format!( + "No {} exists with id {}.", + entity.entity_type.as_ref(), + entity.entity_id + ), + AccessError::BadRequest(msg) => msg.to_string(), + _ => format!( + "The user does not have access to {} {}.", + entity.entity_type.as_ref(), + entity.entity_id + ), + }; + ToolCallError { + description, + internal_error: e.into(), + } + }) + } +} + +/// Create the reminders toolset. +pub fn reminders_toolset() -> AsyncToolCollection> +where + S: RemindersService, + E: EntityAccessService, +{ + AsyncToolCollection::new() + .add_tool::>() + .add_tool::>() + .add_tool::>() + .add_tool::>() +} + +/// Turn a domain error into something the model can act on. +/// +/// `BadRequest` is passed through verbatim — it is the service explaining what +/// was wrong with the request ("remindAt must be in the future"), which is +/// exactly what lets a model correct itself and retry. +fn reminder_error(error: ReminderError) -> ToolCallError { + let description = match &error { + ReminderError::NotFound => { + "That reminder no longer exists. Call ListReminders for the current list.".to_string() + } + ReminderError::EntityNotFound => { + "The entity the reminder would be attached to does not exist.".to_string() + } + ReminderError::BadRequest(message) => message.clone(), + ReminderError::EntityAccessDenied => { + "The user does not have access to that entity.".to_string() + } + ReminderError::Internal(_) => "The reminders service failed.".to_string(), + }; + + ToolCallError { + description, + internal_error: anyhow::Error::msg(format!("{error:?}")), + } +} + +/// Entity types a reminder can be attached to. +/// +/// Deliberately narrower than [`EntityType`], which covers plenty of things a +/// reminder has no business pointing at. The names match the ones `ListEntities` +/// uses so the model sees one vocabulary across tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReminderEntityType { + /// Macro document. + Document, + /// AI chat conversation. + AiChat, + /// Macro project (shown as a folder in the app UI). + Project, + /// Email thread. + Email, + /// Chat channel. + Channel, + /// Call record. + Call, + /// Calendar event. + CalendarEvent, +} + +impl From for EntityType { + fn from(value: ReminderEntityType) -> Self { + match value { + ReminderEntityType::Document => EntityType::Document, + ReminderEntityType::AiChat => EntityType::Chat, + ReminderEntityType::Project => EntityType::Project, + ReminderEntityType::Email => EntityType::EmailThread, + ReminderEntityType::Channel => EntityType::Channel, + ReminderEntityType::Call => EntityType::Call, + ReminderEntityType::CalendarEvent => EntityType::CalendarEvent, + } + } +} + +impl ReminderEntityType { + /// The tool-facing name for a stored entity type, or `None` for one this + /// toolset does not name. + /// + /// Stored reminders can reference types the create tool refuses (they are + /// reachable from the UI), so a read has to survive meeting one rather than + /// fail the whole list. + fn from_entity_type(entity_type: EntityType) -> Option { + match entity_type { + EntityType::Document => Some(Self::Document), + EntityType::Chat => Some(Self::AiChat), + EntityType::Project => Some(Self::Project), + EntityType::EmailThread => Some(Self::Email), + EntityType::Channel => Some(Self::Channel), + EntityType::Call => Some(Self::Call), + EntityType::CalendarEvent => Some(Self::CalendarEvent), + _ => None, + } + } +} + +/// Pair an optional entity type and id, rejecting a half-supplied association. +/// +/// Both or neither. A model that sends only one of them has almost certainly +/// lost the other, and silently creating a standalone reminder would hide that. +fn build_entity( + entity_type: Option, + entity_id: Option, +) -> Result>, ToolCallError> { + match (entity_type, entity_id) { + (Some(entity_type), Some(entity_id)) => Ok(Some( + EntityType::from(entity_type).with_entity_string(entity_id.to_string()), + )), + (None, None) => Ok(None), + _ => Err(ToolCallError { + description: "entityType and entityId must be provided together, or both omitted \ + for a reminder that is not about anything in particular." + .to_string(), + internal_error: anyhow::anyhow!("half-supplied reminder entity association"), + }), + } +} + +/// A reminder as the model sees it. +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ToolReminder { + /// The reminder's id. Pass this to UpdateReminder or DeleteReminder. + pub id: Uuid, + /// What the user wanted to be reminded about. + pub description: String, + /// When the reminder fires next, RFC 3339 in UTC. The user thinks in their + /// own timezone — convert before quoting this back to them. + pub next_run_at: DateTime, + /// Whether `nextRunAt` has already passed, evaluated against the server + /// clock. An overdue reminder is one the user has been notified about and + /// has not dealt with yet. + pub overdue: bool, + /// For a repeating reminder, its cron expression and timezone. Absent on a + /// one-shot, which is everything this toolset can create. + #[serde(skip_serializing_if = "Option::is_none")] + pub recurrence: Option, + /// The type of thing the reminder is about, when it is about something and + /// that type is one these tools name. The app can attach a reminder to + /// kinds of thing this list does not cover, so `entityId` may be present + /// with no `entityType` beside it — the reminder is about something, but + /// not something these tools can name or filter on. + #[serde(skip_serializing_if = "Option::is_none")] + pub entity_type: Option, + /// The id of the thing the reminder is about. + #[serde(skip_serializing_if = "Option::is_none")] + pub entity_id: Option, + /// Whether the user has marked the reminder as dealt with. + pub completed: bool, + /// Whether the reminder will fire at all. A disabled reminder keeps its + /// schedule but is skipped by the dispatcher. + pub enabled: bool, +} + +impl ToolReminder { + /// Render a stored reminder, resolving overdue-ness against `now`. + fn new(reminder: Reminder, now: DateTime) -> Self { + let recurrence = match &reminder.schedule { + ReminderSchedule::Once { .. } => None, + ReminderSchedule::Recurring { cron, timezone } => { + Some(format!("{} ({timezone})", cron.as_str())) + } + }; + + Self { + id: reminder.id, + description: reminder.description, + next_run_at: reminder.next_run_at, + overdue: reminder.next_run_at <= now, + recurrence, + entity_type: reminder + .entity_type + .and_then(ReminderEntityType::from_entity_type), + entity_id: reminder.entity_id, + completed: reminder.completed_at.is_some(), + enabled: reminder.enabled, + } + } +} diff --git a/crates/reminders/src/inbound/toolset/test.rs b/crates/reminders/src/inbound/toolset/test.rs new file mode 100644 index 00000000000..30142c390b5 --- /dev/null +++ b/crates/reminders/src/inbound/toolset/test.rs @@ -0,0 +1,287 @@ +use super::list_reminders::{ListRemindersResponse, build_summary}; +use super::*; +use ai_toolset::schema::generate_validated_input_schema; +use chrono::TimeZone; +use chrono_tz::Tz; + +use crate::domain::models::ReminderCron; + +fn at(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 7, hour, minute, 0).unwrap() +} + +fn reminder(schedule: ReminderSchedule, next_run_at: DateTime) -> Reminder { + Reminder { + id: Uuid::new_v4(), + description: "Reply to Dana".to_string(), + entity_type: None, + entity_id: None, + schedule, + next_run_at, + enabled: true, + completed_at: None, + created_at: at(9, 0), + updated_at: at(9, 0), + } +} + +fn one_shot(next_run_at: DateTime) -> Reminder { + reminder( + ReminderSchedule::Once { + remind_at: next_run_at, + }, + next_run_at, + ) +} + +// --- schema validation --- + +#[test] +fn create_reminder_schema_is_valid() { + let validated = + generate_validated_input_schema::().expect("schema should validate"); + assert_eq!(validated.name, "CreateReminder"); + assert!( + validated.description.contains("remindAt"), + "{}", + validated.description + ); +} + +#[test] +fn list_reminders_schema_is_valid() { + let validated = + generate_validated_input_schema::().expect("schema should validate"); + assert_eq!(validated.name, "ListReminders"); + assert!( + validated.description.contains("soonest first"), + "{}", + validated.description + ); +} + +#[test] +fn update_reminder_schema_is_valid() { + let validated = + generate_validated_input_schema::().expect("schema should validate"); + assert_eq!(validated.name, "UpdateReminder"); + assert!( + validated.description.contains("completed"), + "{}", + validated.description + ); +} + +#[test] +fn delete_reminder_schema_is_valid() { + let validated = + generate_validated_input_schema::().expect("schema should validate"); + assert_eq!(validated.name, "DeleteReminder"); + assert!( + validated.description.contains("cannot be undone"), + "{}", + validated.description + ); +} + +/// Every tool has to survive being put in a collection — that is where name +/// conflicts and schema rejections actually surface. +#[test] +fn toolset_builds_with_every_tool() { + use crate::domain::service::NoOpRemindersService; + use entity_access::domain::ports::NoOpEntityAccessService; + + let toolset = reminders_toolset::(); + + for name in [ + "CreateReminder", + "ListReminders", + "UpdateReminder", + "DeleteReminder", + ] { + assert!(toolset.tools.contains_key(name), "missing {name}"); + } + assert_eq!(toolset.tools.len(), 4); + assert!( + toolset.user_tools.is_empty(), + "reminder tools run in the loop, none are user-executed" + ); +} + +// --- entity pairing --- + +#[test] +fn entity_type_and_id_map_to_a_domain_entity() { + let id = Uuid::new_v4(); + let entity = build_entity(Some(ReminderEntityType::Email), Some(id)) + .expect("a complete pair is valid") + .expect("a complete pair is an entity"); + + assert_eq!(entity.entity_type, EntityType::EmailThread); + assert_eq!(entity.entity_id, id.to_string()); +} + +#[test] +fn neither_entity_field_is_a_standalone_reminder() { + let entity = build_entity(None, None).expect("neither field is valid"); + assert!(entity.is_none()); +} + +/// A model that sends one half has lost the other. Creating a standalone +/// reminder instead would hide that. +#[test] +fn half_an_entity_pair_is_rejected() { + let only_type = build_entity(Some(ReminderEntityType::Document), None) + .expect_err("type without id should be rejected"); + assert!( + only_type.description.contains("must be provided together"), + "{}", + only_type.description + ); + + assert!(build_entity(None, Some(Uuid::new_v4())).is_err()); +} + +/// The tool vocabulary and the stored vocabulary have to agree in both +/// directions, or a reminder is created as one type and read back as another. +#[test] +fn entity_type_mapping_round_trips() { + for tool_type in [ + ReminderEntityType::Document, + ReminderEntityType::AiChat, + ReminderEntityType::Project, + ReminderEntityType::Email, + ReminderEntityType::Channel, + ReminderEntityType::Call, + ReminderEntityType::CalendarEvent, + ] { + let stored = EntityType::from(tool_type); + assert_eq!( + ReminderEntityType::from_entity_type(stored), + Some(tool_type), + "{tool_type:?} did not round trip" + ); + } +} + +/// Reminders on types this toolset does not name are reachable from the UI, so +/// a read has to render them rather than fail. +#[test] +fn unnamed_entity_type_reads_back_as_no_type() { + let mut reminder = one_shot(at(10, 0)); + reminder.entity_type = Some(EntityType::CrmCompany); + reminder.entity_id = Some(Uuid::new_v4().to_string()); + + let rendered = ToolReminder::new(reminder, at(9, 0)); + assert_eq!(rendered.entity_type, None); + assert!( + rendered.entity_id.is_some(), + "the id survives even when the type has no tool-facing name" + ); +} + +// --- rendering --- + +#[test] +fn a_future_reminder_is_not_overdue() { + let rendered = ToolReminder::new(one_shot(at(10, 0)), at(9, 0)); + assert!(!rendered.overdue); + assert_eq!(rendered.recurrence, None); + assert!(!rendered.completed); +} + +/// Overdue is inclusive of the firing instant: at exactly `next_run_at` the +/// sweep has already picked the reminder up. +#[test] +fn a_reminder_at_its_firing_instant_is_overdue() { + let rendered = ToolReminder::new(one_shot(at(9, 0)), at(9, 0)); + assert!(rendered.overdue); +} + +#[test] +fn a_completed_reminder_reads_as_completed() { + let mut reminder = one_shot(at(10, 0)); + reminder.completed_at = Some(at(9, 30)); + + let rendered = ToolReminder::new(reminder, at(9, 0)); + assert!(rendered.completed); +} + +/// A repeating reminder has to announce itself — these tools cannot create or +/// reschedule one, so the model needs to know before it offers to. +#[test] +fn a_recurring_reminder_reports_its_recurrence() { + let schedule = ReminderSchedule::Recurring { + cron: ReminderCron::parse("0 9 * * *").expect("valid cron"), + timezone: Tz::America__New_York, + }; + let rendered = ToolReminder::new(reminder(schedule, at(13, 0)), at(9, 0)); + + let recurrence = rendered.recurrence.expect("recurring reminders say so"); + assert!(recurrence.contains("0 0 9 * * *"), "{recurrence}"); + assert!(recurrence.contains("America/New_York"), "{recurrence}"); +} + +// --- list summary --- + +#[test] +fn empty_list_summary() { + assert_eq!(build_summary(&[], 20), "No reminders match."); +} + +#[test] +fn summary_counts_overdue_reminders() { + let reminders = vec![ + ToolReminder::new(one_shot(at(8, 0)), at(9, 0)), + ToolReminder::new(one_shot(at(10, 0)), at(9, 0)), + ]; + + let summary = build_summary(&reminders, 20); + assert!(summary.contains("2 reminders"), "{summary}"); + assert!(summary.contains("1 of them overdue"), "{summary}"); + assert!( + !summary.contains("there may be more"), + "a short page is the whole list: {summary}" + ); +} + +/// A full page is otherwise indistinguishable from a complete list, and a model +/// that cannot tell will report the truncated count as the total. +#[test] +fn summary_admits_when_the_page_is_full() { + let reminders = vec![ToolReminder::new(one_shot(at(10, 0)), at(9, 0))]; + let summary = build_summary(&reminders, 1); + assert!(summary.contains("there may be more"), "{summary}"); +} + +#[test] +fn single_reminder_summary_is_not_pluralized() { + let reminders = vec![ToolReminder::new(one_shot(at(10, 0)), at(9, 0))]; + let summary = build_summary(&reminders, 20); + assert!(summary.contains("Found 1 reminder."), "{summary}"); +} + +/// The response is what the model actually reads, so the fields it needs have +/// to survive serialization under the names the schema advertises. +#[test] +fn response_serializes_with_camel_case_keys() { + let response = ListRemindersResponse { + reminders: vec![ToolReminder::new(one_shot(at(10, 0)), at(9, 0))], + summary: "Found 1 reminder.".to_string(), + }; + + let json = serde_json::to_value(&response).expect("response should serialize"); + let reminder = &json["reminders"][0]; + + assert!(reminder["nextRunAt"].is_string()); + assert_eq!(reminder["overdue"], serde_json::json!(false)); + assert_eq!(reminder["completed"], serde_json::json!(false)); + assert!( + reminder.get("recurrence").is_none(), + "a one-shot omits recurrence rather than sending null" + ); + assert!( + reminder.get("entityType").is_none(), + "a standalone reminder omits its entity fields" + ); +} diff --git a/crates/reminders/src/inbound/toolset/update_reminder.rs b/crates/reminders/src/inbound/toolset/update_reminder.rs new file mode 100644 index 00000000000..25fc98772b1 --- /dev/null +++ b/crates/reminders/src/inbound/toolset/update_reminder.rs @@ -0,0 +1,119 @@ +//! UpdateReminder tool for rescheduling, rewording, or completing a reminder. + +use ai_toolset::{AsyncTool, RequestContext, ServiceContext, ToolResult}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use entity_access::domain::ports::EntityAccessService; +use schemars::JsonSchema; +use serde::Deserialize; +use uuid::Uuid; + +use super::{RemindersToolContext, ToolReminder, reminder_error, utc_conversion_note}; +use crate::domain::models::{ReminderPatch, ReminderSchedule}; +use crate::domain::ports::RemindersService; + +/// Change one of the current user's reminders. +#[derive(Debug, Deserialize, JsonSchema, Clone)] +#[serde(rename_all = "camelCase")] +#[schemars( + title = "UpdateReminder", + description = concat!( + "\ +Change one of the current user's reminders: reword it, move when it fires, or mark it done. \ +Get the `reminderId` from ListReminders or CreateReminder.\n\ +\n\ +Pass only the fields you are changing; anything omitted is left alone. At least one must be \ +given.\n\ +\n\ +- Snooze or reschedule: set `remindAt`\n\ +- Mark done: `completed: true` — the user has dealt with it and it leaves their active list\n\ +- Reopen: `completed: false`\n\ +- Reword: set `description`\n\ +\n\ +Marking done is the normal way to clear a reminder the user has handled, and it is \ +reversible: the reminder drops out of the default ListReminders results but is still there, \ +readable with `completed: true` and restorable with `completed: false`. Reach for \ +DeleteReminder only when the user wants the reminder not to exist; that cannot be undone.\n\ +\n\ +Two things this tool will not do. It cannot change what a reminder is attached to — create a \ +new reminder and delete this one instead. And setting `remindAt` on a repeating reminder \ +replaces the repetition with that single firing, so only do it if the user asked to stop it \ +repeating.\n\ +\n", + utc_conversion_note!() + ) +)] +pub struct UpdateReminder { + /// The reminder to change. + #[schemars(description = "The id of the reminder to change.")] + pub reminder_id: Uuid, + + /// Replacement description. + #[schemars(description = "Replacement reminder text. Max 2000 characters.")] + #[serde(default)] + pub description: Option, + + /// Replacement one-shot firing time. + #[schemars(description = "Reschedule to this RFC 3339 timestamp in UTC (e.g. \ + \"2026-08-08T14:00:00Z\"). Must be in the future — to move a reminder \ + that has already fired, give it a new future time. Convert from the \ + user's local timezone before sending; see \"Times are UTC\" in the tool \ + description.")] + #[serde(default)] + pub remind_at: Option>, + + /// Mark the reminder as dealt with, or live again. + #[schemars( + description = "Mark the reminder as dealt with (true) or put it back on the active \ + list (false)." + )] + #[serde(default)] + pub completed: Option, +} + +#[async_trait] +impl AsyncTool> for UpdateReminder +where + S: RemindersService, + E: EntityAccessService, +{ + type Output = ToolReminder; + + #[tracing::instrument(skip_all, fields( + user_id = ?request_context.user_id, + reminder_id = %self.reminder_id, + completed = ?self.completed, + ), err)] + async fn call( + &self, + service_context: ServiceContext>, + request_context: RequestContext, + ) -> ToolResult { + tracing::info!("Update reminder"); + + let receipt = service_context + .owner_receipt(&request_context.user_id, self.reminder_id) + .await?; + + let patch = ReminderPatch { + description: self.description.clone(), + schedule: self + .remind_at + .map(|remind_at| ReminderSchedule::Once { remind_at }), + // Not exposed: `enabled` is the dispatcher's switch and reads as a + // second, subtly different way of saying "done". Two booleans that + // both sound like "turn this off" is how a model picks the wrong + // one. Rescheduling covers the case it would serve. + enabled: None, + completed: self.completed, + }; + + let reminder = service_context + .service + .update_reminder(receipt, patch) + .await + .map_err(reminder_error)?; + + Ok(ToolReminder::new(reminder, Utc::now())) + } +} diff --git a/crates/reminders/src/lib.rs b/crates/reminders/src/lib.rs index e5088042856..b5b2a8904c1 100644 --- a/crates/reminders/src/lib.rs +++ b/crates/reminders/src/lib.rs @@ -19,7 +19,7 @@ pub mod domain; -#[cfg(any(feature = "inbound", feature = "dispatch"))] +#[cfg(any(feature = "inbound", feature = "dispatch", feature = "ai_tools"))] pub mod inbound; #[cfg(feature = "outbound")] diff --git a/services/document_cognition_service/src/api/context/test.rs b/services/document_cognition_service/src/api/context/test.rs index 37f07c8cdaa..fc67bb9876e 100644 --- a/services/document_cognition_service/src/api/context/test.rs +++ b/services/document_cognition_service/src/api/context/test.rs @@ -363,6 +363,10 @@ pub async fn test_api_context(pool: sqlx::Pool) -> std::sync::Ar email_tool_context: email_tool_context.clone(), call_tool_context: call_tool_context.clone(), notification_tool_context: notification_tool_context.clone(), + reminders_tool_context: ai_tools::build_reminders_tool_context( + pool.clone(), + entity_access_service.clone(), + ), import_tool_context: ai_tools::ToolImportToolContext::unwired(), chat_tool_context, channel_tool_context: ai_tools::build_channel_tool_context_without_side_effects( diff --git a/services/document_cognition_service/src/main.rs b/services/document_cognition_service/src/main.rs index 93fa1841f58..8f5d2b1a087 100644 --- a/services/document_cognition_service/src/main.rs +++ b/services/document_cognition_service/src/main.rs @@ -503,6 +503,10 @@ async fn main() -> anyhow::Result<()> { email_tool_context: email_tool_context.clone(), call_tool_context: call_tool_context.clone(), notification_tool_context: notification_tool_context.clone(), + reminders_tool_context: ai_tools::build_reminders_tool_context( + db.clone(), + entity_access_service.clone(), + ), import_tool_context: import::inbound::toolset::ImportToolContext::wired( import_service.clone(), ), diff --git a/services/mcp_service/src/context.rs b/services/mcp_service/src/context.rs index eade952f998..2e5c904e919 100644 --- a/services/mcp_service/src/context.rs +++ b/services/mcp_service/src/context.rs @@ -377,6 +377,10 @@ async fn build_tool_context(args: ToolContextBuildArgs<'_>) -> anyhow::Result