Skip to content

feat(reminders): reminders AI toolset - #5620

Open
evanhutnik wants to merge 1 commit into
mainfrom
evan/reminders-f
Open

feat(reminders): reminders AI toolset#5620
evanhutnik wants to merge 1 commit into
mainfrom
evan/reminders-f

Conversation

@evanhutnik

Copy link
Copy Markdown
Contributor

Reminders were reachable from the app and from the HTTP API, but not from the agent loop — so "remind me to reply to this tomorrow" had nowhere to go. This adds the four reminder tools.

The tools

  • ListReminders — read them, soonest first. Filtered to outstanding by default; also how you re-read one by id.
  • CreateReminder — schedule a one-off, optionally attached to a Macro item.
  • UpdateReminder — reword, reschedule, mark done, reopen.
  • DeleteReminder — remove one permanently.

A driving adapter alongside the axum router, going through the same RemindersService port and the same access receipts, so a tool reaches exactly what the HTTP API can and nothing more.

Notes on the details

get_entity_permission had to learn about reminders. Minting an owner receipt went through get_access_level, and ReminderAccessExtractor assembled the receipt by hand from it — fine for an axum route, useless to a non-axum caller. The new arm lets a tool prove ownership without reimplementing the extractor. Covered by three new entity_access tests.

Only one-shot reminders are creatable. Recurring schedules are modelled and stored but never dispatched (DeliveryOutcome::SkippedRecurring), so a tool accepting a cron would let the model promise a reminder that silently never fires. Existing recurring reminders still list, and the description says they will not fire rather than implying they are scheduled.

enabled is deliberately not exposed. It 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.

Tool descriptions went through several rounds of agent review. What came out of it: the exact entityType values are listed rather than described in prose; channel_thread gets an explicit callout to attach via its parent channel's channelId, since passing the thread's own id silently fails; the UTC conversion rule is a single utc_conversion_note!() macro spliced into both tools that take a timestamp, so the copies cannot drift; and done-vs-delete is stated in terms of what is actually recoverable.

Reminders were reachable from the app and the HTTP API but not from the
agent loop, so "remind me to reply to this tomorrow" had nowhere to go.

Adds ListReminders, CreateReminder, UpdateReminder and DeleteReminder as
a driving adapter alongside the axum router. They go through the same
`RemindersService` port and the same access receipts, so a tool reaches
exactly what the HTTP API can and nothing more.

Minting an owner receipt needed `get_entity_permission` to learn about
reminders. Only `get_access_level` knew how, and `ReminderAccessExtractor`
assembled the receipt by hand from it — fine for an axum route, useless to
a non-axum caller. The new arm is what lets a tool prove ownership without
reimplementing the extractor.

Only one-shot reminders are creatable. Recurring schedules are stored but
never dispatched, so a tool that accepted a cron would let the model
promise a reminder that silently never fires; existing recurring reminders
still list, and say what they are.

`enabled` is deliberately not exposed. It 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.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added AI-powered reminders support for creating, listing, updating, and deleting reminders.
    • Reminders can include descriptions, scheduled times, completion status, and supported entity associations.
    • Added filtering for status, overdue reminders, entities, and reminder IDs.
    • Added clear reminder displays with expandable results, status counts, formatted dates, and overdue styling.
  • Bug Fixes
    • Added access checks to ensure reminders and associated entities are only available to authorized users.

Walkthrough

Added feature-gated reminder tools for listing, creating, updating, and deleting reminders. Added entity validation, permission checks, service contexts, serialized outputs, summaries, and tests. Registered the tools for provider and MCP access. Initialized reminder contexts across application services. Added web renderers for reminder results and mutation states.

Mergeability Score: 🟡 Moderate · up to 3045a

The PR adds four reminder actions to the agent experience, but the current head still has concrete issues: user email addresses may be written to logs, completed reminders can disappear from direct lookups, empty updates can report success without changing anything, and some failures are reported as missing reminders; the web summary can also hide pagination and count details. These are bounded but meaningful privacy and correctness risks, so merge should wait for owner follow-up.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses conventional commit format, clearly describes the reminders AI toolset, and is 37 characters long.
Description check ✅ Passed The description directly explains the four reminder tools, their access model, and key behavior added by the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
crates/reminders/src/inbound/toolset/mod.rs (2)

192-211: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the error source instead of a Debug string.

anyhow::Error::msg(format!("{error:?}")) flattens ReminderError::Internal(_) into text and drops the source chain, so downstream logging cannot inspect the cause. If ReminderError implements std::error::Error + Send + Sync + 'static, wrap it directly.

