og-arin commited on
Commit
a2a2b07
Β·
verified Β·
1 Parent(s): ccb41ec

Update inference.py

Browse files
Files changed (1) hide show
  1. inference.py +313 -109
inference.py CHANGED
@@ -1,46 +1,78 @@
1
  """
2
- inference.py – PhishGuard-Env LLM Driver
3
- =========================================
4
-
5
- KEY CHANGES vs. previous version
6
- ----------------------------------
7
- 1. SUCCESS THRESHOLD RECALIBRATED for open-interval rewards:
8
- Previous code used `success = final_score >= 0.7`. With rewards now in
9
- (0.0, 1.0), a 0.7 threshold is still semantically correct (it sits between
10
- R_PHISH_BEC_QUARANTINE=0.60 and R_MALWARE_QUARANTINE=0.75), but the
11
- constant is now imported from grader.py as SUCCESS_THRESHOLD so it stays
12
- in sync with any future reward-table changes.
13
-
14
- 2. ERROR STEP REWARD UPDATED:
15
- Transient-error steps previously appended 0.0 to the rewards list,
16
- violating the open-interval contract. Now appends R_WRONG_PROCEDURE (0.10)
17
- β€” a non-zero signal that reflects "the agent failed to produce an action".
18
-
19
- 3. All previously documented bugs remain fixed:
20
- - HF_TOKEN / OPENAI_API_KEY validation with clear error messages.
21
- - base_url trailing-slash normalisation for openai >= 1.25.
22
- - Transient errors retry in-place instead of resetting the episode.
23
- - Final score uses calculate_overall_score(), not sum/10.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  """
25
 
26
- import asyncio
27
- import os
28
- import requests
29
  import json
30
- import textwrap
 
31
  import time
 
 
32
  from openai import OpenAI, AuthenticationError, APIConnectionError
33
 
34
- from env import env_instance
35
- from grader import calculate_overall_score, R_WRONG_PROCEDURE
36
 
37
- # ── SUCCESS THRESHOLD ─────────────────────────────────────────────────────────
38
- # A run is considered successful if the average reward meets this bar.
39
- # 0.70 sits between "under-response on PHISH/BEC" (0.60) and
40
- # "correct malware containment" (0.75) β€” a meaningful quality gate.
41
- SUCCESS_THRESHOLD = 0.70
42
 
43
- # ── CONFIGURATION ─────────────────────────────────────────────────────────────
 
