Spaces:
Sleeping
Sleeping
Phase 3: CalendarAPI with 2 drifts + DriftInjector
Browse files- drift.py +27 -3
- tests/test_calendar.py +182 -0
- tests/test_drift.py +122 -0
- tools/calendar.py +163 -3
drift.py
CHANGED
|
@@ -1,4 +1,28 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Drift injector — fires scheduled DriftEvents at the configured step."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from models import DriftEvent, EpisodeState
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class DriftInjector:
|
| 8 |
+
"""Stateless helper — reads drift plan from state and mutates tools."""
|
| 9 |
+
|
| 10 |
+
@staticmethod
|
| 11 |
+
def tick(state: EpisodeState, tools: dict) -> list[DriftEvent]:
|
| 12 |
+
"""Fire any drifts whose fires_at_step == current state.step.
|
| 13 |
+
|
| 14 |
+
Returns list of events that fired this tick. Does NOT mark
|
| 15 |
+
detected_by_agent — that's done in environment.step() when
|
| 16 |
+
agent calls report_drift.
|
| 17 |
+
"""
|
| 18 |
+
fired = []
|
| 19 |
+
for event in state.drift_plan:
|
| 20 |
+
if event.fires_at_step == state.step and not _already_fired(event):
|
| 21 |
+
tools[event.tool].apply_drift(event)
|
| 22 |
+
event.details["_fired"] = True
|
| 23 |
+
fired.append(event)
|
| 24 |
+
return fired
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _already_fired(event: DriftEvent) -> bool:
|
| 28 |
+
return event.details.get("_fired", False)
|
tests/test_calendar.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CalendarAPI acceptance tests — Phase 3."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from models import DriftEvent
|
| 7 |
+
from tools.calendar import CalendarAPI
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _fresh_calendar(seed_events: list[dict] | None = None) -> CalendarAPI:
|
| 11 |
+
seed = {"events": list(seed_events or [])}
|
| 12 |
+
return CalendarAPI(seed_data=seed)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _seeded_calendar() -> CalendarAPI:
|
| 16 |
+
return _fresh_calendar([
|
| 17 |
+
{
|
| 18 |
+
"event_id": "evt_1",
|
| 19 |
+
"title": "Kickoff",
|
| 20 |
+
"start": "2026-04-25T10:00:00Z",
|
| 21 |
+
"end": "2026-04-25T11:00:00Z",
|
| 22 |
+
"attendees": ["a@x.com"],
|
| 23 |
+
"status": "confirmed",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"event_id": "evt_2",
|
| 27 |
+
"title": "Review",
|
| 28 |
+
"start": "2026-04-26T10:00:00Z",
|
| 29 |
+
"end": "2026-04-26T11:00:00Z",
|
| 30 |
+
"attendees": ["b@x.com"],
|
| 31 |
+
"status": "confirmed",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"event_id": "evt_3",
|
| 35 |
+
"title": "Retro",
|
| 36 |
+
"start": "2026-04-27T10:00:00Z",
|
| 37 |
+
"end": "2026-04-27T11:00:00Z",
|
| 38 |
+
"attendees": ["c@x.com"],
|
| 39 |
+
"status": "confirmed",
|
| 40 |
+
},
|
| 41 |
+
])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_baseline_create_event() -> None:
|
| 45 |
+
cal = _fresh_calendar()
|
| 46 |
+
resp = cal.call(
|
| 47 |
+
"create_event",
|
| 48 |
+
{
|
| 49 |
+
"title": "Launch",
|
| 50 |
+
"start": "2026-04-25T10:00:00Z",
|
| 51 |
+
"end": "2026-04-25T11:00:00Z",
|
| 52 |
+
"attendees": ["a@x.com", "b@x.com"],
|
| 53 |
+
},
|
| 54 |
+
)
|
| 55 |
+
assert resp.ok is True
|
| 56 |
+
assert resp.status == 200
|
| 57 |
+
assert resp.body is not None
|
| 58 |
+
assert "attendees" in resp.body
|
| 59 |
+
assert "participants" not in resp.body
|
| 60 |
+
assert resp.body["status"] == "confirmed"
|
| 61 |
+
assert resp.body["attendees"] == ["a@x.com", "b@x.com"]
|
| 62 |
+
assert resp.body["event_id"].startswith("evt_")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_drift_field_rename_create_event() -> None:
|
| 66 |
+
cal = _fresh_calendar()
|
| 67 |
+
event = DriftEvent(
|
| 68 |
+
tool="calendar",
|
| 69 |
+
endpoint="create_event",
|
| 70 |
+
kind="field_rename",
|
| 71 |
+
fires_at_step=1,
|
| 72 |
+
details={"from": "attendees", "to": "participants"},
|
| 73 |
+
)
|
| 74 |
+
cal.apply_drift(event)
|
| 75 |
+
|
| 76 |
+
schema = cal.get_schema("create_event")
|
| 77 |
+
assert "participants" in schema["params"]
|
| 78 |
+
assert "attendees" not in schema["params"]
|
| 79 |
+
assert "participants" in schema["required"]
|
| 80 |
+
assert "attendees" not in schema["required"]
|
| 81 |
+
assert "participants" in schema["response_shape"]
|
| 82 |
+
assert "attendees" not in schema["response_shape"]
|
| 83 |
+
|
| 84 |
+
bad = cal.call(
|
| 85 |
+
"create_event",
|
| 86 |
+
{
|
| 87 |
+
"title": "Launch",
|
| 88 |
+
"start": "2026-04-25T10:00:00Z",
|
| 89 |
+
"end": "2026-04-25T11:00:00Z",
|
| 90 |
+
},
|
| 91 |
+
)
|
| 92 |
+
assert bad.ok is False
|
| 93 |
+
assert bad.status == 400
|
| 94 |
+
assert bad.error is not None
|
| 95 |
+
assert "participants" in bad.error
|
| 96 |
+
|
| 97 |
+
ok = cal.call(
|
| 98 |
+
"create_event",
|
| 99 |
+
{
|
| 100 |
+
"title": "Launch",
|
| 101 |
+
"start": "2026-04-25T10:00:00Z",
|
| 102 |
+
"end": "2026-04-25T11:00:00Z",
|
| 103 |
+
"participants": [
|
| 104 |
+
{"email": "a@x.com", "role": "required"},
|
| 105 |
+
{"email": "b@x.com", "role": "optional"},
|
| 106 |
+
],
|
| 107 |
+
},
|
| 108 |
+
)
|
| 109 |
+
assert ok.ok is True
|
| 110 |
+
assert ok.status == 200
|
| 111 |
+
assert ok.body is not None
|
| 112 |
+
assert "participants" in ok.body
|
| 113 |
+
assert "attendees" not in ok.body
|
| 114 |
+
assert isinstance(ok.body["participants"], list)
|
| 115 |
+
assert ok.body["participants"][0]["email"] == "a@x.com"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_drift_tool_removal_delete_event() -> None:
|
| 119 |
+
cal = _seeded_calendar()
|
| 120 |
+
event = DriftEvent(
|
| 121 |
+
tool="calendar",
|
| 122 |
+
endpoint="delete_event",
|
| 123 |
+
kind="tool_removal",
|
| 124 |
+
fires_at_step=1,
|
| 125 |
+
details={"replacement": "update_event(status=cancelled)"},
|
| 126 |
+
)
|
| 127 |
+
cal.apply_drift(event)
|
| 128 |
+
|
| 129 |
+
gone = cal.call("delete_event", {"event_id": "evt_1"})
|
| 130 |
+
assert gone.ok is False
|
| 131 |
+
assert gone.status == 410
|
| 132 |
+
|
| 133 |
+
updated = cal.call("update_event", {"event_id": "evt_1", "status": "cancelled"})
|
| 134 |
+
assert updated.ok is True
|
| 135 |
+
assert updated.status == 200
|
| 136 |
+
assert updated.body is not None
|
| 137 |
+
assert updated.body["status"] == "cancelled"
|
| 138 |
+
|
| 139 |
+
still_there = [e for e in cal.events if e["event_id"] == "evt_1"]
|
| 140 |
+
assert len(still_there) == 1
|
| 141 |
+
assert still_there[0]["status"] == "cancelled"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_update_event_not_found() -> None:
|
| 145 |
+
cal = _seeded_calendar()
|
| 146 |
+
resp = cal.call("update_event", {"event_id": "nonexistent", "status": "cancelled"})
|
| 147 |
+
assert resp.ok is False
|
| 148 |
+
assert resp.status == 404
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_list_events() -> None:
|
| 152 |
+
cal = _seeded_calendar()
|
| 153 |
+
|
| 154 |
+
wide = cal.call(
|
| 155 |
+
"list_events",
|
| 156 |
+
{"date_from": "2026-04-25T00:00:00Z", "date_to": "2026-04-28T00:00:00Z"},
|
| 157 |
+
)
|
| 158 |
+
assert wide.ok is True
|
| 159 |
+
assert wide.body is not None
|
| 160 |
+
assert len(wide.body["events"]) == 3
|
| 161 |
+
|
| 162 |
+
narrow = cal.call(
|
| 163 |
+
"list_events",
|
| 164 |
+
{"date_from": "2026-04-25T00:00:00Z", "date_to": "2026-04-25T23:59:59Z"},
|
| 165 |
+
)
|
| 166 |
+
assert narrow.ok is True
|
| 167 |
+
assert narrow.body is not None
|
| 168 |
+
assert len(narrow.body["events"]) == 1
|
| 169 |
+
assert narrow.body["events"][0]["event_id"] == "evt_1"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_unknown_drift_kind_raises() -> None:
|
| 173 |
+
cal = _fresh_calendar()
|
| 174 |
+
event = DriftEvent(
|
| 175 |
+
tool="calendar",
|
| 176 |
+
endpoint="create_event",
|
| 177 |
+
kind="rate_limit_tightening",
|
| 178 |
+
fires_at_step=1,
|
| 179 |
+
details={},
|
| 180 |
+
)
|
| 181 |
+
with pytest.raises(ValueError):
|
| 182 |
+
cal.apply_drift(event)
|
tests/test_drift.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DriftInjector acceptance tests — Phase 3."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from drift import DriftInjector
|
| 5 |
+
from models import DriftEvent, EpisodeState
|
| 6 |
+
from tools.calendar import CalendarAPI
|
| 7 |
+
from tools.mail import MailAPI
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _make_state(drifts: list[DriftEvent]) -> EpisodeState:
|
| 11 |
+
return EpisodeState(
|
| 12 |
+
episode_id="ep-test",
|
| 13 |
+
task_id="test-task",
|
| 14 |
+
difficulty="easy",
|
| 15 |
+
max_steps=10,
|
| 16 |
+
token_budget=4000,
|
| 17 |
+
token_budget_remaining=4000,
|
| 18 |
+
drift_plan=drifts,
|
| 19 |
+
ground_truth_final_state={},
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _make_tools() -> dict:
|
| 24 |
+
mail = MailAPI(seed_data={"messages": [
|
| 25 |
+
{"id": "m1", "from": "a@x.com", "to": "u@org.com",
|
| 26 |
+
"subject": "s", "body": "b", "folder": "inbox"}
|
| 27 |
+
]})
|
| 28 |
+
cal = CalendarAPI(seed_data={"events": [
|
| 29 |
+
{"event_id": "evt_1", "title": "x",
|
| 30 |
+
"start": "2026-04-25T10:00:00Z", "end": "2026-04-25T11:00:00Z",
|
| 31 |
+
"attendees": ["a@x.com"], "status": "confirmed"}
|
| 32 |
+
]})
|
| 33 |
+
return {"mail": mail, "calendar": cal}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_single_drift_fires_at_step() -> None:
|
| 37 |
+
drift = DriftEvent(
|
| 38 |
+
tool="mail", endpoint="list_messages", kind="field_rename",
|
| 39 |
+
fires_at_step=3, details={},
|
| 40 |
+
)
|
| 41 |
+
state = _make_state([drift])
|
| 42 |
+
tools = _make_tools()
|
| 43 |
+
|
| 44 |
+
state.step = 1
|
| 45 |
+
assert DriftInjector.tick(state, tools) == []
|
| 46 |
+
assert "messages" in tools["mail"].active_schemas["list_messages"].response_shape
|
| 47 |
+
|
| 48 |
+
state.step = 3
|
| 49 |
+
fired = DriftInjector.tick(state, tools)
|
| 50 |
+
assert len(fired) == 1
|
| 51 |
+
assert fired[0] is drift
|
| 52 |
+
mail_shape = tools["mail"].active_schemas["list_messages"].response_shape
|
| 53 |
+
assert "items" in mail_shape
|
| 54 |
+
assert "messages" not in mail_shape
|
| 55 |
+
|
| 56 |
+
state.step = 4
|
| 57 |
+
assert DriftInjector.tick(state, tools) == []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_multiple_drifts_different_steps() -> None:
|
| 61 |
+
mail_drift = DriftEvent(
|
| 62 |
+
tool="mail", endpoint="send_message", kind="endpoint_deprecation",
|
| 63 |
+
fires_at_step=2, details={"replacement": "messages.send"},
|
| 64 |
+
)
|
| 65 |
+
cal_drift = DriftEvent(
|
| 66 |
+
tool="calendar", endpoint="create_event", kind="field_rename",
|
| 67 |
+
fires_at_step=5, details={},
|
| 68 |
+
)
|
| 69 |
+
state = _make_state([mail_drift, cal_drift])
|
| 70 |
+
tools = _make_tools()
|
| 71 |
+
|
| 72 |
+
state.step = 2
|
| 73 |
+
fired = DriftInjector.tick(state, tools)
|
| 74 |
+
assert [e.tool for e in fired] == ["mail"]
|
| 75 |
+
assert "send_message" not in tools["mail"].active_schemas
|
| 76 |
+
assert "messages.send" in tools["mail"].active_schemas
|
| 77 |
+
assert "attendees" in tools["calendar"].active_schemas["create_event"].params
|
| 78 |
+
|
| 79 |
+
state.step = 3
|
| 80 |
+
assert DriftInjector.tick(state, tools) == []
|
| 81 |
+
|
| 82 |
+
state.step = 5
|
| 83 |
+
fired = DriftInjector.tick(state, tools)
|
| 84 |
+
assert [e.tool for e in fired] == ["calendar"]
|
| 85 |
+
cal_params = tools["calendar"].active_schemas["create_event"].params
|
| 86 |
+
assert "participants" in cal_params
|
| 87 |
+
assert "attendees" not in cal_params
|
| 88 |
+
|
| 89 |
+
state.step = 6
|
| 90 |
+
assert DriftInjector.tick(state, tools) == []
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_drift_not_fired_before_step() -> None:
|
| 94 |
+
drift = DriftEvent(
|
| 95 |
+
tool="calendar", endpoint="create_event", kind="field_rename",
|
| 96 |
+
fires_at_step=5, details={},
|
| 97 |
+
)
|
| 98 |
+
state = _make_state([drift])
|
| 99 |
+
tools = _make_tools()
|
| 100 |
+
|
| 101 |
+
state.step = 2
|
| 102 |
+
fired = DriftInjector.tick(state, tools)
|
| 103 |
+
assert fired == []
|
| 104 |
+
cal_params = tools["calendar"].active_schemas["create_event"].params
|
| 105 |
+
assert "attendees" in cal_params
|
| 106 |
+
assert "participants" not in cal_params
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_detected_by_agent_unchanged_by_tick() -> None:
|
| 110 |
+
drift = DriftEvent(
|
| 111 |
+
tool="mail", endpoint="list_messages", kind="field_rename",
|
| 112 |
+
fires_at_step=1, details={},
|
| 113 |
+
)
|
| 114 |
+
state = _make_state([drift])
|
| 115 |
+
tools = _make_tools()
|
| 116 |
+
assert drift.detected_by_agent is False
|
| 117 |
+
|
| 118 |
+
state.step = 1
|
| 119 |
+
fired = DriftInjector.tick(state, tools)
|
| 120 |
+
assert len(fired) == 1
|
| 121 |
+
assert fired[0].detected_by_agent is False
|
| 122 |
+
assert state.drift_plan[0].detected_by_agent is False
|
tools/calendar.py
CHANGED
|
@@ -1,4 +1,164 @@
|
|
| 1 |
-
"""CalendarAPI — list_events, create_event, update_event, delete_event +
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CalendarAPI — list_events, create_event, update_event, delete_event + 2 drift handlers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from models import DriftEvent, ToolResponse
|
| 5 |
+
from tools.base import BaseTool, EndpointSchema
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
_LIST_EVENTS_BASE = EndpointSchema(
|
| 9 |
+
name="list_events",
|
| 10 |
+
params={"date_from": "str", "date_to": "str", "limit": "int"},
|
| 11 |
+
required=["date_from", "date_to"],
|
| 12 |
+
response_shape={"events": "list"},
|
| 13 |
+
error_codes={200: "ok", 400: "bad_request"},
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
_CREATE_EVENT_BASE = EndpointSchema(
|
| 17 |
+
name="create_event",
|
| 18 |
+
params={
|
| 19 |
+
"title": "str", "start": "str", "end": "str",
|
| 20 |
+
"attendees": "list", "location": "str",
|
| 21 |
+
},
|
| 22 |
+
required=["title", "start", "end", "attendees"],
|
| 23 |
+
response_shape={
|
| 24 |
+
"event_id": "str", "title": "str", "start": "str", "end": "str",
|
| 25 |
+
"attendees": "list", "status": "str",
|
| 26 |
+
},
|
| 27 |
+
error_codes={200: "ok", 400: "bad_request", 422: "validation"},
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
_UPDATE_EVENT_BASE = EndpointSchema(
|
| 31 |
+
name="update_event",
|
| 32 |
+
params={
|
| 33 |
+
"event_id": "str", "title": "str", "start": "str", "end": "str",
|
| 34 |
+
"attendees": "list", "location": "str", "status": "str",
|
| 35 |
+
},
|
| 36 |
+
required=["event_id"],
|
| 37 |
+
response_shape={"event_id": "str", "status": "str", "title": "str"},
|
| 38 |
+
error_codes={200: "ok", 404: "not_found"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
_DELETE_EVENT_BASE = EndpointSchema(
|
| 42 |
+
name="delete_event",
|
| 43 |
+
params={"event_id": "str"},
|
| 44 |
+
required=["event_id"],
|
| 45 |
+
response_shape={"deleted": "bool", "event_id": "str"},
|
| 46 |
+
error_codes={200: "ok", 404: "not_found"},
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class CalendarAPI(BaseTool):
|
| 51 |
+
name = "calendar"
|
| 52 |
+
baseline_schemas = {
|
| 53 |
+
"list_events": _LIST_EVENTS_BASE,
|
| 54 |
+
"create_event": _CREATE_EVENT_BASE,
|
| 55 |
+
"update_event": _UPDATE_EVENT_BASE,
|
| 56 |
+
"delete_event": _DELETE_EVENT_BASE,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
def __init__(self, seed_data: dict) -> None:
|
| 60 |
+
super().__init__()
|
| 61 |
+
self.events: list[dict] = list(seed_data.get("events", []))
|
| 62 |
+
self._next_id: int = len(self.events) + 1
|
| 63 |
+
self.handlers = {
|
| 64 |
+
"list_events": self._list_events,
|
| 65 |
+
"create_event": self._create_event,
|
| 66 |
+
"update_event": self._update_event,
|
| 67 |
+
"delete_event": self._delete_event,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
def _list_events(self, params: dict) -> ToolResponse:
|
| 71 |
+
date_from = params["date_from"]
|
| 72 |
+
date_to = params["date_to"]
|
| 73 |
+
# Overlap: event starts on/before date_to AND ends on/after date_from.
|
| 74 |
+
filtered = [
|
| 75 |
+
e for e in self.events
|
| 76 |
+
if e.get("start", "") <= date_to and e.get("end", "") >= date_from
|
| 77 |
+
]
|
| 78 |
+
return ToolResponse(ok=True, status=200, body={"events": filtered[:10]})
|
| 79 |
+
|
| 80 |
+
def _create_event(self, params: dict) -> ToolResponse:
|
| 81 |
+
event_id = f"evt_{self._next_id}"
|
| 82 |
+
self._next_id += 1
|
| 83 |
+
|
| 84 |
+
title = params.get("title", "")
|
| 85 |
+
start = params.get("start", "")
|
| 86 |
+
end = params.get("end", "")
|
| 87 |
+
location = params.get("location", "")
|
| 88 |
+
status = params.get("status", "confirmed")
|
| 89 |
+
|
| 90 |
+
shape = self.active_schemas["create_event"].response_shape
|
| 91 |
+
drifted = "participants" in shape
|
| 92 |
+
|
| 93 |
+
if drifted:
|
| 94 |
+
if "participants" in params:
|
| 95 |
+
participants = list(params["participants"])
|
| 96 |
+
elif "attendees" in params:
|
| 97 |
+
participants = [
|
| 98 |
+
{"email": e, "role": "required"} for e in params["attendees"]
|
| 99 |
+
]
|
| 100 |
+
else:
|
| 101 |
+
participants = []
|
| 102 |
+
event = {
|
| 103 |
+
"event_id": event_id, "title": title, "start": start, "end": end,
|
| 104 |
+
"participants": participants, "location": location, "status": status,
|
| 105 |
+
}
|
| 106 |
+
else:
|
| 107 |
+
attendees = list(params.get("attendees", []))
|
| 108 |
+
event = {
|
| 109 |
+
"event_id": event_id, "title": title, "start": start, "end": end,
|
| 110 |
+
"attendees": attendees, "location": location, "status": status,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
self.events.append(event)
|
| 114 |
+
return ToolResponse(ok=True, status=200, body=dict(event))
|
| 115 |
+
|
| 116 |
+
def _update_event(self, params: dict) -> ToolResponse:
|
| 117 |
+
event_id = params["event_id"]
|
| 118 |
+
for event in self.events:
|
| 119 |
+
if event.get("event_id") == event_id:
|
| 120 |
+
for field in (
|
| 121 |
+
"title", "start", "end", "attendees", "participants",
|
| 122 |
+
"location", "status",
|
| 123 |
+
):
|
| 124 |
+
if field in params:
|
| 125 |
+
event[field] = params[field]
|
| 126 |
+
return ToolResponse(ok=True, status=200, body=dict(event))
|
| 127 |
+
return ToolResponse(
|
| 128 |
+
ok=False, status=404, error=f"event {event_id} not found"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def _delete_event(self, params: dict) -> ToolResponse:
|
| 132 |
+
event_id = params["event_id"]
|
| 133 |
+
for i, event in enumerate(self.events):
|
| 134 |
+
if event.get("event_id") == event_id:
|
| 135 |
+
self.events.pop(i)
|
| 136 |
+
return ToolResponse(
|
| 137 |
+
ok=True, status=200,
|
| 138 |
+
body={"deleted": True, "event_id": event_id},
|
| 139 |
+
)
|
| 140 |
+
return ToolResponse(
|
| 141 |
+
ok=False, status=404, error=f"event {event_id} not found"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
def apply_drift(self, event: DriftEvent) -> None:
|
| 145 |
+
if event.kind == "field_rename" and event.endpoint == "create_event":
|
| 146 |
+
schema = self.active_schemas["create_event"]
|
| 147 |
+
schema.params.pop("attendees", None)
|
| 148 |
+
schema.params["participants"] = "list"
|
| 149 |
+
schema.required = [
|
| 150 |
+
"participants" if r == "attendees" else r for r in schema.required
|
| 151 |
+
]
|
| 152 |
+
schema.response_shape.pop("attendees", None)
|
| 153 |
+
schema.response_shape["participants"] = "list"
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
if event.kind == "tool_removal" and event.endpoint == "delete_event":
|
| 157 |
+
self.active_schemas.pop("delete_event", None)
|
| 158 |
+
self.handlers.pop("delete_event", None)
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
raise ValueError(
|
| 162 |
+
f"CalendarAPI.apply_drift: unhandled drift "
|
| 163 |
+
f"kind={event.kind!r} endpoint={event.endpoint!r}"
|
| 164 |
+
)
|