fix(calendar-tool): anchor today's weekday in prompts + verify expected_weekday on create/update
A user asked Fable to schedule "this Friday at 8am" on Wednesday 4/29
2026. The model picked 4/30 (Thursday) and confidently labeled it
"Friday." The TZ pipeline did everything correctly given the model's
date — the bug was upstream: the model was guessing weekdays from ISO
dates without an anchor, and the calendar tools had no way to verify.
Three layered fixes:
1. **System prompts now name the weekday alongside the ISO date.**
Both the journal-conversation prompt and the general chat prompt
used to say "Today is 2026-04-29 (America/New_York)." They now say
"Today is Wednesday, 2026-04-29 (...)." LLMs are unreliable at
deriving weekday names from ISO dates; supplying the name removes
the guess.
2. **`expected_weekday` parameter on create_event / update_event.**
When the model passes `expected_weekday="friday"`, the backend
computes the resolved start_date's weekday in the user's local
timezone and rejects mismatches with a self-correcting error
("Date 2026-04-30 falls on Thursday, not Friday. Recompute..."),
without creating the event. The check is local-aware: a Friday
23:00 event in Tokyo crosses midnight UTC but the local view
stays Friday, and the validator respects that.
3. **Tool descriptions instruct echo-and-confirm.** create_event and
update_event descriptions now tell the model: when the user names
a weekday, state the resolved date in the reply BEFORE calling
the tool, and pass `expected_weekday`. Costs nothing in code,
reinforces the validator.
6 new tests — match success, mismatch rejection (with create/update
not invoked), omitted-param backcompat, invalid weekday name, local-
not-UTC weekday computation, and the update_event variant. All 18
calendar-tool tests + 33 event-related tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -134,7 +134,11 @@ async def build_journal_system_prompt(
|
||||
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
|
||||
|
||||
today_iso = day_date.isoformat()
|
||||
tz_block = f"Today is {today_iso} ({user_timezone})."
|
||||
# Include the day-of-week explicitly. LLMs are unreliable at deriving
|
||||
# weekday names from ISO dates, which causes "this Friday" / "next
|
||||
# Monday" to land on the wrong calendar day.
|
||||
weekday = day_date.strftime("%A")
|
||||
tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})."
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
profile_section = f"\n\n{profile_context}" if profile_context else ""
|
||||
|
||||
@@ -602,7 +602,12 @@ async def build_context(
|
||||
}
|
||||
|
||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||
today = date_type.today().isoformat()
|
||||
_today_obj = date_type.today()
|
||||
today = _today_obj.isoformat()
|
||||
# Day-of-week paired with the ISO date so the model doesn't have to
|
||||
# derive the weekday — that derivation is a documented failure mode
|
||||
# for "this Friday" / "next Monday"-style requests.
|
||||
today_weekday = _today_obj.strftime("%A")
|
||||
has_caldav = await is_caldav_configured(user_id)
|
||||
|
||||
# Build tool usage guidance based on available integrations
|
||||
@@ -678,7 +683,7 @@ async def build_context(
|
||||
entities_context = await get_people_and_places_context(user_id)
|
||||
entities_section = f"\n\n{entities_context}" if entities_context else ""
|
||||
|
||||
dynamic_tail = f"\n\nToday's date is {today}.{tz_line}{profile_section}{entities_section}"
|
||||
dynamic_tail = f"\n\nToday is {today_weekday}, {today}.{tz_line}{profile_section}{entities_section}"
|
||||
|
||||
# --- System message: stable content only ---
|
||||
# Workspace context and history summary stay here because they carry
|
||||
|
||||
@@ -108,6 +108,34 @@ async def _resolve_event_end(
|
||||
return None
|
||||
|
||||
|
||||
def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None:
|
||||
"""Verify the resolved local date falls on the expected day of the week.
|
||||
|
||||
Models routinely miscompute "this Friday" / "next Monday" when the
|
||||
system prompt only carries an ISO date without a weekday. When the
|
||||
model passes `expected_weekday`, the backend rejects mismatches with
|
||||
a self-correcting error message naming the actual weekday.
|
||||
|
||||
Returns an error string on mismatch, or ``None`` when the check
|
||||
passes (or no expected weekday was supplied).
|
||||
"""
|
||||
if not expected:
|
||||
return None
|
||||
expected_norm = expected.strip().lower()
|
||||
valid = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
|
||||
if expected_norm not in valid:
|
||||
return f"expected_weekday must be a full English weekday name; got {expected!r}."
|
||||
local = start_dt_utc.astimezone(user_tz)
|
||||
actual = local.strftime("%A").lower()
|
||||
if actual == expected_norm:
|
||||
return None
|
||||
return (
|
||||
f"Date {local.date().isoformat()} falls on {actual.title()}, "
|
||||
f"not {expected_norm.title()}. Recompute the date for "
|
||||
f"{expected_norm.title()} or confirm with the user before retrying."
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_event",
|
||||
description=(
|
||||
@@ -117,7 +145,11 @@ async def _resolve_event_end(
|
||||
"separate fields in the user's local time — never combine them and "
|
||||
"never include a timezone suffix. The server attaches the user's "
|
||||
"configured timezone. Omit `start_time` (or set `all_day=true`) "
|
||||
"for all-day events like birthdays or holidays."
|
||||
"for all-day events like birthdays or holidays. "
|
||||
"When the user names a weekday ('this Friday', 'next Monday'), "
|
||||
"state the resolved calendar date in your reply BEFORE calling "
|
||||
"this tool, and pass `expected_weekday` so the server can verify "
|
||||
"the date falls on the day you intended."
|
||||
),
|
||||
parameters={
|
||||
"title": {"type": "string", "description": "A descriptive event title"},
|
||||
@@ -135,6 +167,7 @@ async def _resolve_event_end(
|
||||
"attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"},
|
||||
"calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."},
|
||||
"project": {"type": "string", "description": "Optional project name to associate this event with"},
|
||||
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the start_date should fall on. Pass this whenever the user names a weekday so the server can verify the date is correct. Rejects with a corrective error if the date falls on a different day."},
|
||||
# Legacy combined fields kept for backward compatibility with saved
|
||||
# tool-call payloads in conversation history. New calls should use
|
||||
# start_date + start_time. Hidden from typical model output via the
|
||||
@@ -154,6 +187,10 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
return {"success": False, "error": f"Invalid start: {exc}"}
|
||||
if start_was_date_only:
|
||||
all_day = True
|
||||
user_tz = await get_user_tz(user_id)
|
||||
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
|
||||
if weekday_err:
|
||||
return {"success": False, "error": weekday_err}
|
||||
try:
|
||||
end_dt = await _resolve_event_end(user_id, arguments)
|
||||
except ValueError as exc:
|
||||
@@ -254,7 +291,11 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
"change, move, reschedule, or modify an event. Pass `start_date` "
|
||||
"(YYYY-MM-DD) and `start_time` (HH:MM) as separate fields in the "
|
||||
"user's local time when rescheduling — never combine them, never "
|
||||
"include a timezone suffix."
|
||||
"include a timezone suffix. "
|
||||
"When the user names a weekday ('move to Friday'), state the "
|
||||
"resolved calendar date in your reply BEFORE calling this tool, "
|
||||
"and pass `expected_weekday` so the server can verify the date "
|
||||
"falls on the day you intended."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"},
|
||||
@@ -269,6 +310,7 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
"color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"},
|
||||
"recurrence": {"type": "string", "description": "New iCalendar RRULE"},
|
||||
"reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."},
|
||||
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the new start_date should fall on. Pass whenever the user names a weekday."},
|
||||
# Legacy combined fields kept for backcompat — see create_event.
|
||||
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
|
||||
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
|
||||
@@ -296,6 +338,10 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
start_dt, _ = await _resolve_event_start(user_id, arguments)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"success": False, "error": f"Invalid start: {exc}"}
|
||||
user_tz = await get_user_tz(user_id)
|
||||
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
|
||||
if weekday_err:
|
||||
return {"success": False, "error": weekday_err}
|
||||
fields["start_dt"] = start_dt
|
||||
if arguments.get("end_date") or arguments.get("end"):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user