44
  _raw_key = (
45
  os.getenv("HF_TOKEN")
46
  or os.getenv("OPENAI_API_KEY")
@@ -52,81 +84,193 @@ if not _raw_key:
52
  print(
53
  "❌ ERROR: No API key found in environment variables.\n"
54
  "\n"
55
- " For Hugging Face:\n"
56
  " Linux/macOS : export HF_TOKEN='hf_...'\n"
57
  " PowerShell : $env:HF_TOKEN='hf_...'\n"
58
  "\n"
59
  " For OpenAI:\n"
60
  " Linux/macOS : export OPENAI_API_KEY='sk-...'\n"
61
- " PowerShell : $env:OPENAI_API_KEY='sk-...'\n"
 
62
  )
63
- raise SystemExit(1)
64
 
65
  API_KEY = _raw_key
66
 
 
67
  # base_url must always end with "/" for openai >= 1.25.
68
- _raw_base = (os.getenv("https://huggingface.co/spaces/Scalar-hackathon/Phishing-env") or "https://router.huggingface.co/v1").rstrip("/") + "/"
69
- API_BASE_URL = _raw_base
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
72
- MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3"))
73
- RETRY_DELAY = float(os.getenv("RETRY_DELAY", "2.0")) # seconds between retries
74
 
75
- # ── CLIENT ────────────────────────────────────────────────────────────────────
 
 
 
76
  try:
77
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
78
  except Exception as exc:
79
- print(f"❌ Failed to initialise OpenAI client: {exc}")
80
- raise SystemExit(1)
81
-
82
- # ── SYSTEM PROMPT ─────────────────────────────────────────────────────────────
83
- SYSTEM_PROMPT = textwrap.dedent("""
84
- You are an expert SOC (Security Operations Center) Analyst responsible
85
- for email triage in a corporate environment.
86
-
87
- Analyse ALL fields of the provided email metadata:
88
- - sender, subject, body, links, has_attachments
89
- - spf_record : SPF authentication result (pass / fail / softfail / none)
90
- - dmarc_record : DMARC policy result (pass / fail / none)
91
- - urgency_level : how aggressively the email pressures the recipient
92
- - confidence_hint : noisy SIEM / gateway / threat-intel signal β€” treat
93
- as one data-point, not ground truth
94
-
95
- Choose EXACTLY ONE of these actions:
96
- β€’ MARK_SAFE – Confirmed legitimate; deliver to inbox normally.
97
- β€’ MOVE_TO_SPAM – Unsolicited bulk mail; no active security threat.
98
- β€’ QUARANTINE – Suspicious; hold for deeper analyst investigation.
99
- β€’ BLOCK_DOMAIN – Confirmed phishing / BEC source; block at perimeter.
100
-
101
- Respond ONLY with a valid JSON object β€” no markdown fences, no preamble:
102
- {
103
- "action": "<one of the four actions above, in ALL_CAPS>",
104
- "reasoning": "<one concise technical sentence explaining your decision>"
105
- }
106
- """).strip()
107
-
108
- VALID_ACTIONS = {"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"}
109
-
110
-
111
- # ── LLM CALL WITH RETRY ───────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  def call_llm_with_retry(obs: dict, step_num: int) -> str:
113
  """
114
  Call the LLM for one email observation and return a validated action string.
115
 
116
- Retries up to MAX_RETRIES times on transient errors (JSON parse failures,
117
- rate limits, timeouts). Re-raises immediately on unrecoverable errors
118
- (AuthenticationError, APIConnectionError).
 
 
 
 
 
 
 
 
 
 
 
 
119
  """
120
- last_error = None
121
 
122
  for attempt in range(1, MAX_RETRIES + 1):
123
  try:
124
- completion = client.chat.completions.create(
125
  model=MODEL_NAME,
126
  max_tokens=256,
127
  messages=[
128
  {"role": "system", "content": SYSTEM_PROMPT},
129
- {"role": "user", "content": f"Email Data:\n{json.dumps(obs, indent=2)}"},
 
 
 
130
  ],
131
  response_format={"type": "json_object"},
132
  )
@@ -138,51 +282,82 @@ def call_llm_with_retry(obs: dict, step_num: int) -> str:
138
  if action not in VALID_ACTIONS:
139
  raise ValueError(
140
  f"LLM returned an invalid action: '{action}'. "
141
- f"Must be one of {sorted(VALID_ACTIONS)}."
142
  )
143
 
144
  return action
145
 
146
  except (AuthenticationError, APIConnectionError):
147
- raise # Unrecoverable β€” propagate immediately
148
 
149
  except Exception as exc:
150
  last_error = exc
151
  print(
152
- f" ⚠️ Step {step_num}, attempt {attempt}/{MAX_RETRIES} failed: {exc}",
 
153
  flush=True,
154
  )
155
  if attempt < MAX_RETRIES:
156
  time.sleep(RETRY_DELAY)
157
 
158
  raise RuntimeError(
159
- f"All {MAX_RETRIES} LLM call attempts failed for step {step_num}. "
160
  f"Last error: {last_error}"
161
  )
162
 
163
 
164
- # ── MAIN EPISODE LOOP ─────────────────────────────────────────────────────────
165
- async def main() -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  rewards: list[float] = []
167
- steps = 0
168
- success = False
169
- final_score = R_WRONG_PROCEDURE # Safe open-interval default
170
 
171
  print(
172
  f"[START] task=phishing_triage env=phishguard_v1 model={MODEL_NAME}",
173
  flush=True,
174
  )
 
 
 
 
 
175
 
176
  try:
177
- obs = env_instance.reset()
 
178
  done = False
179
 
 
180
  while not done and steps < 30:
181
  steps += 1
182
 
183
  try:
 
184
  action_str = call_llm_with_retry(obs, steps)
185
- obs, reward, done, info = env_instance.step(action_str)
 
 
186
  rewards.append(reward)
187
 
188
  print(
@@ -192,36 +367,58 @@ async def main() -> None:
192
  flush=True,
193
  )
194
 
 
 
 
 
 
195
  except (AuthenticationError, APIConnectionError) as exc:
196
- # Unrecoverable API error β€” abort the entire run.
197
  print(
198
- f"[STEP] step={steps} action=ABORT reward={R_WRONG_PROCEDURE:.4f} "
199
- f"done=true error='{exc}'",
 
200
  flush=True,
201
  )
202
  print(
203
  f"\n❌ Unrecoverable API error: {exc}\n"
204
- f" Verify HF_TOKEN / OPENAI_API_KEY and API_BASE_URL.",
 
 
 
 
 
 
 
 
 
 
 
 
205
  )
 
206
  rewards.append(R_WRONG_PROCEDURE)
207
- break
208
 
209
  except Exception as exc:
210
- # Transient error: do NOT reset the episode.
211
- # Append the minimum non-zero signal and keep the current obs.
 
 
212
  print(
213
- f"[STEP] step={steps} action=ERROR reward={R_WRONG_PROCEDURE:.4f} "
214
- f"done=false error='{exc}'",
 
215
  flush=True,
216
  )
217
  rewards.append(R_WRONG_PROCEDURE)
218
- # obs is unchanged β€” loop retries from the same email
219
 
220
  final_score = calculate_overall_score(rewards)
221
  success = final_score >= SUCCESS_THRESHOLD
222
 
223
  except Exception as exc:
224
- print(f"❌ Execution Failure: {exc}")
225
  final_score = R_WRONG_PROCEDURE # Open-interval safe default
226
 
227
  finally:
@@ -231,10 +428,17 @@ async def main() -> None:
231
  f"score={final_score:.4f} rewards={rewards_str}",
232
  flush=True,
233
  )
 
 
 
 
 
 
 
 
 
 