♻️ Proposed refactor
     ToolCallError {
         description,
-        internal_error: anyhow::Error::msg(format!("{error:?}")),
+        internal_error: anyhow::Error::new(error),
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/mod.rs` around lines 192 - 211, Update
reminder_error to preserve the original ReminderError as the internal error
source instead of converting it with format!("{error:?}"). Wrap or convert error
directly through its Error implementation, retaining the existing user-facing
description mapping and ensuring the required Send, Sync, and 'static bounds are
satisfied.

1-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use plain text for axum_router in the ai_tools build.

toolset is enabled by ai_tools, but axum_router is enabled only by inbound. The super::axum_router intra-doc link is unresolved in an ai_tools-only build.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/mod.rs` around lines 1 - 13, The
module-level documentation in the toolset inbound adapter references
super::axum_router, which is unavailable in ai_tools-only builds. Replace that
intra-doc link with plain text while preserving the surrounding explanation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/lib/core/component/AI/component/tool/Reminders.tsx`:
- Around line 99-104: Update statusText in Reminders to use the backend response
summary fields from ctx.response instead of deriving text from
reminders().length. Display the reported total count, overdue count, and whether
more results exist, preserving the existing no-response handling.

In `@crates/reminders/src/inbound/toolset/create_reminder.rs`:
- Around line 105-109: Remove the user_id span field from the tracing
instrumentation in create_reminder.rs, update_reminder.rs, delete_reminder.rs,
and list_reminders.rs; retain each tool’s existing non-sensitive fields:
remind_at/entity_type, reminder_id/completed, reminder_id, and completed/overdue
respectively.

In `@crates/reminders/src/inbound/toolset/list_reminders.rs`:
- Around line 139-151: Update the completed filter in the reminder query within
the list-reminders handler so explicit reminderIds lookups pass no completion
restriction, allowing completed reminders to be returned. Preserve the existing
default of false for queries without explicit ids, using the ids value already
prepared before SoupReminderQuery.

In `@crates/reminders/src/inbound/toolset/mod.rs`:
- Around line 302-308: Update the documentation for the overdue field in the
reminder result type to describe only its time-based meaning: it is true when
next_run_at is less than or equal to the current server time. Remove the claim
that overdue implies the user has not dealt with the reminder, and clarify that
it is independent of completion status, consistent with ListReminders.
- Around line 107-127: Update owner_receipt to handle AccessError variants
separately, matching the treatment in entity_receipt: keep the “no reminder
belongs to this user” description only for the not-found/access-denied variant,
and use an accurate internal-failure message for database or other internal
errors. Use the actual AccessError variant names from its definition and
preserve each error as internal_error.

In `@crates/reminders/src/inbound/toolset/update_reminder.rs`:
- Around line 98-109: Validate the update input before constructing the patch or
calling update_reminder: when description, remind_at, and completed are all
absent, return a ToolCallError immediately. Add the required
ai_toolset::ToolCallError and anyhow imports, and preserve the existing
service-call path when at least one field is provided.

---

Nitpick comments:
In `@crates/reminders/src/inbound/toolset/mod.rs`:
- Around line 192-211: Update reminder_error to preserve the original
ReminderError as the internal error source instead of converting it with
format!("{error:?}"). Wrap or convert error directly through its Error
implementation, retaining the existing user-facing description mapping and
ensuring the required Send, Sync, and 'static bounds are satisfied.
- Around line 1-13: The module-level documentation in the toolset inbound
adapter references super::axum_router, which is unavailable in ai_tools-only
builds. Replace that intra-doc link with plain text while preserving the
surrounding explanation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e9ee482-0101-420b-bd45-e70aa5e8084d

📥 Commits

Reviewing files that changed from the base of the PR and between fc737f0 and 3045a0b.

⛔ Files ignored due to path filters (4)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
  • apps/web/src/lib/service-clients/service-cognition/generated/tools/schemas.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • apps/web/src/lib/service-clients/service-cognition/generated/tools/tool.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • apps/web/src/lib/service-clients/service-cognition/generated/tools/types.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
📒 Files selected for processing (21)
  • apps/web/src/lib/core/component/AI/component/tool/Reminders.tsx
  • apps/web/src/lib/core/component/AI/component/tool/handler.tsx
  • crates/ai_tools/Cargo.toml
  • crates/ai_tools/src/build_context.rs
  • crates/ai_tools/src/lib.rs
  • crates/ai_tools/src/tool_context.rs
  • crates/entity_access/src/domain/service.rs
  • crates/entity_access/src/domain/service/test.rs
  • crates/memory/src/context.rs
  • crates/reminders/Cargo.toml
  • crates/reminders/src/inbound.rs
  • crates/reminders/src/inbound/toolset/create_reminder.rs
  • crates/reminders/src/inbound/toolset/delete_reminder.rs
  • crates/reminders/src/inbound/toolset/list_reminders.rs
  • crates/reminders/src/inbound/toolset/mod.rs
  • crates/reminders/src/inbound/toolset/test.rs
  • crates/reminders/src/inbound/toolset/update_reminder.rs
  • crates/reminders/src/lib.rs
  • services/document_cognition_service/src/api/context/test.rs
  • services/document_cognition_service/src/main.rs
  • services/mcp_service/src/context.rs

Comment on lines +99 to +104
const statusText = () => {
if (!ctx.response) return undefined;
const count = reminders().length;
if (count === 0) return 'No Results';
return `${count} reminder${count === 1 ? '' : 's'}`;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show the backend response summary.

Lines 101-103 reconstruct the status from the current page length. The backend summary reports the total count, overdue count, and whether more results exist. This status hides those values when the response is paginated.

Proposed fix
 const statusText = () => {
   if (!ctx.response) return undefined;
-  const count = reminders().length;
-  if (count === 0) return 'No Results';
-  return `${count} reminder${count === 1 ? '' : 's'}`;
+  return ctx.response.data.summary;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const statusText = () => {
if (!ctx.response) return undefined;
const count = reminders().length;
if (count === 0) return 'No Results';
return `${count} reminder${count === 1 ? '' : 's'}`;
};
const statusText = () => {
if (!ctx.response) return undefined;
return ctx.response.data.summary;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/core/component/AI/component/tool/Reminders.tsx` around lines
99 - 104, Update statusText in Reminders to use the backend response summary
fields from ctx.response instead of deriving text from reminders().length.
Display the reported total count, overdue count, and whether more results exist,
preserving the existing no-response handling.

Comment on lines +105 to +109
#[tracing::instrument(skip_all, fields(
user_id = ?request_context.user_id,
remind_at = %self.remind_at,
entity_type = ?self.entity_type,
), err)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Every reminder tool records MacroUserIdStr as a span field. MacroUserIdStr is the composite auth id in the form macro|user@email.com, so each span writes a user email into logs. The reminders repository already skips this value for the same reason, so the toolset spans should match that treatment.

  • crates/reminders/src/inbound/toolset/create_reminder.rs#L105-L109: remove user_id = ?request_context.user_id and keep remind_at and entity_type.
  • crates/reminders/src/inbound/toolset/update_reminder.rs#L82-L86: remove user_id = ?request_context.user_id and keep reminder_id and completed.
  • crates/reminders/src/inbound/toolset/delete_reminder.rs#L53-L56: remove user_id = ?request_context.user_id and keep reminder_id.
  • crates/reminders/src/inbound/toolset/list_reminders.rs#L123-L127: remove user_id = ?request_context.user_id and keep completed and overdue.

