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

Phase 4: CRMAPI with 3 drifts + scenarios E1/E2/E3

Browse files
Files changed (5) hide show
  1. scenarios.py +123 -3
  2. tests/test_crm.py +142 -0
  3. tests/test_scenarios.py +65 -0
  4. tools/base.py +7 -0
  5. tools/crm.py +212 -3
scenarios.py CHANGED
@@ -1,4 +1,124 @@
1
- """Scenario definitions (E1-E3 easy, M1-M3 medium, H1-H3 hard).
 
2
 
3
- Will be filled in Phase 4 (easy) and Phase 11 (medium+).
4
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scenario definitions (E1-E3 easy). Medium/hard added in Phase 11."""
2
+ from __future__ import annotations
3
 
4
+ from models import DriftEvent
5
+
6
+
7
+ SCENARIOS: dict[str, dict] = {
8
+ "E1_onboard_new_hire": {
9
+ "difficulty": "easy",
10
+ "max_steps": 8,
11
+ "token_budget": 4000,
12
+ "task_description": (
13
+ "A new employee named Priya Sharma (priya@company.com) starts on "
14
+ "Monday April 27. Send her a welcome email at priya@company.com "
15
+ "with subject containing 'welcome', then create a calendar event "
16
+ "titled 'New Hire Orientation' on Monday April 27 at 10:00 AM for "
17
+ "1 hour, inviting both Priya and her manager alex@company.com."
18
+ ),
19
+ "success_criteria": [
20
+ "Welcome email sent to priya@company.com with 'welcome' in subject",
21
+ "Calendar event created for Monday April 27 10am with both Priya "
22
+ "and Alex attending",
23
+ ],
24
+ "seed_data": {
25
+ "mail": {"messages": []},
26
+ "calendar": {"events": []},
27
+ },
28
+ "drift_plan": [
29
+ DriftEvent(
30
+ tool="calendar",
31
+ endpoint="create_event",
32
+ kind="field_rename",
33
+ fires_at_step=3,
34
+ details={"from": "attendees", "to": "participants"},
35
+ ),
36
+ ],
37
+ "ground_truth_final_state": {
38
+ "mail.sent_count": 1,
39
+ "mail.last_sent_to": "priya@company.com",
40
+ "mail.last_subject_contains_welcome": True,
41
+ "calendar.events_count": 1,
42
+ "calendar.last_event_has_both_attendees": True,
43
+ },
44
+ "required_tools": ["mail", "calendar"],
45
+ },
46
+
47
+ "E2_meeting_invite_blast": {
48
+ "difficulty": "easy",
49
+ "max_steps": 6,
50
+ "token_budget": 4000,
51
+ "task_description": (
52
+ "Send calendar invite emails to three team members for an "
53
+ "all-hands meeting at 3:00 PM Friday. Recipients: "
54
+ "alex@company.com, jordan@company.com, sam@company.com. "
55
+ "Subject: 'All-Hands: Friday 3pm'. "
56
+ "Body should mention the time and agenda."
57
+ ),
58
+ "success_criteria": [
59
+ "Three emails sent to the three listed recipients",
60
+ "All three emails have subject containing 'All-Hands'",
61
+ ],
62
+ "seed_data": {
63
+ "mail": {"messages": []},
64
+ },
65
+ "drift_plan": [
66
+ DriftEvent(
67
+ tool="mail",
68
+ endpoint="send_message",
69
+ kind="endpoint_deprecation",
70
+ fires_at_step=1,
71
+ details={"replacement": "messages.send"},
72
+ ),
73
+ ],
74
+ "ground_truth_final_state": {
75
+ "mail.sent_count": 3,
76
+ "mail.sent_to_all_three_recipients": True,
77
+ },
78
+ "required_tools": ["mail"],
79
+ },
80
+
81
+ "E3_customer_lookup": {
82
+ "difficulty": "easy",
83
+ "max_steps": 8,
84
+ "token_budget": 4000,
85
+ "task_description": (
86
+ "A customer emailed support asking about their account: "
87
+ "bob@customer.com. Look them up in the CRM by email, retrieve "
88
+ "their full contact record, and update their status field to "
89
+ "'support_in_progress'. Report back their company name in your "
90
+ "final summary."
91
+ ),
92
+ "success_criteria": [
93
+ "Customer bob@customer.com found in CRM",
94
+ "Contact status updated to 'support_in_progress'",
95
+ "Completion summary mentions the customer's company",
96
+ ],
97
+ "seed_data": {
98
+ "crm": {
99
+ "contacts": [
100
+ {"contact_id": "c_1", "customer_email": "alice@customer.com",
101
+ "name": "Alice Nguyen", "company": "Acme Corp", "status": "active"},
102
+ {"contact_id": "c_2", "customer_email": "bob@customer.com",
103
+ "name": "Bob Taylor", "company": "Globex Industries", "status": "active"},
104
+ {"contact_id": "c_3", "customer_email": "carol@customer.com",
105
+ "name": "Carol Davis", "company": "Initech", "status": "inactive"},
106
+ ],
107
+ },
108
+ },
109
+ "drift_plan": [
110
+ DriftEvent(
111
+ tool="crm",
112
+ endpoint=None,
113
+ kind="field_rename",
114
+ fires_at_step=2,
115
+ details={"from": "customer_email", "to": "email_address"},
116
+ ),
117
+ ],
118
+ "ground_truth_final_state": {
119
+ "crm.contact_c_2_status": "support_in_progress",
120
+ "complete_summary_mentions_company": True,
121
+ },
122
+ "required_tools": ["crm"],
123
+ },
124
+ }
tests/test_crm.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CRMAPI acceptance tests — Phase 4."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from models import DriftEvent
7
+ from tools.crm import CRMAPI
8
+
9
+
10
+ def _fresh_crm() -> CRMAPI:
11
+ return CRMAPI(seed_data={
12
+ "contacts": [
13
+ {"contact_id": "c_1", "customer_email": "alice@customer.com",
14
+ "name": "Alice Nguyen", "company": "Acme Corp", "status": "active"},
15
+ {"contact_id": "c_2", "customer_email": "bob@customer.com",
16
+ "name": "Bob Taylor", "company": "Globex Industries", "status": "active"},
17
+ {"contact_id": "c_3", "customer_email": "carol@customer.com",
18
+ "name": "Carol Davis", "company": "Initech", "status": "inactive"},
19
+ ]
20
+ })
21
+
22
+
23
+ def test_baseline_search_contacts() -> None:
24
+ crm = _fresh_crm()
25
+ by_email = crm.call("search_contacts", {"customer_email": "bob@customer.com"})
26
+ assert by_email.ok is True
27
+ assert by_email.status == 200
28
+ assert by_email.body is not None
29
+ assert by_email.body["total"] == 1
30
+ assert by_email.body["contacts"][0]["name"] == "Bob Taylor"
31
+ assert by_email.body["contacts"][0]["customer_email"] == "bob@customer.com"
32
+
33
+ by_name = crm.call("search_contacts", {"name": "Alice"})
34
+ assert by_name.ok is True
35
+ assert by_name.body["total"] == 1
36
+ assert by_name.body["contacts"][0]["contact_id"] == "c_1"
37
+
38
+ all_contacts = crm.call("search_contacts", {})
39
+ assert all_contacts.ok is True
40
+ assert all_contacts.body["total"] == 3
41
+
42
+
43
+ def test_baseline_create_and_get() -> None:
44
+ crm = _fresh_crm()
45
+ created = crm.call("create_contact", {
46
+ "customer_email": "dan@customer.com",
47
+ "name": "Dan Ellis",
48
+ "company": "DEMO Corp",
49
+ })
50
+ assert created.ok is True
51
+ assert created.status == 200
52
+ cid = created.body["contact_id"]
53
+ assert cid.startswith("c_")
54
+
55
+ got = crm.call("get_contact", {"contact_id": cid})
56
+ assert got.ok is True
57
+ assert got.status == 200
58
+ assert got.body is not None
59
+ assert got.body["name"] == "Dan Ellis"
60
+ assert got.body["customer_email"] == "dan@customer.com"
61
+ assert got.body["company"] == "DEMO Corp"
62
+
63
+
64
+ def test_drift_field_rename_crm_tool_wide() -> None:
65
+ crm = _fresh_crm()
66
+ event = DriftEvent(
67
+ tool="crm", endpoint=None, kind="field_rename",
68
+ fires_at_step=1, details={},
69
+ )
70
+ crm.apply_drift(event)
71
+
72
+ for ep in ("search_contacts", "get_contact", "create_contact", "update_contact"):
73
+ schema = crm.get_schema(ep)
74
+ assert "customer_email" not in schema["params"]
75
+ assert "customer_email" not in schema["required"]
76
+ assert "customer_email" not in schema["response_shape"]
77
+
78
+ assert "email_address" in crm.get_schema("search_contacts")["params"]
79
+ assert "email_address" in crm.get_schema("create_contact")["required"]
80
+ assert "email_address" in crm.get_schema("get_contact")["response_shape"]
81
+
82
+ bad = crm.call("search_contacts", {"customer_email": "bob@customer.com"})
83
+ assert bad.ok is False
84
+ assert bad.status == 400
85
+
86
+ ok = crm.call("search_contacts", {"email_address": "bob@customer.com"})
87
+ assert ok.ok is True
88
+ assert ok.status == 200
89
+ assert ok.body is not None
90
+ assert ok.body["total"] == 1
91
+ contact = ok.body["contacts"][0]
92
+ assert "email_address" in contact
93
+ assert "customer_email" not in contact
94
+ assert contact["email_address"] == "bob@customer.com"
95
+
96
+
97
+ def test_drift_rate_limit_tightening() -> None:
98
+ crm = _fresh_crm()
99
+ event = DriftEvent(
100
+ tool="crm", endpoint=None, kind="rate_limit_tightening",
101
+ fires_at_step=1, details={},
102
+ )
103
+ crm.apply_drift(event)
104
+
105
+ r1 = crm.call("search_contacts", {})
106
+ assert r1.ok is True
107
+
108
+ r2 = crm.call("search_contacts", {})
109
+ assert r2.ok is True
110
+
111
+ r3 = crm.call("search_contacts", {})
112
+ assert r3.ok is False
113
+ assert r3.status == 429
114
+
115
+
116
+ def test_drift_endpoint_deprecation_update_contact() -> None:
117
+ crm = _fresh_crm()
118
+ event = DriftEvent(
119
+ tool="crm", endpoint="update_contact", kind="endpoint_deprecation",
120
+ fires_at_step=1, details={"replacement": "contacts.patch"},
121
+ )
122
+ crm.apply_drift(event)
123
+
124
+ old = crm.call("update_contact", {"contact_id": "c_1", "status": "updated"})
125
+ assert old.ok is False
126
+ assert old.status == 410
127
+
128
+ new = crm.call("contacts.patch", {"contact_id": "c_1", "status": "updated"})
129
+ assert new.ok is True
130
+ assert new.status == 200
131
+ assert new.body is not None
132
+ assert new.body["status"] == "updated"
133
+
134
+
135
+ def test_crm_unknown_drift_raises() -> None:
136
+ crm = _fresh_crm()
137
+ event = DriftEvent(
138
+ tool="crm", endpoint="search_contacts", kind="tool_removal",
139
+ fires_at_step=1, details={},
140
+ )
141
+ with pytest.raises(ValueError):
142
+ crm.apply_drift(event)
tests/test_scenarios.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scenario structure tests — Phase 4."""
2
+ from __future__ import annotations
3
+
4
+ from models import DriftEvent
5
+ from scenarios import SCENARIOS
6
+
7
+
8
+ REQUIRED_KEYS = {
9
+ "difficulty",
10
+ "max_steps",
11
+ "token_budget",
12
+ "task_description",
13
+ "success_criteria",
14
+ "seed_data",
15
+ "drift_plan",
16
+ "ground_truth_final_state",
17
+ "required_tools",
18
+ }
19
+
20
+
21
+ def test_all_three_scenarios_present() -> None:
22
+ assert set(SCENARIOS.keys()) == {
23
+ "E1_onboard_new_hire",
24
+ "E2_meeting_invite_blast",
25
+ "E3_customer_lookup",
26
+ }
27
+
28
+
29
+ def test_each_scenario_has_required_fields() -> None:
30
+ for name, sc in SCENARIOS.items():
31
+ missing = REQUIRED_KEYS - set(sc.keys())
32
+ assert not missing, f"{name} missing keys: {missing}"
33
+
34
+
35
+ def test_drift_plans_contain_valid_events() -> None:
36
+ valid_tools = {"mail", "calendar", "crm", "chat", "docs"}
37
+ for name, sc in SCENARIOS.items():
38
+ plan = sc["drift_plan"]
39
+ assert isinstance(plan, list)
40
+ assert len(plan) > 0, f"{name}: drift_plan must have at least one event"
41
+ for d in plan:
42
+ assert isinstance(d, DriftEvent), f"{name}: non-DriftEvent in drift_plan"
43
+ assert d.tool in valid_tools
44
+ assert isinstance(d.fires_at_step, int)
45
+ assert d.fires_at_step >= 0
46
+
47
+
48
+ def test_required_tools_match_scenario_intent() -> None:
49
+ assert SCENARIOS["E1_onboard_new_hire"]["required_tools"] == ["mail", "calendar"]
50
+ assert SCENARIOS["E2_meeting_invite_blast"]["required_tools"] == ["mail"]
51
+ assert SCENARIOS["E3_customer_lookup"]["required_tools"] == ["crm"]
52
+
53
+ for name, sc in SCENARIOS.items():
54
+ seed_keys = set(sc["seed_data"].keys())
55
+ req = set(sc["required_tools"])
56
+ assert seed_keys.issubset(req), (
57
+ f"{name}: seed_data keys {seed_keys} not a subset of required_tools {req}"
58
+ )
59
+
60
+
61
+ def test_ground_truth_keys_non_empty() -> None:
62
+ for name, sc in SCENARIOS.items():
63
+ gt = sc["ground_truth_final_state"]
64
+ assert isinstance(gt, dict)
65
+ assert len(gt) > 0, f"{name}: ground_truth_final_state is empty"
tools/base.py CHANGED
@@ -40,6 +40,13 @@ class BaseTool:
40
  error=f"Endpoint '{endpoint}' is no longer available on {self.name}.",
41
  )
42
  schema = self.active_schemas[endpoint]
 
 
 
 
 
 
 
43
  missing = [p for p in schema.required if p not in params]
44
  if missing:
45
  return ToolResponse(
 
40
  error=f"Endpoint '{endpoint}' is no longer available on {self.name}.",
41
  )
42
  schema = self.active_schemas[endpoint]
43
+ unknown = [p for p in params if p not in schema.params]
44
+ if unknown:
45
+ return ToolResponse(
46
+ ok=False,
47
+ status=400,
48
+ error=f"Unknown params on {endpoint}: {unknown}",
49
+ )
50
  missing = [p for p in schema.required if p not in params]
51
  if missing:
52
  return ToolResponse(
tools/crm.py CHANGED
@@ -1,4 +1,213 @@
1
- """CRMAPI — search_contacts, get_contact, create_contact, update_contact + drifts.
 