234
 
235
 
236
  if __name__ == "__main__":
237
- try:
238
- asyncio.run(main())
239
- except KeyboardInterrupt:
240
- print("\nProcess interrupted by user.")
 
1
  """
2
+ inference.py – PhishGuard-Env | Hybrid LLM + HTTP Driver
3
+ ============================================================
4
+
5
+ ARCHITECTURE β€” "Hybrid Agent" (resolves the Gemini vs GPT/Claude debate)
6
+ --------------------------------------------------------------------------
7
+ Both sides of the AI architecture debate were PARTIALLY correct:
8
+
9
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
10
+ β”‚ GEMINI was right about: The agent needs HF_TOKEN + API_BASE_URL β”‚
11
+ β”‚ to call the Qwen LLM "brain" for β”‚
12
+ β”‚ INFERENCE INTELLIGENCE (thinking). β”‚
13
+ β”‚ β”‚
14
+ β”‚ GPT/CLAUDE was right about: The agent only needs requests.post() β”‚
15
+ β”‚ to hit the /step endpoint of env.py for β”‚
16
+ β”‚ ENVIRONMENT INTERACTION (acting). β”‚
17
+ β”‚ β”‚
18
+ β”‚ UNIFIED SOLUTION: Two separate clients, one agent: β”‚
19
+ β”‚ llm_client β†’ OpenAI(base_url=HF_ROUTER) for THINKING β”‚
20
+ β”‚ ENV_BASE_URL β†’ requests.post(ENV_BASE_URL) for ACTING β”‚
21
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
22
+
23
+ Per-step flow
24
+ -------------
25
+ 1. POST {ENV_BASE_URL}/reset β†’ receive first email observation
26
+ 2. llm_client.chat.completions(obs) β†’ LLM "thinks" β†’ returns action
27
+ 3. POST {ENV_BASE_URL}/step({action}) β†’ env "acts" β†’ returns reward, done
28
+ 4. Log [STEP] in OpenEnv format
29
+ 5. Repeat 2-4 until done=True
30
+ 6. Log [END] with final score
31
+
32
+ Environment Variables
33
+ ---------------------
34
+ Required:
35
+ HF_TOKEN : Hugging Face access token (hf_...)
36
+ OR
37
+ OPENAI_API_KEY : Standard OpenAI key if using OpenAI router
38
+
39
+ Optional:
40
+ ENV_BASE_URL : Base URL of the running env.py server
41
+ Default: http://localhost:7860
42
+ HF Spaces: https://<org>-<space-name>.hf.space
43
+ API_BASE_URL : LLM inference router base URL
44
+ Default: https://router.huggingface.co/v1
45
+ MODEL_NAME : Inference model ID
46
+ Default: Qwen/Qwen2.5-72B-Instruct
47
+ MAX_RETRIES : LLM call retries before aborting a step (default 3)
48
+ RETRY_DELAY : Seconds between LLM retries (default 2.0)
49
+ REQUEST_TIMEOUT : HTTP timeout for env API calls in seconds (default 30)
50
+
51
+ OpenEnv Logging Format (compliant)
52
+ -----------------------------------
53
+ [START] task=phishing_triage env=phishguard_v1 model=<MODEL_NAME>
54
+ [STEP] step=N action=ACTION reward=R health=H done=true|false error=null
55
+ [END] success=true|false steps=N score=S rewards=r1,r2,...
56
  """