Based on learnings: "In crates/reminders/src/outbound/pg_reminders_repo.rs, tracing spans selectively skip sensitive arguments instead of using skip_all: CRUD methods skip MacroUserIdStr" and "treat MacroUserIdStr as the auth provider composite ID string in the form macro|useremail.com".

📍 Affects 4 files
  • crates/reminders/src/inbound/toolset/create_reminder.rs#L105-L109 (this comment)
  • crates/reminders/src/inbound/toolset/update_reminder.rs#L82-L86
  • crates/reminders/src/inbound/toolset/delete_reminder.rs#L53-L56
  • crates/reminders/src/inbound/toolset/list_reminders.rs#L123-L127
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/create_reminder.rs` around lines 105 -
109, Remove the user_id span field from the tracing instrumentation in
create_reminder.rs, update_reminder.rs, delete_reminder.rs, and
list_reminders.rs; retain each tool’s existing non-sensitive fields:
remind_at/entity_type, reminder_id/completed, reminder_id, and completed/overdue
respectively.

Source: Learnings

Comment on lines +139 to +151
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)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

reminderIds lookups silently return nothing for completed reminders.

The tool description tells the model to pass reminderIds to re-read a reminder it already has the id for. The completed filter still defaults to false, so a direct id lookup of a reminder the user marked done returns an empty list. The model then reports that the reminder does not exist, right after UpdateReminder marked it done. Drop the default when the caller names explicit ids.

🐛 Proposed fix
         let ids = self.reminder_ids.clone().unwrap_or_default();
         let limit = self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
+
+        // An explicit id list is a direct lookup, so the outstanding-only
+        // default would hide a reminder the caller just marked done.
+        let completed = if ids.is_empty() {
+            Some(self.completed.unwrap_or(false))
+        } else {
+            self.completed
+        };
-                    completed: Some(self.completed.unwrap_or(false)),
+                    completed,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)),
let ids = self.reminder_ids.clone().unwrap_or_default();
let limit = self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
// An explicit id list is a direct lookup, so the outstanding-only
// default would hide a reminder the caller just marked done.
let completed = if ids.is_empty() {
Some(self.completed.unwrap_or(false))
} else {
self.completed
};
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,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/list_reminders.rs` around lines 139 -
151, Update the completed filter in the reminder query within the list-reminders
handler so explicit reminderIds lookups pass no completion restriction, allowing
completed reminders to be returned. Preserve the existing default of false for
queries without explicit ids, using the ids value already prepared before
SoupReminderQuery.

