yashash04 commited on
Commit
27dd958
·
1 Parent(s): 8410ff1

Phase 3: CalendarAPI with 2 drifts + DriftInjector

Browse files
Files changed (4) hide show
  1. drift.py +27 -3
  2. tests/test_calendar.py +182 -0
  3. tests/test_drift.py +122 -0
  4. tools/calendar.py +163 -3
drift.py CHANGED
@@ -1,4 +1,28 @@
1
- """DriftInjector — fires scheduled drift events against tools mid-episode.
 
2
 
3
- Will be filled in Phase 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 + drifts.
 
2
 
3
- Will be filled in Phase 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
+ )