57
 
58
+ from __future__ import annotations
59
+
 
60
  import json
61
+ import os
62
+ import sys
63
  import time
64
+
65
+ import requests
66
  from openai import OpenAI, AuthenticationError, APIConnectionError
67
 
68
+ from grader import calculate_overall_score, R_WRONG_PROCEDURE, R_PERFECT
 
69
 
70
+ # ══════════════════════════════════════════════════════════════════════════════
71
+ # CONFIGURATION
72
+ # ══════════════════════════════════════════════════════════════════════════════
 
 
73
 
74
+ # ── API Key ───────────────────────────────────────────────────────────────────
75
+ # Priority: HF_TOKEN β†’ OPENAI_API_KEY β†’ API_KEY
76
  _raw_key = (
77
  os.getenv("HF_TOKEN")
78
  or os.getenv("OPENAI_API_KEY")
 
84
  print(
85
  "❌ ERROR: No API key found in environment variables.\n"
86
  "\n"
87
+ " For Hugging Face Inference Router:\n"
88
  " Linux/macOS : export HF_TOKEN='hf_...'\n"
89
  " PowerShell : $env:HF_TOKEN='hf_...'\n"
90
  "\n"
91
  " For OpenAI:\n"
92
  " Linux/macOS : export OPENAI_API_KEY='sk-...'\n"
93
+ " PowerShell : $env:OPENAI_API_KEY='sk-...'\n",
94
+ file=sys.stderr,
95
  )
96
+ sys.exit(1)
97
 
98
  API_KEY = _raw_key
99
 
100
+ # ── LLM Router (the "brain" β€” for THINKING) ───────────────────────────────────
101
  # base_url must always end with "/" for openai >= 1.25.
102
+ _raw_llm_base = (
103
+ os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
104
+ ).rstrip("/") + "/"
105
+ LLM_BASE_URL = _raw_llm_base
106
+
107
+ # ── Environment Server (the "world" β€” for ACTING) ─────────────────────────────
108
+ # When running locally: http://localhost:7860
109
+ # When env.py is deployed on HF Spaces: https://<org>-<space-name>.hf.space
110
+ _raw_env_base = (
111
+ os.getenv("ENV_BASE_URL") or "http://localhost:7860"
112
+ ).rstrip("/")
113
+ ENV_BASE_URL = _raw_env_base
114
+
115
+ # ── Model and runtime settings ────────────────────────────────────────────────
116
+ MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
117
+ MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3"))
118
+ RETRY_DELAY = float(os.getenv("RETRY_DELAY", "2.0")) # seconds between LLM retries
119
+ REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "30")) # seconds for env HTTP calls
120
+
121
+ # A run is successful if the average reward meets this bar.
122
+ # Sits between R_PHISH_BEC_QUARANTINE (0.60) and R_MALWARE_QUARANTINE (0.75).
123
+ SUCCESS_THRESHOLD = 0.70
124
+
125
+ VALID_ACTIONS = frozenset({"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"})
126
 
 
 
 
127
 
128
+ # ══════════════════════════════════════════════════════════════════════════════
129
+ # LLM CLIENT (THINKING layer)
130
+ # ══════════════════════════════════════════════════════════════════════════════
131
+
132
  try:
