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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user