Comment on lines +107 to +127
async fn owner_receipt(
&self,
user_id: &MacroUserIdStr<'_>,
reminder_id: Uuid,
) -> Result<EntityAccessReceipt<OwnerAccessLevel>, ToolCallError> {
self.entity_access_service
.generate_entity_access_receipt::<OwnerAccessLevel>(
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(),
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not report every receipt failure as "no such reminder".

owner_receipt maps all AccessError variants to one message. A database or internal failure then tells the model the reminder does not belong to the user. The model will report a wrong fact to the user, or loop on ListReminders. entity_receipt already separates the variants at Lines 149-165. Apply the same treatment here.

🐛 Proposed fix
-            .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(),
+            .map_err(|e| {
+                let description = match &e {
+                    AccessError::NotFound(_) | AccessError::Forbidden(_) => format!(
+                        "No reminder with id {reminder_id} belongs to this user. \
+                         Call ListReminders to see the user's reminders and their ids."
+                    ),
+                    AccessError::BadRequest(msg) => msg.to_string(),
+                    _ => "Could not check access to that reminder.".to_string(),
+                };
+                ToolCallError {
+                    description,
+                    internal_error: e.into(),
+                }
             })

Adjust the variant names to the actual AccessError definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/mod.rs` around lines 107 - 127, Update
owner_receipt to handle AccessError variants separately, matching the treatment
in entity_receipt: keep the “no reminder belongs to this user” description only
for the not-found/access-denied variant, and use an accurate internal-failure
message for database or other internal errors. Use the actual AccessError
variant names from its definition and preserve each error as internal_error.

Comment on lines +302 to +308
/// 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<Utc>,
/// 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the overdue documentation.

overdue is computed as next_run_at <= now only. A completed reminder that is past its time still reports overdue: true. The current text states that an overdue reminder is one the user "has not dealt with yet", which the model will read as mutually exclusive with completed. ListReminders already documents the two flags as independent.

📝 Proposed doc fix
     /// 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.
+    /// clock. Independent of `completed`: a completed reminder whose time has
+    /// passed still reads as overdue. Overdue and not completed is the
+    /// needs-attention case.
     pub overdue: bool,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// 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<Utc>,
/// 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,
/// 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<Utc>,
/// Whether `nextRunAt` has already passed, evaluated against the server
/// clock. Independent of `completed`: a completed reminder whose time has
/// passed still reads as overdue. Overdue and not completed is the
/// needs-attention case.
pub overdue: bool,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/mod.rs` around lines 302 - 308, Update
the documentation for the overdue field in the reminder result type to describe
only its time-based meaning: it is true when next_run_at is less than or equal
to the current server time. Remove the claim that overdue implies the user has
not dealt with the reminder, and clarify that it is independent of completion
status, consistent with ListReminders.

Comment on lines +98 to +109
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,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject a patch with no fields set.

The tool description states that at least one field must be given. Nothing enforces it. If description, remind_at, and completed are all absent, the call reaches update_reminder as a no-op write and returns the unchanged reminder, so the model believes it changed something. Return a ToolCallError before the service call.

🐛 Proposed fix
+        if self.description.is_none() && self.remind_at.is_none() && self.completed.is_none() {
+            return Err(ToolCallError {
+                description: "Nothing to change. Set at least one of description, remindAt, \
+                              or completed."
+                    .to_string(),
+                internal_error: anyhow::anyhow!("empty reminder patch"),
+            });
+        }
+
         let receipt = service_context
             .owner_receipt(&request_context.user_id, self.reminder_id)
             .await?;

Import ai_toolset::ToolCallError and add anyhow to the imports.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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,
};
if self.description.is_none() && self.remind_at.is_none() && self.completed.is_none() {
return Err(ToolCallError {
description: "Nothing to change. Set at least one of description, remindAt, \
or completed."
.to_string(),
internal_error: anyhow::anyhow!("empty reminder patch"),
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reminders/src/inbound/toolset/update_reminder.rs` around lines 98 -
109, Validate the update input before constructing the patch or calling
update_reminder: when description, remind_at, and completed are all absent,
return a ToolCallError immediately. Add the required ai_toolset::ToolCallError
and anyhow imports, and preserve the existing service-call path when at least
one field is provided.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant