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:
@@ -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