yashash04 commited on
Commit
c618a61
·
1 Parent(s): 46b8c56

Phase 6: SchemaShiftEnvironment scheduler — full episode lifecycle

Browse files
Files changed (2) hide show
  1. server/environment.py +360 -1
  2. tests/test_environment.py +242 -0
server/environment.py CHANGED
@@ -1,4 +1,363 @@
1
  """SchemaShiftEnvironment — episode scheduler. reset/step loop with drift ticks + grader.
2
 
3
- Will be filled in Phase 6.
4
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """SchemaShiftEnvironment — episode scheduler. reset/step loop with drift ticks + grader.
2
 
3
+ Round 1 bug prevention: step() raises RuntimeError if called before reset(). Never lazy-init.
4
  """
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from copy import deepcopy
9
+ from typing import Any
10
+
11
+ from drift import DriftInjector
12
+ from graders import build_grader, compute_step_shaping
13
+ from models import (
14
+ Action,
15
+ DriftReportParams,
16
+ EpisodeState,
17
+ HistoryStep,
18
+ Observation,
19
+ RewardBreakdown,
20
+ ToolResponse,
21
+ )
22
+ from scenarios import SCENARIOS
23
+
24
+
25
+ def _instantiate_tool(name: str, seed_data: dict) -> Any:
26
+ """Lazy import so missing stretch tools don't break core scenarios."""
27
+ if name == "mail":
28
+ from tools.mail import MailAPI
29
+ return MailAPI(seed_data)
30
+ if name == "calendar":
31
+ from tools.calendar import CalendarAPI
32
+ return CalendarAPI(seed_data)
33
+ if name == "crm":
34
+ from tools.crm import CRMAPI
35
+ return CRMAPI(seed_data)
36
+ if name == "chat":
37
+ from tools.chat import ChatAPI # type: ignore[attr-defined]
38
+ return ChatAPI(seed_data)
39
+ if name == "docs":
40
+ from tools.docs import DocsAPI # type: ignore[attr-defined]
41
+ return DocsAPI(seed_data)
42
+ raise ValueError(f"Unknown tool: {name}")
43
+
44
+
45
+ class SchemaShiftEnvironment:
46
+ """The SchemaShift RL environment. One instance = one episode at a time."""
47
+
48
+ def __init__(self) -> None:
49
+ self._state: EpisodeState | None = None
50
+ self._tools: dict[str, Any] = {}
51
+ self._grader = build_grader()
52
+
53
+ # ──────────────────────────────────────────────────────────────
54
+ # Public API
55
+ # ──────────────────────────────────────────────────────────────
56
+
57
+ def reset(self, task_id: str, seed: int = 0) -> Observation:
58
+ if task_id not in SCENARIOS:
59
+ raise ValueError(
60
+ f"Unknown task_id: {task_id}. Available: {list(SCENARIOS.keys())}"
61
+ )
62
+
63
+ scenario = SCENARIOS[task_id]
64
+ required_tools = scenario["required_tools"]
65
+
66
+ self._tools = {}
67
+ for tool_name in required_tools:
68
+ tool_seed = scenario["seed_data"].get(tool_name, {})
69
+ self._tools[tool_name] = _instantiate_tool(tool_name, tool_seed)
70
+
71
+ self._state = EpisodeState(
72
+ episode_id=str(uuid.uuid4()),
73
+ task_id=task_id,
74
+ difficulty=scenario["difficulty"],
75
+ step=0,
76
+ max_steps=scenario["max_steps"],
77
+ token_budget=scenario["token_budget"],
78
+ token_budget_remaining=scenario["token_budget"],
79
+ drift_plan=deepcopy(scenario["drift_plan"]),
80
+ ground_truth_final_state=dict(scenario["ground_truth_final_state"]),
81
+ agent_state={},
82
+ history=[],
83
+ done=False,
84
+ cumulative_reward=0.0,
85
+ )
86
+
87
+ return self._observation("Episode started.")
88
+
89
+ def step(
90
+ self, action: Action, tokens_used: int = 0
91
+ ) -> tuple[Observation, RewardBreakdown]:
92
+ if self._state is None:
93
+ raise RuntimeError(
94
+ "Call reset() before step(). "
95
+ "SchemaShiftEnvironment requires an active episode."
96
+ )
97
+ s = self._state
98
+ if s.done:
99
+ raise RuntimeError(
100
+ "Episode already done. Call reset() to start a new episode."
101
+ )
102
+
103
+ s.step += 1
104
+ s.token_budget_remaining = max(0, s.token_budget_remaining - tokens_used)
105
+
106
+ # 1. Apply any scheduled drifts for this step
107
+ fired_drifts = DriftInjector.tick(s, self._tools)
108
+
109
+ # 2. Dispatch action (does NOT mark drift detected — that happens after shaping)
110
+ response, feedback = self._dispatch_action(action)
111
+
112
+ # 3. Compute step shaping BEFORE marking drift detected
113
+ # (shaping checks `not d.detected_by_agent` — must run pre-mark)
114
+ step_shape = compute_step_shaping(s, action, response)
115
+
116
+ # 4. Now apply drift-detection mark (so grader sees the detection this step)
117
+ if action.type == "report_drift" and action.report is not None:
118
+ self._mark_drift_detected(action.report)
119
+
120
+ # 5. Update agent_state from action+response
121
+ self._update_agent_state(action, response)
122
+
123
+ # 6. Log the history step (reward will be filled below)
124
+ history_step = HistoryStep(
125
+ step=s.step, action=action, response=response, reward_breakdown=None,
126
+ )
127
+ s.history.append(history_step)
128
+
129
+ # 7. Check terminal conditions
130
+ if s.step >= s.max_steps or s.token_budget_remaining <= 0:
131
+ s.done = True
132
+ if action.type == "complete_task":
133
+ s.done = True
134
+
135
+ # 8. Run grader (sees marked drifts + updated agent_state + done flag)
136
+ reward = self._grader(s)
137
+ reward.step_shaping = step_shape
138
+ reward.shaped_total += step_shape
139
+
140
+ # 9. Log reward into history
141
+ s.history[-1].reward_breakdown = reward.model_dump()
142
+ s.cumulative_reward += reward.shaped_total
143
+
144
+ # 10. Decorate feedback with drift info and return
145
+ if fired_drifts:
146
+ feedback += (
147
+ f" [DRIFT FIRED: {len(fired_drifts)} drift event(s) on step {s.step}.]"
148
+ )
149
+
150
+ return self._observation(feedback), reward
151
+
152
+ # ──────────────────────────────────────────────────────────────
153
+ # Action dispatch (pure — no state mutation for drift detection)
154
+ # ──────────────────────────────────────────────────────────────
155
+
156
+ def _dispatch_action(self, action: Action) -> tuple[ToolResponse | None, str]:
157
+ s = self._state
158
+ assert s is not None
159
+
160
+ if action.type == "call_tool":
161
+ if action.tool_call is None:
162
+ return None, "Invalid call_tool: missing tool_call params."
163
+ if action.tool_call.tool not in self._tools:
164
+ return (
165
+ ToolResponse(
166
+ ok=False, status=404,
167
+ error=f"Tool '{action.tool_call.tool}' not available in this scenario.",
168
+ ),
169
+ "Tool not available.",
170
+ )
171
+ response = self._tools[action.tool_call.tool].call(
172
+ action.tool_call.endpoint, action.tool_call.params
173
+ )
174
+ return (
175
+ response,
176
+ f"Called {action.tool_call.tool}.{action.tool_call.endpoint}: status={response.status}",
177
+ )
178
+
179
+ if action.type == "inspect_schema":
180
+ if action.inspect is None or action.inspect.tool not in self._tools:
181
+ return (
182
+ ToolResponse(
183
+ ok=False, status=404,
184
+ error="Tool unavailable for inspection.",
185
+ ),
186
+ "Inspect target missing.",
187
+ )
188
+ schema = self._tools[action.inspect.tool].get_schema()
189
+ return (
190
+ ToolResponse(ok=True, status=200, body={"schema": schema}),
191
+ f"Inspected {action.inspect.tool} schema.",
192
+ )
193
+
194
+ if action.type == "retry_with_variant":
195
+ if action.retry is None or action.retry.tool not in self._tools:
196
+ return (
197
+ ToolResponse(
198
+ ok=False, status=404,
199
+ error="Retry target unavailable.",
200
+ ),
201
+ "Retry target missing.",
202
+ )
203
+ response = self._tools[action.retry.tool].call(
204
+ action.retry.endpoint, action.retry.params
205
+ )
206
+ return (
207
+ response,
208
+ f"Retried {action.retry.tool}.{action.retry.endpoint}: status={response.status}",
209
+ )
210
+
211
+ if action.type == "report_drift":
212
+ if action.report is None:
213
+ return None, "Invalid report_drift: missing report params."
214
+ for d in s.drift_plan:
215
+ if (d.tool == action.report.tool
216
+ and d.kind == action.report.drift_kind
217
+ and d.fires_at_step <= s.step
218
+ and not d.detected_by_agent):
219
+ return (
220
+ None,
221
+ f"Drift correctly reported: {d.kind} on {d.tool} at step {d.fires_at_step}.",
222
+ )
223
+ return None, "Drift report did not match any undetected fired drift."
224
+
225
+ if action.type == "complete_task":
226
+ summary = action.complete.summary if action.complete else ""
227
+ s.agent_state["_completion_summary"] = summary
228
+ self._check_completion_summary(summary)
229
+ return None, f"Episode marked complete. Summary: {summary[:80]}"
230
+
231
+ return None, f"Unknown action type: {action.type}"
232
+
233
+ # ──────────────────────────────────────────────────────────────
234
+ # Post-shaping state mutations
235
+ # ──────────────────────────────────────────────────────────────
236
+
237
+ def _mark_drift_detected(self, report: DriftReportParams) -> None:
238
+ s = self._state
239
+ assert s is not None
240
+ for d in s.drift_plan:
241
+ if (d.tool == report.tool
242
+ and d.kind == report.drift_kind
243
+ and d.fires_at_step <= s.step
244
+ and not d.detected_by_agent):
245
+ d.detected_by_agent = True
246
+ return
247
+
248
+ # ──────────────────────────────────────────────────────────────
249
+ # State tracking — populates agent_state so grader can read it
250
+ # ──────────────────────────────────────────────────────────────
251
+
252
+ def _update_agent_state(
253
+ self, action: Action, response: ToolResponse | None
254
+ ) -> None:
255
+ if response is None or not response.ok:
256
+ return
257
+ s = self._state
258
+ assert s is not None
259
+ st = s.agent_state
260
+
261
+ tool: str | None = None
262
+ endpoint: str | None = None
263
+ params: dict = {}
264
+ if action.type == "call_tool" and action.tool_call is not None:
265
+ tool = action.tool_call.tool
266
+ endpoint = action.tool_call.endpoint
267
+ params = action.tool_call.params
268
+ elif action.type == "retry_with_variant" and action.retry is not None:
269
+ tool = action.retry.tool
270
+ endpoint = action.retry.endpoint
271
+ params = action.retry.params
272
+ else:
273
+ return
274
+
275
+ # MAIL ────────────────────────────────────────────────────
276
+ if tool == "mail":
277
+ if endpoint in ("send_message", "messages.send"):
278
+ st["mail.sent_count"] = st.get("mail.sent_count", 0) + 1
279
+ sent_to = params.get("to", "")
280
+ st["mail.last_sent_to"] = sent_to
281
+ subject = str(params.get("subject", "")).lower()
282
+ if "welcome" in subject:
283
+ st["mail.last_subject_contains_welcome"] = True
284
+ if "all-hands" in subject or "all hands" in subject:
285
+ st["mail.last_subject_contains_allhands"] = True
286
+ recipients: list[str] = st.get("mail.all_recipients", [])
287
+ if sent_to and sent_to not in recipients:
288
+ recipients.append(sent_to)
289
+ st["mail.all_recipients"] = recipients
290
+ e2_required = {"alex@company.com", "jordan@company.com", "sam@company.com"}
291
+ if e2_required.issubset(set(recipients)):
292
+ st["mail.sent_to_all_three_recipients"] = True
293
+
294
+ # CALENDAR ────────────────────────────────────────────────
295
+ if tool == "calendar":
296
+ if endpoint == "create_event":
297
+ st["calendar.events_count"] = st.get("calendar.events_count", 0) + 1
298
+ body = response.body or {}
299
+ raw = body.get("attendees") or body.get("participants") or []
300
+ emails: list[str] = []
301
+ for a in raw:
302
+ if isinstance(a, str):
303
+ emails.append(a)
304
+ elif isinstance(a, dict):
305
+ emails.append(a.get("email", ""))
306
+ st["calendar.last_event_attendees"] = emails
307
+ if "priya@company.com" in emails and "alex@company.com" in emails:
308
+ st["calendar.last_event_has_both_attendees"] = True
309
+
310
+ # CRM ─────────────────────────────────────────────────────
311
+ if tool == "crm":
312
+ if endpoint in ("update_contact", "contacts.patch"):
313
+ cid = params.get("contact_id", "")
314
+ status = params.get("status")
315
+ if cid and status:
316
+ st[f"crm.contact_{cid}_status"] = status
317
+
318
+ def _check_completion_summary(self, summary: str) -> None:
319
+ s = self._state
320
+ assert s is not None
321
+ st = s.agent_state
322
+ gt = s.ground_truth_final_state
323
+ if "complete_summary_mentions_company" in gt:
324
+ for c in ("Globex", "Acme", "Initech"):
325
+ if c.lower() in summary.lower():
326
+ st["complete_summary_mentions_company"] = True
327
+ break
328
+
329
+ # ──────────────────────────────────────────────────────────────
330
+ # Observation construction
331
+ # ──────────────────────────────────────────────────────────────
332
+
333
+ def _observation(self, feedback: str) -> Observation:
334
+ s = self._state
335
+ assert s is not None
336
+ scenario = SCENARIOS[s.task_id]
337
+
338
+ return Observation(
339
+ episode_id=s.episode_id,
340
+ task_id=s.task_id,
341
+ difficulty=s.difficulty,
342
+ step=s.step,
343
+ max_steps=s.max_steps,
344
+ token_budget_remaining=s.token_budget_remaining,
345
+ task_description=scenario["task_description"],
346
+ success_criteria=list(scenario["success_criteria"]),
347
+ tool_schemas={name: t.get_schema() for name, t in self._tools.items()},
348
+ known_state=dict(s.agent_state),
349
+ history=list(s.history[-5:]),
350
+ last_response=s.history[-1].response if s.history else None,
351
+ drift_events_visible=[
352
+ {
353
+ "tool": d.tool,
354
+ "kind": d.kind,
355
+ "endpoint": d.endpoint,
356
+ "fires_at_step": d.fires_at_step,
357
+ }
358
+ for d in s.drift_plan
359
+ if d.detected_by_agent
360
+ ],
361
+ done=s.done,
362
+ feedback=feedback,
363
+ )
tests/test_environment.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SchemaShiftEnvironment acceptance tests — Phase 6 (end-to-end RL loop)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from models import (
7
+ Action,
8
+ CompleteParams,
9
+ DriftReportParams,
10
+ InspectParams,
11
+ RetryParams,
12
+ ToolCallParams,
13
+ )
14
+ from server.environment import SchemaShiftEnvironment
15
+
16
+
17
+ # ──────────────────────────────────────────────────────────────────
18
+ # Test 1 — Round 1 bug prevention
19
+ # ──────────────────────────────────────────────────────────────────
20
+
21
+ def test_step_before_reset_raises() -> None:
22
+ env = SchemaShiftEnvironment()
23
+ action = Action(type="complete_task", complete=CompleteParams(summary="noop"))
24
+ with pytest.raises(RuntimeError) as excinfo:
25
+ env.step(action)
26
+ assert "reset" in str(excinfo.value).lower()
27
+
28
+
29
+ # ──────────────────────────────────────────────────────────────────
30
+ # Test 2 — reset returns a valid observation
31
+ # ──────────────────────────────────────────────────────────────────
32
+
33
+ def test_reset_returns_valid_observation() -> None:
34
+ env = SchemaShiftEnvironment()
35
+ obs = env.reset("E1_onboard_new_hire")
36
+ assert obs.task_id == "E1_onboard_new_hire"
37
+ assert obs.step == 0
38
+ assert obs.max_steps == 8
39
+ assert "mail" in obs.tool_schemas
40
+ assert "calendar" in obs.tool_schemas
41
+ assert "crm" not in obs.tool_schemas
42
+ assert obs.done is False
43
+ assert obs.difficulty == "easy"
44
+ assert len(obs.success_criteria) >= 1
45
+
46
+
47
+ # ──────────────────────────────────────────────────────────────────
48
+ # Test 3 — reset on unknown task raises
49
+ # ──────────────────────────────────────────────────────────────────
50
+
51
+ def test_reset_unknown_task_raises() -> None:
52
+ env = SchemaShiftEnvironment()
53
+ with pytest.raises(ValueError):
54
+ env.reset("nonexistent_task")
55
+
56
+
57
+ # ──────────────────────────────────────────────────────────────────
58
+ # Test 4 — call_tool success updates agent_state
59
+ # ──────────────────────────────────────────────────────────────────
60
+
61
+ def test_call_tool_success_updates_state() -> None:
62
+ env = SchemaShiftEnvironment()
63
+ env.reset("E1_onboard_new_hire")
64
+
65
+ obs, reward = env.step(Action(
66
+ type="call_tool",
67
+ tool_call=ToolCallParams(
68
+ tool="mail",
69
+ endpoint="send_message",
70
+ params={
71
+ "to": "priya@company.com",
72
+ "subject": "Welcome!",
73
+ "body": "Welcome to the team.",
74
+ },
75
+ ),
76
+ ))
77
+ assert obs.last_response is not None
78
+ assert obs.last_response.ok is True
79
+ assert obs.known_state["mail.sent_count"] == 1
80
+ assert obs.known_state["mail.last_sent_to"] == "priya@company.com"
81
+ assert obs.known_state["mail.last_subject_contains_welcome"] is True
82
+
83
+
84
+ # ──────────────────────────────────────────────────────────────────
85
+ # Test 5 — FULL E1 episode with drift → inspect → retry → report → complete
86
+ # ──────────────────────────────────────────────────────────────────
87
+
88
+ def test_e1_full_episode_with_adaptation() -> None:
89
+ env = SchemaShiftEnvironment()
90
+ env.reset("E1_onboard_new_hire")
91
+
92
+ # Step 1: send welcome email (pre-drift)
93
+ obs, r1 = env.step(Action(
94
+ type="call_tool",
95
+ tool_call=ToolCallParams(
96
+ tool="mail", endpoint="send_message",
97
+ params={"to": "priya@company.com", "subject": "Welcome aboard!",
98
+ "body": "Welcome to the team."},
99
+ ),
100
+ ))
101
+ assert obs.last_response is not None and obs.last_response.ok is True
102
+
103
+ # Step 2: inspect calendar (pre-drift)
104
+ obs, r2 = env.step(Action(
105
+ type="inspect_schema", inspect=InspectParams(tool="calendar"),
106
+ ))
107
+ assert obs.last_response is not None and obs.last_response.ok is True
108
+
109
+ # Step 3: drift fires at state.step=3; call with stale attendees fails
110
+ obs, r3 = env.step(Action(
111
+ type="call_tool",
112
+ tool_call=ToolCallParams(
113
+ tool="calendar", endpoint="create_event",
114
+ params={"title": "New Hire Orientation",
115
+ "start": "2026-04-27T10:00:00Z",
116
+ "end": "2026-04-27T11:00:00Z",
117
+ "attendees": ["priya@company.com", "alex@company.com"]},
118
+ ),
119
+ ))
120
+ assert obs.last_response is not None and obs.last_response.ok is False
121
+
122
+ # Step 4: inspect calendar (now shows participants schema)
123
+ obs, r4 = env.step(Action(
124
+ type="inspect_schema", inspect=InspectParams(tool="calendar"),
125
+ ))
126
+ assert obs.last_response is not None and obs.last_response.ok is True
127
+ cal_schema = obs.tool_schemas["calendar"]["create_event"]
128
+ assert "participants" in cal_schema["params"]
129
+
130
+ # Step 5: retry with participants format
131
+ obs, r5 = env.step(Action(
132
+ type="retry_with_variant",
133
+ retry=RetryParams(
134
+ tool="calendar", endpoint="create_event",
135
+ params={"title": "New Hire Orientation",
136
+ "start": "2026-04-27T10:00:00Z",
137
+ "end": "2026-04-27T11:00:00Z",
138
+ "participants": [
139
+ {"email": "priya@company.com", "role": "required"},
140
+ {"email": "alex@company.com", "role": "required"},
141
+ ]},
142
+ ),
143
+ ))
144
+ assert obs.last_response is not None and obs.last_response.ok is True
145
+
146
+ # Step 6: report drift
147
+ obs, r6 = env.step(Action(
148
+ type="report_drift",
149
+ report=DriftReportParams(
150
+ tool="calendar", drift_kind="field_rename",
151
+ description="create_event attendees renamed to participants",
152
+ ),
153
+ ))
154
+
155
+ # Step 7: complete
156
+ obs, r7 = env.step(Action(
157
+ type="complete_task",
158
+ complete=CompleteParams(
159
+ summary="Onboarded Priya with welcome email and orientation event.",
160
+ ),
161
+ ))
162
+
163
+ state = env._state
164
+ assert state is not None
165
+ assert obs.done is True
166
+ assert state.agent_state["mail.sent_count"] == 1
167
+ assert state.agent_state["calendar.events_count"] == 1
168
+ assert state.agent_state["calendar.last_event_has_both_attendees"] is True
169
+ assert state.drift_plan[0].detected_by_agent is True
170
+ assert r7.task_completion == 1.0
171
+ assert r7.drift_detection == 1.0
172
+ assert r7.adaptation_quality == 1.0
173
+ assert r7.shaped_total > 0.5
174
+ assert r7.binary == 1.0
175
+
176
+
177
+ # ──────────────────────────────────────────────────────────────────
178
+ # Test 6 — max_steps terminates episode
179
+ # ──────────────────────────────────────────────────────────────────
180
+
181
+ def test_max_steps_terminates_episode() -> None:
182
+ env = SchemaShiftEnvironment()
183
+ env.reset("E2_meeting_invite_blast")
184
+ inspect = Action(type="inspect_schema", inspect=InspectParams(tool="mail"))
185
+ obs = None
186
+ for _ in range(6):
187
+ obs, _ = env.step(inspect)
188
+ assert obs is not None
189
+ assert obs.done is True
190
+ assert obs.step == 6
191
+
192
+
193
+ # ──────────────────────────────────────────────────────────────────
194
+ # Test 7 — step_shaping +0.10 for inspect after failure
195
+ # ──────────────────────────────────────────────────────────────────
196
+
197
+ def test_step_shaping_applied_correctly() -> None:
198
+ env = SchemaShiftEnvironment()
199
+ env.reset("E1_onboard_new_hire")
200
+
201
+ # Step 1: send_message missing required 'body' → 400
202
+ env.step(Action(
203
+ type="call_tool",
204
+ tool_call=ToolCallParams(
205
+ tool="mail", endpoint="send_message",
206
+ params={"to": "x@y.com", "subject": "hi"},
207
+ ),
208
+ ))
209
+
210
+ # Step 2: inspect_schema after failure → +0.10 shaping
211
+ obs, reward = env.step(Action(
212
+ type="inspect_schema", inspect=InspectParams(tool="mail"),
213
+ ))
214
+ assert reward.step_shaping == pytest.approx(0.10)
215
+
216
+
217
+ # ──────────────────────────────────────────────────────────────────
218
+ # Test 8 — dumb retry penalty
219
+ # ──────────────────────────────────────────────────────────────────
220
+
221
+ def test_dumb_retry_penalty() -> None:
222
+ env = SchemaShiftEnvironment()
223
+ env.reset("E1_onboard_new_hire")
224
+
225
+ # Step 1: call_tool mail.send_message with only {"to": "x"} → 400
226
+ env.step(Action(
227
+ type="call_tool",
228
+ tool_call=ToolCallParams(
229
+ tool="mail", endpoint="send_message",
230
+ params={"to": "x@y.com"},
231
+ ),
232
+ ))
233
+
234
+ # Step 2: same call again → dumb retry → -0.05 penalty
235
+ obs, reward = env.step(Action(
236
+ type="call_tool",
237
+ tool_call=ToolCallParams(
238
+ tool="mail", endpoint="send_message",
239
+ params={"to": "x@y.com"},
240
+ ),
241
+ ))
242
+ assert reward.step_shaping == pytest.approx(-0.05)