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:
2026-04-29 08:43:32 -04:00
parent 611c940527
commit 03d725ea3e
4 changed files with 276 additions and 5 deletions
@@ -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 ""
+7 -2
View File
@@ -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
+48 -2
View File
@@ -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:
+216
View File
@@ -395,3 +395,219 @@ async def test_update_event_split_fields_reschedule_no_drift():
assert result["success"] is True
utc = captured["start_dt"].astimezone(timezone.utc)
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ────
@pytest.mark.asyncio
async def test_create_event_expected_weekday_match_succeeds():
"""When expected_weekday agrees with the resolved local date's
weekday, the event is created normally."""
from fabledassistant.services.tools.calendar import create_event_tool
captured = {}
async def fake_create_event(**kwargs):
captured.update(kwargs)
event = AsyncMock()
event.to_dict.return_value = {"id": 1}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
result = await create_event_tool(
user_id=1,
arguments={
"title": "Meeting",
"start_date": "2026-05-01", # is a Friday
"start_time": "08:00",
"expected_weekday": "friday",
},
)
assert result["success"] is True
assert captured["start_dt"] is not None
@pytest.mark.asyncio
async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual():
"""The reported failure mode: model picks Thursday and calls it
Friday. With expected_weekday set, the create is rejected and the
error names the actual weekday so the model can self-correct."""
from fabledassistant.services.tools.calendar import create_event_tool
create_called = False
async def fake_create_event(**kwargs):
nonlocal create_called
create_called = True
event = AsyncMock()
event.to_dict.return_value = {"id": 1}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
result = await create_event_tool(
user_id=1,
arguments={
"title": "Dentist",
"start_date": "2026-04-30", # Thursday
"start_time": "08:00",
"expected_weekday": "friday",
},
)
assert result["success"] is False
assert "Thursday" in result["error"]
assert "Friday" in result["error"]
# Critical: the event must NOT have been created when the check failed.
assert create_called is False
@pytest.mark.asyncio
async def test_create_event_expected_weekday_omitted_skips_check():
"""Backcompat: when expected_weekday isn't passed, no validation
runs — the existing create flow is unchanged."""
from fabledassistant.services.tools.calendar import create_event_tool
captured = {}
async def fake_create_event(**kwargs):
captured.update(kwargs)
event = AsyncMock()
event.to_dict.return_value = {"id": 1}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
result = await create_event_tool(
user_id=1,
arguments={
"title": "x",
"start_date": "2026-04-30",
"start_time": "08:00",
},
)
assert result["success"] is True
@pytest.mark.asyncio
async def test_create_event_expected_weekday_invalid_value_rejects():
"""Garbage in expected_weekday produces a clear validation error,
not a silent pass."""
from fabledassistant.services.tools.calendar import create_event_tool
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
):
result = await create_event_tool(
user_id=1,
arguments={
"title": "x",
"start_date": "2026-05-01",
"start_time": "08:00",
"expected_weekday": "fri", # abbreviation not accepted
},
)
assert result["success"] is False
assert "weekday" in result["error"].lower()
@pytest.mark.asyncio
async def test_update_event_expected_weekday_mismatch_rejects():
"""update_event must enforce the same weekday check on reschedules."""
from fabledassistant.services.tools.calendar import update_event_tool
update_called = False
async def fake_find(*, user_id, query):
ev = AsyncMock()
ev.id = 99
ev.title = "Coffee"
return [ev]
async def fake_update(*, user_id, event_id, **fields):
nonlocal update_called
update_called = True
ev = AsyncMock()
ev.to_dict.return_value = {"id": event_id}
return ev
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
), patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.tools.calendar.events_update_event",
side_effect=fake_update,
):
result = await update_event_tool(
user_id=1,
arguments={
"query": "Coffee",
"start_date": "2026-04-30", # Thursday
"start_time": "08:00",
"expected_weekday": "friday",
},
)
assert result["success"] is False
assert "Thursday" in result["error"]
assert update_called is False
@pytest.mark.asyncio
async def test_create_event_weekday_check_uses_local_not_utc():
"""The weekday check must use the LOCAL date, not the UTC date.
A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC,
so a UTC-day check would call it Saturday — but the user's calendar
says Friday. The check must respect the user's local view."""
from fabledassistant.services.tools.calendar import create_event_tool
captured = {}
async def fake_create_event(**kwargs):
captured.update(kwargs)
event = AsyncMock()
event.to_dict.return_value = {"id": 1}
return event
with patch(
"fabledassistant.services.tools.calendar.get_user_tz",
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
), patch(
"fabledassistant.services.tools.calendar.events_create_event",
side_effect=fake_create_event,
):
result = await create_event_tool(
user_id=1,
arguments={
"title": "Friday night",
"start_date": "2026-05-01", # Friday in Tokyo
"start_time": "23:00", # 14:00 UTC same day; safe
"expected_weekday": "friday",
},
)
assert result["success"] is True