Spaces:
Sleeping
Sleeping
Phase 6: SchemaShiftEnvironment scheduler — full episode lifecycle
Browse files- server/environment.py +360 -1
- 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 |
-
|
| 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)
|