2
 
3
- Will be filled in Phase 4.
4
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CRMAPI — search_contacts, get_contact, create_contact, update_contact + 3 drift handlers."""
2
+ from __future__ import annotations
3
 
4
+ import copy
5
+
6
+ from models import DriftEvent, ToolResponse
7
+ from tools.base import BaseTool, EndpointSchema
8
+
9
+
10
+ _SEARCH_CONTACTS_BASE = EndpointSchema(
11
+ name="search_contacts",
12
+ params={"customer_email": "str", "name": "str", "limit": "int"},
13
+ required=[],
14
+ response_shape={"contacts": "list", "total": "int"},
15
+ error_codes={200: "ok", 400: "bad_request", 429: "rate_limit"},
16
+ )
17
+
18
+ _GET_CONTACT_BASE = EndpointSchema(
19
+ name="get_contact",
20
+ params={"contact_id": "str"},
21
+ required=["contact_id"],
22
+ response_shape={
23
+ "contact_id": "str", "name": "str", "customer_email": "str",
24
+ "company": "str", "status": "str",
25
+ },
26
+ error_codes={200: "ok", 404: "not_found", 429: "rate_limit"},
27
+ )
28
+
29
+ _CREATE_CONTACT_BASE = EndpointSchema(
30
+ name="create_contact",
31
+ params={"customer_email": "str", "name": "str", "company": "str"},
32
+ required=["customer_email", "name"],
33
+ response_shape={
34
+ "contact_id": "str", "name": "str",
35
+ "customer_email": "str", "status": "str",
36
+ },
37
+ error_codes={200: "ok", 400: "bad_request", 422: "validation", 429: "rate_limit"},
38
+ )
39
+
40
+ _UPDATE_CONTACT_BASE = EndpointSchema(
41
+ name="update_contact",
42
+ params={
43
+ "contact_id": "str", "customer_email": "str", "name": "str",
44
+ "company": "str", "status": "str",
45
+ },
46
+ required=["contact_id"],
47
+ response_shape={"contact_id": "str", "status": "str"},
48
+ error_codes={200: "ok", 404: "not_found", 422: "validation", 429: "rate_limit"},
49
+ )
50
+
51
+
52
+ class CRMAPI(BaseTool):
53
+ name = "crm"
54
+ baseline_schemas = {
55
+ "search_contacts": _SEARCH_CONTACTS_BASE,
56
+ "get_contact": _GET_CONTACT_BASE,
57
+ "create_contact": _CREATE_CONTACT_BASE,
58
+ "update_contact": _UPDATE_CONTACT_BASE,
59
+ }
60
+
61
+ def __init__(self, seed_data: dict) -> None:
62
+ super().__init__()
63
+ self.contacts: list[dict] = list(seed_data.get("contacts", []))
64
+ self._next_id: int = len(self.contacts) + 1
65
+ self._call_count: int = 0
66
+ self._rate_limit_active: bool = False
67
+ self.handlers = {
68
+ "search_contacts": self._search_contacts,
69
+ "get_contact": self._get_contact,
70
+ "create_contact": self._create_contact,
71
+ "update_contact": self._update_contact,
72
+ }
73
+
74
+ def call(self, endpoint: str, params: dict) -> ToolResponse:
75
+ rl = self._check_rate_limit()
76
+ if rl is not None:
77
+ return rl
78
+ return super().call(endpoint, params)
79
+
80
+ def _check_rate_limit(self) -> ToolResponse | None:
81
+ self._call_count += 1
82
+ if self._rate_limit_active and self._call_count > 2:
83
+ return ToolResponse(ok=False, status=429, error="Rate limit exceeded")
84
+ return None
85
+
86
+ def _email_field_active(self) -> str:
87
+ any_schema = next(iter(self.active_schemas.values()))
88
+ if "email_address" in any_schema.response_shape or "email_address" in any_schema.params:
89
+ return "email_address"
90
+ for s in self.active_schemas.values():
91
+ if "email_address" in s.response_shape or "email_address" in s.params:
92
+ return "email_address"
93
+ return "customer_email"
94
+
95
+ def _normalize_email_in(self, params: dict) -> dict:
96
+ out = dict(params)
97
+ if "email_address" in out and "customer_email" not in out:
98
+ out["customer_email"] = out.pop("email_address")
99
+ return out
100
+
101
+ def _project_contact(self, contact: dict) -> dict:
102
+ out = dict(contact)
103
+ drifted = self._email_field_active() == "email_address"
104
+ if drifted and "customer_email" in out:
105
+ out["email_address"] = out.pop("customer_email")
106
+ elif not drifted and "email_address" in out:
107
+ out["customer_email"] = out.pop("email_address")
108
+ return out
109
+
110
+ def _search_contacts(self, params: dict) -> ToolResponse:
111
+ email = params.get("email_address") or params.get("customer_email")
112
+ name = params.get("name")
113
+
114
+ if email is None and name is None:
115
+ matched = list(self.contacts)
116
+ else:
117
+ matched = []
118
+ for c in self.contacts:
119
+ email_match = email is not None and c.get("customer_email", "").lower() == email.lower()
120
+ name_match = name is not None and name.lower() in c.get("name", "").lower()
121
+ if email_match or name_match:
122
+ matched.append(c)
123
+
124
+ projected = [self._project_contact(c) for c in matched]
125
+ return ToolResponse(
126
+ ok=True, status=200,
127
+ body={"contacts": projected, "total": len(projected)},
128
+ )
129
+
130
+ def _get_contact(self, params: dict) -> ToolResponse:
131
+ cid = params["contact_id"]
132
+ for c in self.contacts:
133
+ if c.get("contact_id") == cid:
134
+ return ToolResponse(ok=True, status=200, body=self._project_contact(c))
135
+ return ToolResponse(ok=False, status=404, error=f"contact {cid} not found")
136
+
137
+ def _create_contact(self, params: dict) -> ToolResponse:
138
+ normalized = self._normalize_email_in(params)
139
+ cid = f"c_{self._next_id}"
140
+ self._next_id += 1
141
+ contact = {
142
+ "contact_id": cid,
143
+ "customer_email": normalized.get("customer_email", ""),
144
+ "name": normalized.get("name", ""),
145
+ "company": normalized.get("company", ""),
146
+ "status": normalized.get("status", "active"),
147
+ }
148
+ self.contacts.append(contact)
149
+ return ToolResponse(ok=True, status=200, body=self._project_contact(contact))
150
+
151
+ def _update_contact(self, params: dict) -> ToolResponse:
152
+ cid = params["contact_id"]
153
+ normalized = self._normalize_email_in(params)
154
+ for c in self.contacts:
155
+ if c.get("contact_id") == cid:
156
+ for field in ("customer_email", "name", "company", "status"):
157
+ if field in normalized:
158
+ c[field] = normalized[field]
159
+ return ToolResponse(ok=True, status=200, body=self._project_contact(c))
160
+ return ToolResponse(ok=False, status=404, error=f"contact {cid} not found")
161
+
162
+ def apply_drift(self, event: DriftEvent) -> None:
163
+ if event.kind == "field_rename":
164
+ targets = (
165
+ "search_contacts", "get_contact", "create_contact",
166
+ "update_contact", "contacts.patch",
167
+ )
168
+ for ep in targets:
169
+ if ep not in self.active_schemas:
170
+ continue
171
+ schema = self.active_schemas[ep]
172
+ if "customer_email" in schema.params:
173
+ schema.params.pop("customer_email")
174
+ schema.params["email_address"] = "str"
175
+ if "customer_email" in schema.required:
176
+ schema.required = [
177
+ "email_address" if r == "customer_email" else r
178
+ for r in schema.required
179
+ ]
180
+ if "customer_email" in schema.response_shape:
181
+ schema.response_shape.pop("customer_email")
182
+ schema.response_shape["email_address"] = "str"
183
+ return
184
+
185
+ if event.kind == "rate_limit_tightening":
186
+ self._rate_limit_active = True
187
+ self._call_count = 0
188
+ return
189
+
190
+ if event.kind == "endpoint_deprecation" and event.endpoint == "update_contact":
191
+ drifted_rename = any(
192
+ "email_address" in s.params or "email_address" in s.response_shape
193
+ for s in self.active_schemas.values()
194
+ )
195
+ self.active_schemas.pop("update_contact", None)
196
+ self.handlers.pop("update_contact", None)
197
+ new_schema = copy.deepcopy(self.baseline_schemas["update_contact"])
198
+ new_schema.name = "contacts.patch"
199
+ if drifted_rename:
200
+ if "customer_email" in new_schema.params:
201
+ new_schema.params.pop("customer_email")
202
+ new_schema.params["email_address"] = "str"
203
+ if "customer_email" in new_schema.response_shape:
204
+ new_schema.response_shape.pop("customer_email")
205
+ new_schema.response_shape["email_address"] = "str"
206
+ self.active_schemas["contacts.patch"] = new_schema
207
+ self.handlers["contacts.patch"] = self._update_contact
208
+ return
209
+
210
+ raise ValueError(
211
+ f"CRMAPI.apply_drift: unhandled drift "
212
+ f"kind={event.kind!r} endpoint={event.endpoint!r}"
213
+ )