133
+ llm_client = OpenAI(base_url=LLM_BASE_URL, api_key=API_KEY)
134
  except Exception as exc:
135
+ print(f"❌ Failed to initialise LLM client: {exc}", file=sys.stderr)
136
+ sys.exit(1)
137
+
138
+
139
+ # ══════════════════════════════════════════════════════════════════════════════
140
+ # SYSTEM PROMPT
141
+ # ══════════════════════════════════════════════════════════════════════════════
142
+
143
+ SYSTEM_PROMPT = """
144
+ You are an expert SOC (Security Operations Center) Analyst responsible
145
+ for email triage in a corporate environment.
146
+
147
+ Analyse ALL fields of the provided email metadata:
148
+ - sender : full sender address β€” check for typosquatting
149
+ - subject : urgency language, pressure tactics
150
+ - body : instructions, links, attachment requests
151
+ - links : check for URL shorteners, suspicious TLDs, mismatches
152
+ - has_attachments : True = elevated risk unless sender is fully trusted
153
+ - spf_record : pass | softfail | fail | none
154
+ - dmarc_record : pass | fail | none
155
+ - urgency_level : low | medium | high | critical
156
+ - confidence_hint : noisy SIEM / gateway / threat-intel signal
157
+ β†’ treat as ONE data point, NOT ground truth
158
+
159
+ SPF/DMARC adversarial note: some sophisticated attacks (BEC, supply-chain)
160
+ will show spf_record=pass and dmarc_record=pass on compromised or lookalike
161
+ domains. Never treat authentication pass as definitive clearance.
162
+
163
+ Choose EXACTLY ONE of these actions:
164
+ MARK_SAFE – Confirmed legitimate; deliver to inbox normally.
165
+ MOVE_TO_SPAM – Unsolicited bulk mail; no active security threat.
166
+ QUARANTINE – Suspicious; hold for deeper analyst investigation.
167
+ BLOCK_DOMAIN – Confirmed phishing / BEC source; block at perimeter.
168
+
169
+ Respond ONLY with a valid JSON object β€” no markdown fences, no preamble:
170
+ {
171
+ "action": "<one of the four actions above, in ALL_CAPS>",
172
+ "reasoning": "<one concise technical sentence explaining your decision>"
173
+ }
174
+ """.strip()
175
+
176
+
177
+ # ══════════════════════════════════════════════════════════════════════════════
178
+ # ENVIRONMENT HTTP HELPERS (ACTING layer)
179
+ # ══════════════════════════════════════════════════════════════════════════════
180
+
181
+ def env_reset() -> dict:
182
+ """
183
+ POST {ENV_BASE_URL}/reset β†’ returns the first email observation dict.
184
+
185
+ Raises
186
+ ------
187
+ RuntimeError if the server is unreachable or returns a non-200 status.
188
+ """
189
+ url = f"{ENV_BASE_URL}/reset"
190
+ try:
191
+ resp = requests.post(url, timeout=REQUEST_TIMEOUT)
192
+ resp.raise_for_status()
193
+ data = resp.json()
194
+ return data["observation"]
195
+ except requests.exceptions.ConnectionError as exc:
196
+ raise RuntimeError(
197
+ f"Cannot reach env.py server at {ENV_BASE_URL}.\n"
198
+ f" β†’ Is it running? Start with: uvicorn env:app --host 0.0.0.0 --port 7860\n"
199
+ f" β†’ For HF Spaces, set ENV_BASE_URL to your Space URL.\n"
200
+ f" Original error: {exc}"
201
+ ) from exc
202
+ except Exception as exc:
203
+ raise RuntimeError(f"env_reset() failed: {exc}") from exc
204
+
205
+
206
+ def env_step(action_str: str) -> tuple[dict | None, float, bool, dict]:
207
+ """
208
+ POST {ENV_BASE_URL}/step β†’ returns (obs, reward, done, info).
209
+
210
+ Parameters
211
+ ----------
212
+ action_str : one of MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN
213
+
214
+ Returns
215
+ -------
216
+ (observation, reward, done, info)
217
+ """
218
+ url = f"{ENV_BASE_URL}/step"
219
+ payload = {"action": action_str}
220
+ try:
221
+ resp = requests.post(url, json=payload, timeout=REQUEST_TIMEOUT)
222
+ resp.raise_for_status()
223
+ data = resp.json()
224
+ obs = data.get("observation")
225
+ reward = float(data.get("reward", R_WRONG_PROCEDURE))
226
+ done = bool(data.get("done", False))
227
+ info = data.get("info", {})
228
+ return obs, reward, done, info
229
+ except requests.exceptions.ConnectionError as exc:
230
+ raise RuntimeError(
231
+ f"Cannot reach env.py server at {ENV_BASE_URL}: {exc}"
232
+ ) from exc
233
+ except Exception as exc:
234
+ raise RuntimeError(f"env_step() failed: {exc}") from exc
235
+
236
+
237
+ # ══════════════════════════════════════════════════════════════════════════════
238
+ # LLM CALL WITH RETRY (THINKING layer)
239
+ # ══════════════════════════════════════════════════════════════════════════════
240
+
241
  def call_llm_with_retry(obs: dict, step_num: int) -> str:
242
  """
243
  Call the LLM for one email observation and return a validated action string.
244
 
245
+ Strategy
246
+ --------
247
+ β€’ Up to MAX_RETRIES attempts on transient errors (JSON parse, rate limit,
248
+ timeout, invalid action token).
249
+ β€’ Re-raises immediately on unrecoverable errors (AuthenticationError,
250
+ APIConnectionError) β€” no point retrying these.
251
+
252
+ Parameters
253
+ ----------
254
+ obs : email observation dict from the environment
255
+ step_num : 1-based step counter (for log messages only)
256
+
257
+ Returns
258
+ -------
259
+ str β€” one of MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN
260
  """
261
+ last_error: Exception | None = None
262
 
263
  for attempt in range(1, MAX_RETRIES + 1):
264
  try:
265
+ completion = llm_client.chat.completions.create(
266
  model=MODEL_NAME,
267
  max_tokens=256,
268
  messages=[
269
  {"role": "system", "content": SYSTEM_PROMPT},
270
+ {
271
+ "role": "user",
272
+ "content": f"Email Data:\n{json.dumps(obs, indent=2)}",
273
+ },
274
  ],
275
  response_format={"type": "json_object"},
276
  )
 
282
  if action not in VALID_ACTIONS:
283
  raise ValueError(
284
  f"LLM returned an invalid action: '{action}'. "
285
+ f"Expected one of: {sorted(VALID_ACTIONS)}"
286
  )
287
 
288
  return action
289
 
290
  except (AuthenticationError, APIConnectionError):
291
+ raise # Unrecoverable β€” propagate to caller immediately
292
 
293
  except Exception as exc:
294
  last_error = exc
295
  print(
296
+ f" ⚠️ Step {step_num}, LLM attempt {attempt}/{MAX_RETRIES} "
297
+ f"failed: {exc}",
298
  flush=True,
299
  )
300
  if attempt < MAX_RETRIES:
301
  time.sleep(RETRY_DELAY)
302
 
303
  raise RuntimeError(
304
+ f"All {MAX_RETRIES} LLM call attempts failed at step {step_num}. "
305
  f"Last error: {last_error}"
306
  )
307
 
308
 
309
+ # ══════════════════════════════════════════════════════════════════════════════
310
+ # MAIN EPISODE LOOP
311
+ # ══════════════════════════════════════════════════════════════════════════════
312
+
313
+ def main() -> None:
314
+ """
315
+ Run one full PhishGuard-Env episode using the Hybrid Agent pattern.
316
+
317
+ Flow
318
+ ----
319
+ 1. env_reset() β†’ first email observation (HTTP)
320
+ 2. call_llm_with_retry(obs) β†’ triage action (LLM call)
321
+ 3. env_step(action) β†’ reward, next obs, done flag (HTTP)
322
+ 4. Log in OpenEnv format
323
+ 5. Repeat until done=True or safety-limit hit
324
+
325
+ Logging format (OpenEnv compliant)
326
+ -----------------------------------
327
+ [START] task=phishing_triage env=phishguard_v1 model=<MODEL>
328
+ [STEP] step=N action=ACTION reward=R health=H done=true|false error=null
329
+ [END] success=true|false steps=N score=S rewards=r1,r2,...
330
+ """
331
  rewards: list[float] = []
332
+ steps = 0
333
+ success = False
334
+ final_score = R_WRONG_PROCEDURE # Open-interval safe default
335
 
336
  print(
337
  f"[START] task=phishing_triage env=phishguard_v1 model={MODEL_NAME}",
338
  flush=True,
339
  )
340
+ print(
341
+ f" LLM router : {LLM_BASE_URL}\n"
342
+ f" Env server : {ENV_BASE_URL}",
343
+ flush=True,
344
+ )
345
 
346
  try:
347
+ # ── 1. Reset the environment (HTTP call) ──────────────────────────────
348
+ obs = env_reset()
349
  done = False
350
 
351
+ # Safety cap: 10 scenarios + small buffer for transient step errors
352
  while not done and steps < 30:
353
  steps += 1
354
 
355
  try:
356
+ # ── 2. LLM decides action (THINKING) ─────────────────────────
357
  action_str = call_llm_with_retry(obs, steps)
358
+
359
+ # ── 3. Submit action to environment (ACTING) ──────────────────
360
+ obs, reward, done, info = env_step(action_str)
361
  rewards.append(reward)
362
 
363
  print(
 
367
  flush=True,
368
  )
369
 
370
+ # Emit the human-readable feedback from the grader
371
+ feedback = info.get("feedback", "")
372
+ if feedback:
373
+ print(f" {feedback}", flush=True)
374
+
375
  except (AuthenticationError, APIConnectionError) as exc:
376
+ # Unrecoverable LLM API error β€” abort the entire run
377
  print(
378
+ f"[STEP] step={steps} action=ABORT "
379
+ f"reward={R_WRONG_PROCEDURE:.4f} done=true "
380
+ f"error='{exc}'",
381
  flush=True,
382
  )
383
  print(
384
  f"\n❌ Unrecoverable API error: {exc}\n"
385
+ f" Check HF_TOKEN / OPENAI_API_KEY and API_BASE_URL.",
386
+ file=sys.stderr,
387
+ )
388
+ rewards.append(R_WRONG_PROCEDURE)
389
+ done = True # Force episode end
390
+
391
+ except RuntimeError as exc:
392
+ # Environment unreachable β€” abort
393
+ print(
394
+ f"[STEP] step={steps} action=ABORT "
395
+ f"reward={R_WRONG_PROCEDURE:.4f} done=true "
396
+ f"error='{exc}'",
397
+ flush=True,
398
  )
399
+ print(f"\n❌ Environment error: {exc}", file=sys.stderr)
400
  rewards.append(R_WRONG_PROCEDURE)
401
+ done = True
402
 
403
  except Exception as exc:
404
+ # Transient / unexpected error:
405
+ # β€’ Do NOT reset the episode.
406
+ # β€’ Append minimum non-zero signal.
407
+ # β€’ Keep the current obs and retry from the same email.
408
  print(
409
+ f"[STEP] step={steps} action=ERROR "
410
+ f"reward={R_WRONG_PROCEDURE:.4f} done=false "
411
+ f"error='{exc}'",
412
  flush=True,
413
  )
414
  rewards.append(R_WRONG_PROCEDURE)
415
+ # obs is unchanged β€” next loop iteration retries same email
416
 
417
  final_score = calculate_overall_score(rewards)
418
  success = final_score >= SUCCESS_THRESHOLD
419
 
420
  except Exception as exc:
421
+ print(f"❌ Execution failure: {exc}", file=sys.stderr)
422
  final_score = R_WRONG_PROCEDURE # Open-interval safe default
423
 
424
  finally:
 
428
  f"score={final_score:.4f} rewards={rewards_str}",
429
  flush=True,
430
  )
431
+ # Human-readable summary
432
+ print(
433
+ f"\n{'='*60}\n"
434
+ f" Final Score : {final_score:.4f}\n"
435
+ f" Steps taken : {steps}\n"
436
+ f" Success : {success} (threshold β‰₯ {SUCCESS_THRESHOLD})\n"
437
+ f" Rewards : {[round(r, 2) for r in rewards]}\n"
438
+ f"{'='*60}",
439
+ flush=True,
440
+ )
441
 
442
 
443
  if __name__ == "__main__":
444
+ main()