yc1838 commited on
Commit
0bfffbc
·
1 Parent(s): c516e08

updated to print

Browse files
.gitignore CHANGED
@@ -12,3 +12,4 @@ anatomy_full.pdf
12
  *.csv
13
  .pycache/*
14
  .worktrees/
 
 
12
  *.csv
13
  .pycache/*
14
  .worktrees/
15
+ __pycache__/*
app.py CHANGED
@@ -1,5 +1,6 @@
1
  import os
2
  import sys
 
3
  from pathlib import Path
4
 
5
  from dotenv import load_dotenv
@@ -26,7 +27,19 @@ class LilithAgent:
26
  self.cfg = cfg or Config.from_env()
27
  self.client = client or ScoringApiClient()
28
  self.graph = build_react_agent(self.cfg)
29
- print(f"LilithAgent initialized (caveman={self.cfg.caveman}/{self.cfg.caveman_mode}).")
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def run_and_submit_all(profile: gr.OAuthProfile | None):
@@ -34,9 +47,9 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
34
 
35
  if profile:
36
  username = profile.username
37
- print(f"User logged in: {username}")
38
  else:
39
- print("User not logged in.")
40
  return "Please Login to Hugging Face with the button.", None
41
 
42
  submit_url = f"{DEFAULT_API_URL}/submit"
@@ -44,31 +57,40 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
44
  try:
45
  agent = LilithAgent()
46
  except Exception as e:
47
- print(f"Error instantiating agent: {e}")
 
48
  return f"Error initializing agent: {e}", None
49
 
50
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
51
- print(agent_code)
52
 
53
- print("Fetching questions from scoring API...")
54
  try:
55
  questions_data = agent.client.get_questions()
56
  except requests.exceptions.RequestException as e:
 
 
57
  return f"Error fetching questions: {e}", None
58
  if agent.client.last_warning:
59
- print(agent.client.last_warning)
60
 
61
  if not questions_data:
62
  return "Fetched questions list is empty or invalid format.", None
63
- print(f"Fetched {len(questions_data)} questions.")
64
-
65
- print(f"Running agent on {len(questions_data)} questions...")
66
- answers_payload = run_agent_on_questions(
67
- agent.graph,
68
- questions_data,
69
- agent.cfg.checkpoint_dir,
70
- client=agent.client,
71
- )
 
 
 
 
 
 
72
  answers_by_id = {a["task_id"]: a["submitted_answer"] for a in answers_payload}
73
  results_log = [
74
  {
@@ -88,12 +110,20 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
88
  "agent_code": agent_code,
89
  "answers": answers_payload,
90
  }
91
- print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
92
 
93
  try:
94
  response = requests.post(submit_url, json=submission_data, timeout=60)
 
95
  response.raise_for_status()
96
  result_data = response.json()
 
 
 
 
 
 
 
97
  final_status = (
98
  f"Submission Successful!\n"
99
  f"User: {result_data.get('username')}\n"
@@ -109,12 +139,18 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
109
  error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
110
  except requests.exceptions.JSONDecodeError:
111
  error_detail += f" Response: {e.response.text[:500]}"
 
112
  return f"Submission Failed: {error_detail}", pd.DataFrame(results_log)
113
  except requests.exceptions.Timeout:
 
114
  return "Submission Failed: The request timed out.", pd.DataFrame(results_log)
115
  except requests.exceptions.RequestException as e:
 
 
116
  return f"Submission Failed: Network error - {e}", pd.DataFrame(results_log)
117
  except Exception as e:
 
 
118
  return f"An unexpected error occurred during submission: {e}", pd.DataFrame(results_log)
119
 
120
 
@@ -144,24 +180,24 @@ with gr.Blocks() as demo:
144
 
145
 
146
  if __name__ == "__main__":
147
- print("\n" + "-" * 30 + " App Starting " + "-" * 30)
148
  space_host_startup = os.getenv("SPACE_HOST")
149
  space_id_startup = os.getenv("SPACE_ID")
150
 
151
  if space_host_startup:
152
- print(f"✅ SPACE_HOST found: {space_host_startup}")
153
- print(f" Runtime URL should be: https://{space_host_startup}.hf.space")
154
  else:
155
- print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
156
 
157
  if space_id_startup:
158
- print(f"✅ SPACE_ID found: {space_id_startup}")
159
- print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
160
- print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
161
  else:
162
- print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.")
163
 
164
- print("-" * (60 + len(" App Starting ")) + "\n")
165
 
166
- print("Launching Gradio Interface for Lilith Agent Evaluation...")
167
  demo.launch(debug=True, share=False)
 
1
  import os
2
  import sys
3
+ import traceback
4
  from pathlib import Path
5
 
6
  from dotenv import load_dotenv
 
27
  self.cfg = cfg or Config.from_env()
28
  self.client = client or ScoringApiClient()
29
  self.graph = build_react_agent(self.cfg)
30
+ print(f"LilithAgent initialized (caveman={self.cfg.caveman}/{self.cfg.caveman_mode}).", flush=True)
31
+ print(
32
+ "[config] "
33
+ f"cheap={self.cfg.cheap_provider}/{self.cfg.cheap_model} "
34
+ f"strong={self.cfg.strong_provider}/{self.cfg.strong_model} "
35
+ f"extra={self.cfg.extra_strong_provider}/{self.cfg.extra_strong_model} "
36
+ f"vision={self.cfg.vision_provider}/{self.cfg.vision_model} "
37
+ f"recursion_limit={self.cfg.recursion_limit} "
38
+ f"budget_warn_at={self.cfg.budget_warn_at} "
39
+ f"budget_hard_cap={self.cfg.budget_hard_cap} "
40
+ f"checkpoint_dir={self.cfg.checkpoint_dir}",
41
+ flush=True,
42
+ )
43
 
44
 
45
  def run_and_submit_all(profile: gr.OAuthProfile | None):
 
47
 
48
  if profile:
49
  username = profile.username
50
+ print(f"User logged in: {username}", flush=True)
51
  else:
52
+ print("User not logged in.", flush=True)
53
  return "Please Login to Hugging Face with the button.", None
54
 
55
  submit_url = f"{DEFAULT_API_URL}/submit"
 
57
  try:
58
  agent = LilithAgent()
59
  except Exception as e:
60
+ print(f"Error instantiating agent: {e}", flush=True)
61
+ traceback.print_exc()
62
  return f"Error initializing agent: {e}", None
63
 
64
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
65
+ print(agent_code, flush=True)
66
 
67
+ print("Fetching questions from scoring API...", flush=True)
68
  try:
69
  questions_data = agent.client.get_questions()
70
  except requests.exceptions.RequestException as e:
71
+ print(f"Error fetching questions: {e}", flush=True)
72
+ traceback.print_exc()
73
  return f"Error fetching questions: {e}", None
74
  if agent.client.last_warning:
75
+ print(agent.client.last_warning, flush=True)
76
 
77
  if not questions_data:
78
  return "Fetched questions list is empty or invalid format.", None
79
+ print(f"Fetched {len(questions_data)} questions.", flush=True)
80
+
81
+ print(f"Running agent on {len(questions_data)} questions...", flush=True)
82
+ try:
83
+ answers_payload = run_agent_on_questions(
84
+ agent.graph,
85
+ questions_data,
86
+ agent.cfg.checkpoint_dir,
87
+ client=agent.client,
88
+ )
89
+ except Exception as e:
90
+ print(f"[app] runner failed type={type(e).__name__} error={e}", flush=True)
91
+ traceback.print_exc()
92
+ return f"Agent runner failed: {type(e).__name__}: {e}", None
93
+ print(f"[app] runner produced {len(answers_payload)} answers", flush=True)
94
  answers_by_id = {a["task_id"]: a["submitted_answer"] for a in answers_payload}
95
  results_log = [
96
  {
 
110
  "agent_code": agent_code,
111
  "answers": answers_payload,
112
  }
113
+ print(f"Submitting {len(answers_payload)} answers to: {submit_url}", flush=True)
114
 
115
  try:
116
  response = requests.post(submit_url, json=submission_data, timeout=60)
117
+ print(f"[submit] status_code={response.status_code}", flush=True)
118
  response.raise_for_status()
119
  result_data = response.json()
120
+ print(
121
+ "[submit] success "
122
+ f"score={result_data.get('score', 'N/A')} "
123
+ f"correct={result_data.get('correct_count', '?')} "
124
+ f"attempted={result_data.get('total_attempted', '?')}",
125
+ flush=True,
126
+ )
127
  final_status = (
128
  f"Submission Successful!\n"
129
  f"User: {result_data.get('username')}\n"
 
139
  error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
140
  except requests.exceptions.JSONDecodeError:
141
  error_detail += f" Response: {e.response.text[:500]}"
142
+ print(f"[submit] http_error {error_detail}", flush=True)
143
  return f"Submission Failed: {error_detail}", pd.DataFrame(results_log)
144
  except requests.exceptions.Timeout:
145
+ print("[submit] timeout", flush=True)
146
  return "Submission Failed: The request timed out.", pd.DataFrame(results_log)
147
  except requests.exceptions.RequestException as e:
148
+ print(f"[submit] network_error {e}", flush=True)
149
+ traceback.print_exc()
150
  return f"Submission Failed: Network error - {e}", pd.DataFrame(results_log)
151
  except Exception as e:
152
+ print(f"[submit] unexpected_error type={type(e).__name__} error={e}", flush=True)
153
+ traceback.print_exc()
154
  return f"An unexpected error occurred during submission: {e}", pd.DataFrame(results_log)
155
 
156
 
 
180
 
181
 
182
  if __name__ == "__main__":
183
+ print("\n" + "-" * 30 + " App Starting " + "-" * 30, flush=True)
184
  space_host_startup = os.getenv("SPACE_HOST")
185
  space_id_startup = os.getenv("SPACE_ID")
186
 
187
  if space_host_startup:
188
+ print(f"✅ SPACE_HOST found: {space_host_startup}", flush=True)
189
+ print(f" Runtime URL should be: https://{space_host_startup}.hf.space", flush=True)
190
  else:
191
+ print("ℹ️ SPACE_HOST environment variable not found (running locally?).", flush=True)
192
 
193
  if space_id_startup:
194
+ print(f"✅ SPACE_ID found: {space_id_startup}", flush=True)
195
+ print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}", flush=True)
196
+ print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main", flush=True)
197
  else:
198
+ print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.", flush=True)
199
 
200
+ print("-" * (60 + len(" App Starting ")) + "\n", flush=True)
201
 
202
+ print("Launching Gradio Interface for Lilith Agent Evaluation...", flush=True)
203
  demo.launch(debug=True, share=False)
src/lilith_agent/app.py CHANGED
@@ -307,8 +307,16 @@ def _route_after_model(
307
  AIMessage has tool_calls; otherwise END.
308
  """
309
  if state.get("iterations", 0) >= recursion_limit - 2:
 
 
 
 
310
  return "fail_safe"
311
  if _count_tool_calls_since_last_human(state["messages"]) >= budget_hard_cap:
 
 
 
 
312
  log.info("[hard_cap] per-question tool-call cap hit; forcing fail_safe")
313
  return "fail_safe"
314
  last = state["messages"][-1]
@@ -335,6 +343,10 @@ def _build_tool_node(
335
  tool_calls = getattr(last, "tool_calls", None) or []
336
  todo_state_update = None
337
  if tool_calls:
 
 
 
 
338
  log_tools.info(
339
  "[tools] dispatching %d call(s): %s",
340
  len(tool_calls),
@@ -368,6 +380,7 @@ def _build_tool_node(
368
 
369
  key = _call_key(name, args)
370
  if key in seen:
 
371
  log.info("[dedup] short-circuited repeat tool call: %s %s", name, args)
372
  results.append(ToolMessage(
373
  tool_call_id=tc_id,
@@ -392,6 +405,7 @@ def _build_tool_node(
392
  if score > best_score:
393
  best_prior, best_score = prior_q, score
394
  if best_score >= semantic_dedup_threshold:
 
395
  log.info("[semantic_dedup] %.2f match vs prior: %r ~ %r", best_score, q, best_prior)
396
  results.append(ToolMessage(
397
  tool_call_id=tc_id,
@@ -409,6 +423,7 @@ def _build_tool_node(
409
 
410
  cooldown_limit = _cooldown_limit_for(name)
411
  if count_recent_errors(name) >= cooldown_limit:
 
412
  log.info("[loop_breaker] force-cooldown %s (limit=%d)", name, cooldown_limit)
413
  results.append(ToolMessage(
414
  tool_call_id=tc_id,
@@ -425,6 +440,7 @@ def _build_tool_node(
425
 
426
  tool = tools_by_name.get(name)
427
  if tool is None:
 
428
  results.append(ToolMessage(
429
  tool_call_id=tc_id,
430
  name=name or "unknown",
@@ -440,10 +456,12 @@ def _build_tool_node(
440
  if len(args_preview) > _TOOL_ARG_PREVIEW_CHARS:
441
  args_preview = args_preview[:_TOOL_ARG_PREVIEW_CHARS] + "…"
442
  log_tools.info("[tools] calling tool=%s args=%s", name, args_preview)
 
443
 
444
  try:
445
  out = tool.invoke(args)
446
  except Exception as e:
 
447
  log_tools.warning("[tools] %s raised: %s", name, e)
448
  out = f"ERROR: {type(e).__name__}: {e}"
449
  if len(out) > 1000:
@@ -471,6 +489,7 @@ def _build_tool_node(
471
  if len(preview) > _TOOL_RESULT_PREVIEW_CHARS:
472
  preview = preview[:_TOOL_RESULT_PREVIEW_CHARS] + "…"
473
  log_tools.info("[tools] tool result (%d chars): %s", len(out_str), preview)
 
474
  results.append(ToolMessage(tool_call_id=tc_id, name=name, content=out_str))
475
 
476
  update = {"messages": results}
@@ -522,6 +541,15 @@ def build_react_agent(cfg: Config):
522
  supervisor_model = None
523
  summarize_fn = _make_tool_result_summarizer(cfg) if cfg.compact_summarize else None
524
 
 
 
 
 
 
 
 
 
 
525
  def model_node(state):
526
  from langchain_core.messages import SystemMessage
527
  from lilith_agent.memory import retrieve_relevant_context
@@ -604,6 +632,10 @@ def build_react_agent(cfg: Config):
604
  tool_calls_this_turn,
605
  len(compacted),
606
  )
 
 
 
 
607
  response = model.invoke(prompt_msgs)
608
 
609
  # Clean up Gemini signatures and unhelpful metadata to reduce log noise and context bloat
@@ -618,6 +650,7 @@ def build_react_agent(cfg: Config):
618
 
619
  # Fallback for empty responses
620
  if not response.content and not getattr(response, "tool_calls", None):
 
621
  log_model.warning("[model] blank response detected; injecting system nudge")
622
  response = AIMessage(content=(
623
  "SYSTEM: Your previous response was empty. If you have enough information, "
@@ -627,6 +660,7 @@ def build_react_agent(cfg: Config):
627
  else:
628
  requested = [tc.get("name") for tc in (getattr(response, "tool_calls", None) or [])]
629
  if requested:
 
630
  log_model.info("[model] requested tool_calls=%s", requested)
631
  else:
632
  content_text = response.content
@@ -638,22 +672,27 @@ def build_react_agent(cfg: Config):
638
  content_text = str(content_text or "").strip().replace("\n", " ")
639
  if len(content_text) > _TOOL_RESULT_PREVIEW_CHARS:
640
  content_text = content_text[:_TOOL_RESULT_PREVIEW_CHARS] + "…"
 
641
  log_model.info("[model] finished content=%r", content_text)
642
 
643
  return {"messages": [response], "iterations": iteration + 1}
644
 
645
  def fail_safe_node(state):
 
646
  log_fail_safe.warning(
647
  "[fail_safe] emergency override: iter=%d",
648
  state.get("iterations", 0),
649
  )
 
650
  sys_prompt = (
651
  "SYSTEM EMERGENCY OVERRIDE: You have hit the absolute maximum iteration limit for this task. "
652
- "You are FORCED to stop. Provide a brief 'Final Answer:' summarizing what you have tried, "
653
- "why it failed, and what the best conclusion is so far."
 
654
  )
655
  compacted = _compact_old_tool_messages(state["messages"], summarize_fn=summarize_fn)
656
  response = base_model.invoke([SystemMessage(sys_prompt)] + compacted)
 
657
  return {"messages": [response]}
658
 
659
  def supervisor_node(state):
@@ -694,6 +733,10 @@ def build_react_agent(cfg: Config):
694
  best_answer[:80],
695
  guidance[:160],
696
  )
 
 
 
 
697
  update = {
698
  "supervisor_decision": status,
699
  "supervisor_best_answer": best_answer,
@@ -712,16 +755,21 @@ def build_react_agent(cfg: Config):
712
  def supervisor_finalizer_node(state):
713
  best_answer = str(state.get("supervisor_best_answer", "") or "").strip()
714
  if best_answer:
 
715
  return {"messages": [AIMessage(content=f"Final Answer: {best_answer}")]}
716
  guidance = str(state.get("supervisor_guidance", "") or "").strip()
 
717
  compacted = _compact_old_tool_messages(state["messages"], summarize_fn=summarize_fn)
718
  response = base_model.invoke([
719
  SystemMessage(content=(
720
- "SUPERVISOR FINALIZER: Stop tool use. Produce the best possible Final Answer "
721
- f"using the existing evidence. Supervisor guidance: {guidance}"
 
 
722
  )),
723
  *compacted,
724
  ])
 
725
  return {"messages": [response]}
726
 
727
  def extract_memory_node(state):
 
307
  AIMessage has tool_calls; otherwise END.
308
  """
309
  if state.get("iterations", 0) >= recursion_limit - 2:
310
+ print(
311
+ f"[route] recursion threshold reached iter={state.get('iterations', 0)} limit={recursion_limit}",
312
+ flush=True,
313
+ )
314
  return "fail_safe"
315
  if _count_tool_calls_since_last_human(state["messages"]) >= budget_hard_cap:
316
+ print(
317
+ f"[route] hard cap reached tool_calls={_count_tool_calls_since_last_human(state['messages'])} cap={budget_hard_cap}",
318
+ flush=True,
319
+ )
320
  log.info("[hard_cap] per-question tool-call cap hit; forcing fail_safe")
321
  return "fail_safe"
322
  last = state["messages"][-1]
 
343
  tool_calls = getattr(last, "tool_calls", None) or []
344
  todo_state_update = None
345
  if tool_calls:
346
+ print(
347
+ f"[tools] dispatching count={len(tool_calls)} names={[tc.get('name') for tc in tool_calls]}",
348
+ flush=True,
349
+ )
350
  log_tools.info(
351
  "[tools] dispatching %d call(s): %s",
352
  len(tool_calls),
 
380
 
381
  key = _call_key(name, args)
382
  if key in seen:
383
+ print(f"[tools] dedup tool={name}", flush=True)
384
  log.info("[dedup] short-circuited repeat tool call: %s %s", name, args)
385
  results.append(ToolMessage(
386
  tool_call_id=tc_id,
 
405
  if score > best_score:
406
  best_prior, best_score = prior_q, score
407
  if best_score >= semantic_dedup_threshold:
408
+ print(f"[tools] semantic_dedup score={best_score:.2f} tool={name}", flush=True)
409
  log.info("[semantic_dedup] %.2f match vs prior: %r ~ %r", best_score, q, best_prior)
410
  results.append(ToolMessage(
411
  tool_call_id=tc_id,
 
423
 
424
  cooldown_limit = _cooldown_limit_for(name)
425
  if count_recent_errors(name) >= cooldown_limit:
426
+ print(f"[tools] cooldown tool={name} limit={cooldown_limit}", flush=True)
427
  log.info("[loop_breaker] force-cooldown %s (limit=%d)", name, cooldown_limit)
428
  results.append(ToolMessage(
429
  tool_call_id=tc_id,
 
440
 
441
  tool = tools_by_name.get(name)
442
  if tool is None:
443
+ print(f"[tools] unknown tool={name}", flush=True)
444
  results.append(ToolMessage(
445
  tool_call_id=tc_id,
446
  name=name or "unknown",
 
456
  if len(args_preview) > _TOOL_ARG_PREVIEW_CHARS:
457
  args_preview = args_preview[:_TOOL_ARG_PREVIEW_CHARS] + "…"
458
  log_tools.info("[tools] calling tool=%s args=%s", name, args_preview)
459
+ print(f"[tools] calling tool={name} args={args_preview}", flush=True)
460
 
461
  try:
462
  out = tool.invoke(args)
463
  except Exception as e:
464
+ print(f"[tools] error tool={name} type={type(e).__name__} msg={e}", flush=True)
465
  log_tools.warning("[tools] %s raised: %s", name, e)
466
  out = f"ERROR: {type(e).__name__}: {e}"
467
  if len(out) > 1000:
 
489
  if len(preview) > _TOOL_RESULT_PREVIEW_CHARS:
490
  preview = preview[:_TOOL_RESULT_PREVIEW_CHARS] + "…"
491
  log_tools.info("[tools] tool result (%d chars): %s", len(out_str), preview)
492
+ print(f"[tools] result tool={name} chars={len(out_str)} preview={preview}", flush=True)
493
  results.append(ToolMessage(tool_call_id=tc_id, name=name, content=out_str))
494
 
495
  update = {"messages": results}
 
541
  supervisor_model = None
542
  summarize_fn = _make_tool_result_summarizer(cfg) if cfg.compact_summarize else None
543
 
544
+ def _initial_question_from_state(state) -> str:
545
+ for m in state["messages"]:
546
+ if isinstance(m, HumanMessage):
547
+ raw = str(m.content).split("--- BENCHMARK SCORING RULES ---")[0].strip()
548
+ if raw.startswith("<gaia_question>") and raw.endswith("</gaia_question>"):
549
+ raw = raw[len("<gaia_question>"):-len("</gaia_question>")].strip()
550
+ return raw
551
+ return ""
552
+
553
  def model_node(state):
554
  from langchain_core.messages import SystemMessage
555
  from lilith_agent.memory import retrieve_relevant_context
 
632
  tool_calls_this_turn,
633
  len(compacted),
634
  )
635
+ print(
636
+ f"[model] invoking iter={iteration} tool_calls_so_far={tool_calls_this_turn} msgs={len(compacted)}",
637
+ flush=True,
638
+ )
639
  response = model.invoke(prompt_msgs)
640
 
641
  # Clean up Gemini signatures and unhelpful metadata to reduce log noise and context bloat
 
650
 
651
  # Fallback for empty responses
652
  if not response.content and not getattr(response, "tool_calls", None):
653
+ print("[model] blank response detected", flush=True)
654
  log_model.warning("[model] blank response detected; injecting system nudge")
655
  response = AIMessage(content=(
656
  "SYSTEM: Your previous response was empty. If you have enough information, "
 
660
  else:
661
  requested = [tc.get("name") for tc in (getattr(response, "tool_calls", None) or [])]
662
  if requested:
663
+ print(f"[model] requested tool_calls={requested}", flush=True)
664
  log_model.info("[model] requested tool_calls=%s", requested)
665
  else:
666
  content_text = response.content
 
672
  content_text = str(content_text or "").strip().replace("\n", " ")
673
  if len(content_text) > _TOOL_RESULT_PREVIEW_CHARS:
674
  content_text = content_text[:_TOOL_RESULT_PREVIEW_CHARS] + "…"
675
+ print(f"[model] finished content={content_text!r}", flush=True)
676
  log_model.info("[model] finished content=%r", content_text)
677
 
678
  return {"messages": [response], "iterations": iteration + 1}
679
 
680
  def fail_safe_node(state):
681
+ print(f"[fail_safe] emergency override: iter={state.get('iterations', 0)}", flush=True)
682
  log_fail_safe.warning(
683
  "[fail_safe] emergency override: iter=%d",
684
  state.get("iterations", 0),
685
  )
686
+ original_question = _initial_question_from_state(state)
687
  sys_prompt = (
688
  "SYSTEM EMERGENCY OVERRIDE: You have hit the absolute maximum iteration limit for this task. "
689
+ "You are FORCED to stop tool use. Answer the original question, not an intermediate hop. "
690
+ "Provide a bare final answer in 'Final Answer: ...' form using the best conclusion supported so far. "
691
+ f"Original question: {original_question}"
692
  )
693
  compacted = _compact_old_tool_messages(state["messages"], summarize_fn=summarize_fn)
694
  response = base_model.invoke([SystemMessage(sys_prompt)] + compacted)
695
+ print(f"[fail_safe] produced content={_message_text(getattr(response, 'content', ''))[:240]!r}", flush=True)
696
  return {"messages": [response]}
697
 
698
  def supervisor_node(state):
 
733
  best_answer[:80],
734
  guidance[:160],
735
  )
736
+ print(
737
+ f"[supervisor] status={status} best={best_answer[:80]!r} guidance={guidance[:160]!r}",
738
+ flush=True,
739
+ )
740
  update = {
741
  "supervisor_decision": status,
742
  "supervisor_best_answer": best_answer,
 
755
  def supervisor_finalizer_node(state):
756
  best_answer = str(state.get("supervisor_best_answer", "") or "").strip()
757
  if best_answer:
758
+ print(f"[supervisor_finalizer] finalizing best={best_answer[:160]!r}", flush=True)
759
  return {"messages": [AIMessage(content=f"Final Answer: {best_answer}")]}
760
  guidance = str(state.get("supervisor_guidance", "") or "").strip()
761
+ original_question = _initial_question_from_state(state)
762
  compacted = _compact_old_tool_messages(state["messages"], summarize_fn=summarize_fn)
763
  response = base_model.invoke([
764
  SystemMessage(content=(
765
+ "SUPERVISOR FINALIZER: Stop tool use. Answer the original question, not an intermediate hop. "
766
+ "Produce a bare final answer in 'Final Answer: ...' form using the existing evidence. "
767
+ f"Original question: {original_question}\n"
768
+ f"Supervisor guidance: {guidance}"
769
  )),
770
  *compacted,
771
  ])
772
+ print(f"[supervisor_finalizer] produced content={_message_text(getattr(response, 'content', ''))[:240]!r}", flush=True)
773
  return {"messages": [response]}
774
 
775
  def extract_memory_node(state):
src/lilith_agent/config.py CHANGED
@@ -49,6 +49,8 @@ class Config:
49
  semantic_dedup_threshold: float = 0.5
50
  compact_summarize: bool = True
51
  llm_formatter_enabled: bool = True
 
 
52
 
53
  @classmethod
54
  def from_env(cls) -> "Config":
@@ -79,4 +81,6 @@ class Config:
79
  semantic_dedup_threshold=_get_float_env("GAIA_SEMANTIC_DEDUP_THRESHOLD", "0.5"),
80
  compact_summarize=os.getenv("GAIA_COMPACT_SUMMARIZE", "true").lower() == "true",
81
  llm_formatter_enabled=os.getenv("GAIA_LLM_FORMATTER_ENABLED", "true").lower() == "true",
 
 
82
  )
 
49
  semantic_dedup_threshold: float = 0.5
50
  compact_summarize: bool = True
51
  llm_formatter_enabled: bool = True
52
+ answer_contract_enabled: bool = True
53
+ give_up_recovery_enabled: bool = True
54
 
55
  @classmethod
56
  def from_env(cls) -> "Config":
 
81
  semantic_dedup_threshold=_get_float_env("GAIA_SEMANTIC_DEDUP_THRESHOLD", "0.5"),
82
  compact_summarize=os.getenv("GAIA_COMPACT_SUMMARIZE", "true").lower() == "true",
83
  llm_formatter_enabled=os.getenv("GAIA_LLM_FORMATTER_ENABLED", "true").lower() == "true",
84
+ answer_contract_enabled=os.getenv("GAIA_ANSWER_CONTRACT_ENABLED", "true").lower() == "true",
85
+ give_up_recovery_enabled=os.getenv("GAIA_GIVE_UP_RECOVERY_ENABLED", "true").lower() == "true",
86
  )
src/lilith_agent/runner.py CHANGED
@@ -56,6 +56,51 @@ _LLM_FORMATTER_LEN_GATE = 40
56
  _ASSIGNMENT_PREFIX = re.compile(r"^\s*(?:x|y|answer|result)\s*[:=]\s*(.+?)\s*$", re.IGNORECASE)
57
  _COMMA_GROUPED_INTEGER = re.compile(r"^[+-]?\d{1,3}(?:,\d{3})+$")
58
  _SCALAR_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
 
61
  def _wrap_user_question(text: str) -> str:
@@ -167,9 +212,201 @@ def _normalize_gaia_submission(question: str, answer: str) -> str:
167
  if ";" in s:
168
  s = re.sub(r"\s*;\s*", "; ", s).strip()
169
 
 
 
 
 
170
  return s
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def _write_checkpoint_atomic(path: Path, data: dict) -> None:
174
  """Serialize first, then rename. A crash mid-serialize leaves the prior file intact.
175
 
@@ -239,6 +476,7 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
239
  cheap_model = get_cheap_model(cfg)
240
 
241
  total = len(questions)
 
242
 
243
  def _invoke_task_once(task_state: dict, task_id: str):
244
  from lilith_agent.memory import ephemeral_memory
@@ -251,6 +489,7 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
251
  pause_seconds = batch_rate_limit_pause_seconds()
252
  if pause_seconds is None:
253
  return
 
254
  log_runner.warning("[runner] pausing batch for %ss due to rate limit window", pause_seconds)
255
  time.sleep(pause_seconds)
256
  clear_batch_rate_limit_window()
@@ -260,13 +499,18 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
260
  task_id = question.get("task_id")
261
  prompt = question.get("question")
262
  if not task_id or not prompt:
 
263
  continue
264
 
265
  file_name = question.get("file_name")
266
  if file_name and client:
 
267
  file_path = client.download_file(task_id, dest_dir=checkpoint_root / "files")
268
  if file_path:
 
269
  prompt += f"\n\n[Attached File Path: {file_path.absolute()}]"
 
 
270
 
271
  # Scoring rules removed from here to reduce per-turn context bloat.
272
  # They are now applied in a final post-processing step.
@@ -276,6 +520,7 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
276
  try:
277
  checkpoint = json.loads(checkpoint_path.read_text())
278
  log_runner.info("[runner] task=%s (%d/%d) skipped (checkpoint exists)", task_id, idx, total)
 
279
  answers.append(
280
  {
281
  "task_id": task_id,
@@ -284,12 +529,14 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
284
  )
285
  continue
286
  except Exception:
 
287
  pass
288
 
289
  log_runner.info(
290
  "[runner] task=%s (%d/%d) starting q=%r",
291
  task_id, idx, total, (prompt[:160] + "…") if len(prompt) > 160 else prompt,
292
  )
 
293
 
294
  state = {
295
  "messages": [HumanMessage(content=_wrap_user_question(prompt))],
@@ -300,6 +547,10 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
300
  try:
301
  result = _invoke_task_once(state, task_id)
302
  except RateLimitCooldownError as exc:
 
 
 
 
303
  log_runner.warning(
304
  "[runner] task=%s rate limited provider=%s model=%s cooldown=%s",
305
  task_id,
@@ -308,18 +559,22 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
308
  exc.cooldown_seconds,
309
  )
310
  time.sleep(exc.cooldown_seconds)
 
311
  result = _invoke_task_once(state, task_id)
312
  except RateLimitCooldownError as exc:
 
313
  log_runner.warning("[runner] task=%s rate limited after retry: %s", task_id, exc)
314
  answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
315
  _maybe_pause_for_batch_rate_limit()
316
  continue
317
  except QuestionRateLimitStreakError as exc:
 
318
  log_runner.warning("[runner] task=%s rate limit streak: %s", task_id, exc)
319
  answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
320
  _maybe_pause_for_batch_rate_limit()
321
  continue
322
  except BatchAbortRateLimitError as exc:
 
323
  log_runner.warning("[runner] task=%s batch abort rate limit: %s", task_id, exc)
324
  answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
325
  _write_checkpoint_atomic(
@@ -333,6 +588,7 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
333
  _maybe_pause_for_batch_rate_limit()
334
  continue
335
  except Exception as exc:
 
336
  log_runner.warning("[runner] task=%s agent error: %s", task_id, exc)
337
  answers.append(
338
  {
@@ -359,8 +615,22 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
359
  llm_formatter_enabled=cfg.llm_formatter_enabled,
360
  )
361
  submitted_answer = _normalize_gaia_submission(prompt, submitted_answer)
362
-
363
  reasoning_trace = _render_reasoning_trace(result["messages"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
  checkpoint = {
366
  "task_id": task_id,
@@ -371,15 +641,19 @@ def run_agent_on_questions(graph: Any, questions: list[dict], checkpoint_dir: st
371
 
372
  if submitted_answer and not submitted_answer.startswith("AGENT ERROR"):
373
  _write_checkpoint_atomic(checkpoint_path, checkpoint)
 
374
 
375
  log_runner.info(
376
  "[runner] task=%s (%d/%d) answer=%r",
377
  task_id, idx, total,
378
  (submitted_answer[:160] + "…") if len(submitted_answer) > 160 else submitted_answer,
379
  )
 
 
380
  answers.append({"task_id": task_id, "submitted_answer": submitted_answer.strip()})
381
  _maybe_pause_for_batch_rate_limit()
382
 
 
383
  return answers
384
 
385
 
 
56
  _ASSIGNMENT_PREFIX = re.compile(r"^\s*(?:x|y|answer|result)\s*[:=]\s*(.+?)\s*$", re.IGNORECASE)
57
  _COMMA_GROUPED_INTEGER = re.compile(r"^[+-]?\d{1,3}(?:,\d{3})+$")
58
  _SCALAR_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$")
59
+ # Matches "to N decimal places" or "to the nearest tenth/hundredth/thousandth"
60
+ _DECIMAL_PLACES_RE = re.compile(
61
+ r"(?:to|rounded?\s+to|nearest)\s+"
62
+ r"(?:(\d+)\s+decimal\s+place[s]?|the\s+nearest\s+(tenth|hundredth|thousandth|ten[-\s]?thousandth))",
63
+ re.IGNORECASE,
64
+ )
65
+ _PRECISION_WORDS = {
66
+ "tenth": 1, "hundredth": 2, "thousandth": 3,
67
+ "ten-thousandth": 4, "ten thousandth": 4,
68
+ }
69
+ def _required_decimal_places(question: str) -> int | None:
70
+ """Return the number of decimal places the question demands, or None."""
71
+ m = _DECIMAL_PLACES_RE.search(question)
72
+ if not m:
73
+ return None
74
+ if m.group(1):
75
+ return int(m.group(1))
76
+ word = m.group(2).lower().replace(" ", "-")
77
+ return _PRECISION_WORDS.get(word)
78
+
79
+
80
+ def _apply_decimal_precision(s: str, places: int) -> str:
81
+ """Reformat a numeric string to exactly `places` decimal places."""
82
+ try:
83
+ value = float(s)
84
+ return f"{value:.{places}f}"
85
+ except (ValueError, OverflowError):
86
+ return s
87
+
88
+
89
+ _ANSWER_CONTRACT_QUESTION_MARKERS = (
90
+ "country", "countries", "capital", "arrival", "time", "meter", "metre",
91
+ "label", "score", "passenger", "title", "author", "date", "year",
92
+ "how many",
93
+ )
94
+ _GIVE_UP_PHRASES = (
95
+ "unknown",
96
+ "i don't know",
97
+ "cannot determine",
98
+ "could not determine",
99
+ "unable to determine",
100
+ "not enough information",
101
+ "could not complete",
102
+ "why it failed",
103
+ )
104
 
105
 
106
  def _wrap_user_question(text: str) -> str:
 
212
  if ";" in s:
213
  s = re.sub(r"\s*;\s*", "; ", s).strip()
214
 
215
+ required_places = _required_decimal_places(question)
216
+ if required_places is not None and _SCALAR_NUMBER.fullmatch(s):
217
+ s = _apply_decimal_precision(s, required_places)
218
+
219
  return s
220
 
221
 
222
+ def _is_give_up_answer(answer: str) -> bool:
223
+ s = answer.strip().lower()
224
+ if not s:
225
+ return True
226
+ if s.startswith("agent error"):
227
+ return False
228
+ if s in {"unknown", "n/a", "not found"}:
229
+ return True
230
+ if len(s) <= 240 and any(phrase in s for phrase in _GIVE_UP_PHRASES):
231
+ return True
232
+ return False
233
+
234
+
235
+ def _question_has_answer_contract_marker(question: str) -> bool:
236
+ q = question.lower()
237
+ return any(re.search(rf"(?<!\w){re.escape(marker)}(?!\w)", q) for marker in _ANSWER_CONTRACT_QUESTION_MARKERS)
238
+
239
+
240
+ def _needs_answer_contract_check(question: str, answer: str) -> bool:
241
+ if _is_give_up_answer(answer):
242
+ return False
243
+ q = question.lower()
244
+ if not _question_has_answer_contract_marker(question):
245
+ return False
246
+ if _SCALAR_NUMBER.fullmatch(answer.strip()) and not any(marker in q for marker in ("time", "arrival", "date", "year")):
247
+ return False
248
+ return True
249
+
250
+
251
+ def _parse_contract_response(content: Any) -> dict[str, str]:
252
+ if isinstance(content, list):
253
+ text = "".join(
254
+ part.get("text", "") if isinstance(part, dict) else str(part)
255
+ for part in content
256
+ )
257
+ else:
258
+ text = str(content or "")
259
+ text = text.strip()
260
+ try:
261
+ parsed = json.loads(text)
262
+ except Exception:
263
+ match = re.search(r"\{.*\}", text, flags=re.S)
264
+ if not match:
265
+ return {"status": "ok", "submitted_answer": "", "reason": ""}
266
+ try:
267
+ parsed = json.loads(match.group(0))
268
+ except Exception:
269
+ return {"status": "ok", "submitted_answer": "", "reason": ""}
270
+ if not isinstance(parsed, dict):
271
+ return {"status": "ok", "submitted_answer": "", "reason": ""}
272
+ status = str(parsed.get("status", "ok") or "ok").strip().lower()
273
+ if status not in {"ok", "repair"}:
274
+ status = "ok"
275
+ return {
276
+ "status": status,
277
+ "submitted_answer": str(parsed.get("submitted_answer", "") or "").strip(),
278
+ "reason": str(parsed.get("reason", "") or "").strip(),
279
+ }
280
+
281
+
282
+ def _repair_supported_by_context(question: str, reasoning_trace: str, repaired: str) -> bool:
283
+ context = f"{question}\n{reasoning_trace}".lower()
284
+ pieces = [
285
+ piece.strip(" \t\r\n.,;:()[]{}\"'`")
286
+ for piece in re.split(r"\s*(?:,|;|\band\b)\s*", repaired)
287
+ ]
288
+ pieces = [piece for piece in pieces if piece]
289
+ if not pieces:
290
+ return False
291
+ return all(piece.lower() in context for piece in pieces)
292
+
293
+
294
+ def _apply_answer_contract(
295
+ model: Any,
296
+ question: str,
297
+ answer: str,
298
+ reasoning_trace: str,
299
+ *,
300
+ enabled: bool = True,
301
+ ) -> str:
302
+ if not enabled or not _needs_answer_contract_check(question, answer):
303
+ return answer
304
+ from langchain_core.messages import SystemMessage, HumanMessage
305
+
306
+ prompt = (
307
+ "You are a GAIA benchmark answer contract verifier. Check whether the submitted "
308
+ "answer answers the ORIGINAL question, not an intermediate hop. If the answer type "
309
+ "matches the question, return JSON {\"status\":\"ok\"}. If the answer is clearly "
310
+ "the wrong type and the evidence trace contains the correct final answer, return "
311
+ "JSON {\"status\":\"repair\",\"submitted_answer\":\"...\",\"reason\":\"...\"}. "
312
+ "Do not guess. Do not repair unless the replacement appears in the evidence trace."
313
+ )
314
+ user = (
315
+ f"ORIGINAL QUESTION:\n{question}\n\n"
316
+ f"SUBMITTED ANSWER:\n{answer}\n\n"
317
+ f"EVIDENCE TRACE:\n{reasoning_trace[-4000:]}"
318
+ )
319
+ try:
320
+ response = model.invoke([SystemMessage(content=prompt), HumanMessage(content=user)])
321
+ except Exception as exc:
322
+ log.warning("answer_contract: verifier failed (%s), keeping original answer", exc)
323
+ return answer
324
+ decision = _parse_contract_response(getattr(response, "content", ""))
325
+ if decision["status"] != "repair":
326
+ return answer
327
+ repaired = _normalize_gaia_submission(question, decision["submitted_answer"])
328
+ if not repaired:
329
+ return answer
330
+ if not _repair_supported_by_context(question, reasoning_trace, repaired):
331
+ log.warning("answer_contract: rejected unsupported repair %r", repaired)
332
+ return answer
333
+ log.info("answer_contract: repaired answer %r -> %r", answer, repaired)
334
+ return repaired
335
+
336
+
337
+ def _parse_recovery_response(content: Any) -> dict[str, str]:
338
+ if isinstance(content, list):
339
+ text = "".join(
340
+ part.get("text", "") if isinstance(part, dict) else str(part)
341
+ for part in content
342
+ )
343
+ else:
344
+ text = str(content or "")
345
+ text = text.strip()
346
+ try:
347
+ parsed = json.loads(text)
348
+ except Exception:
349
+ match = re.search(r"\{.*\}", text, flags=re.S)
350
+ if not match:
351
+ return {"status": "keep", "submitted_answer": "", "reason": ""}
352
+ try:
353
+ parsed = json.loads(match.group(0))
354
+ except Exception:
355
+ return {"status": "keep", "submitted_answer": "", "reason": ""}
356
+ if not isinstance(parsed, dict):
357
+ return {"status": "keep", "submitted_answer": "", "reason": ""}
358
+ status = str(parsed.get("status", "keep") or "keep").strip().lower()
359
+ if status not in {"keep", "answer"}:
360
+ status = "keep"
361
+ return {
362
+ "status": status,
363
+ "submitted_answer": str(parsed.get("submitted_answer", "") or "").strip(),
364
+ "reason": str(parsed.get("reason", "") or "").strip(),
365
+ }
366
+
367
+
368
+ def _apply_give_up_recovery(
369
+ model: Any,
370
+ question: str,
371
+ answer: str,
372
+ reasoning_trace: str,
373
+ *,
374
+ enabled: bool = True,
375
+ ) -> str:
376
+ if not enabled or not _is_give_up_answer(answer):
377
+ return answer
378
+ from langchain_core.messages import SystemMessage, HumanMessage
379
+
380
+ prompt = (
381
+ "You are a GAIA benchmark give-up recovery verifier. The submitted answer is "
382
+ "empty, unknown, or a failure summary. If the evidence trace contains a concrete "
383
+ "answer to the original question, return JSON {\"status\":\"answer\","
384
+ "\"submitted_answer\":\"...\",\"reason\":\"...\"}. Otherwise return JSON "
385
+ "{\"status\":\"keep\"}. Do not guess. The submitted_answer must appear in the trace."
386
+ )
387
+ user = (
388
+ f"ORIGINAL QUESTION:\n{question}\n\n"
389
+ f"CURRENT SUBMITTED ANSWER:\n{answer}\n\n"
390
+ f"EVIDENCE TRACE:\n{reasoning_trace[-4000:]}"
391
+ )
392
+ try:
393
+ response = model.invoke([SystemMessage(content=prompt), HumanMessage(content=user)])
394
+ except Exception as exc:
395
+ log.warning("give_up_recovery: verifier failed (%s), keeping original answer", exc)
396
+ return answer
397
+ decision = _parse_recovery_response(getattr(response, "content", ""))
398
+ if decision["status"] != "answer":
399
+ return answer
400
+ recovered = _normalize_gaia_submission(question, decision["submitted_answer"])
401
+ if not recovered:
402
+ return answer
403
+ if not _repair_supported_by_context(question, reasoning_trace, recovered):
404
+ log.warning("give_up_recovery: rejected unsupported answer %r", recovered)
405
+ return answer
406
+ log.info("give_up_recovery: recovered answer %r -> %r", answer, recovered)
407
+ return recovered
408
+
409
+
410
  def _write_checkpoint_atomic(path: Path, data: dict) -> None:
411
  """Serialize first, then rename. A crash mid-serialize leaves the prior file intact.
412
 
 
476
  cheap_model = get_cheap_model(cfg)
477
 
478
  total = len(questions)
479
+ print(f"[runner] starting batch total={total} checkpoint_dir={checkpoint_root}", flush=True)
480
 
481
  def _invoke_task_once(task_state: dict, task_id: str):
482
  from lilith_agent.memory import ephemeral_memory
 
489
  pause_seconds = batch_rate_limit_pause_seconds()
490
  if pause_seconds is None:
491
  return
492
+ print(f"[runner] pausing batch seconds={pause_seconds} reason=rate_limit_window", flush=True)
493
  log_runner.warning("[runner] pausing batch for %ss due to rate limit window", pause_seconds)
494
  time.sleep(pause_seconds)
495
  clear_batch_rate_limit_window()
 
499
  task_id = question.get("task_id")
500
  prompt = question.get("question")
501
  if not task_id or not prompt:
502
+ print(f"[runner] skipping invalid question idx={idx} task_id={task_id!r}", flush=True)
503
  continue
504
 
505
  file_name = question.get("file_name")
506
  if file_name and client:
507
+ print(f"[runner] task={task_id} downloading file={file_name}", flush=True)
508
  file_path = client.download_file(task_id, dest_dir=checkpoint_root / "files")
509
  if file_path:
510
+ print(f"[runner] task={task_id} file_path={file_path.absolute()}", flush=True)
511
  prompt += f"\n\n[Attached File Path: {file_path.absolute()}]"
512
+ else:
513
+ print(f"[runner] task={task_id} file_download_missing file={file_name}", flush=True)
514
 
515
  # Scoring rules removed from here to reduce per-turn context bloat.
516
  # They are now applied in a final post-processing step.
 
520
  try:
521
  checkpoint = json.loads(checkpoint_path.read_text())
522
  log_runner.info("[runner] task=%s (%d/%d) skipped (checkpoint exists)", task_id, idx, total)
523
+ print(f"[runner] task={task_id} ({idx}/{total}) skipped checkpoint={checkpoint_path}", flush=True)
524
  answers.append(
525
  {
526
  "task_id": task_id,
 
529
  )
530
  continue
531
  except Exception:
532
+ print(f"[runner] task={task_id} checkpoint unreadable path={checkpoint_path}", flush=True)
533
  pass
534
 
535
  log_runner.info(
536
  "[runner] task=%s (%d/%d) starting q=%r",
537
  task_id, idx, total, (prompt[:160] + "…") if len(prompt) > 160 else prompt,
538
  )
539
+ print(f"[runner] task={task_id} ({idx}/{total}) starting", flush=True)
540
 
541
  state = {
542
  "messages": [HumanMessage(content=_wrap_user_question(prompt))],
 
547
  try:
548
  result = _invoke_task_once(state, task_id)
549
  except RateLimitCooldownError as exc:
550
+ print(
551
+ f"[runner] task={task_id} rate_limited provider={exc.provider} model={exc.model} cooldown={exc.cooldown_seconds}",
552
+ flush=True,
553
+ )
554
  log_runner.warning(
555
  "[runner] task=%s rate limited provider=%s model=%s cooldown=%s",
556
  task_id,
 
559
  exc.cooldown_seconds,
560
  )
561
  time.sleep(exc.cooldown_seconds)
562
+ print(f"[runner] task={task_id} retrying after cooldown", flush=True)
563
  result = _invoke_task_once(state, task_id)
564
  except RateLimitCooldownError as exc:
565
+ print(f"[runner] task={task_id} rate_limited_after_retry error={exc}", flush=True)
566
  log_runner.warning("[runner] task=%s rate limited after retry: %s", task_id, exc)
567
  answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
568
  _maybe_pause_for_batch_rate_limit()
569
  continue
570
  except QuestionRateLimitStreakError as exc:
571
+ print(f"[runner] task={task_id} rate_limit_streak error={exc}", flush=True)
572
  log_runner.warning("[runner] task=%s rate limit streak: %s", task_id, exc)
573
  answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
574
  _maybe_pause_for_batch_rate_limit()
575
  continue
576
  except BatchAbortRateLimitError as exc:
577
+ print(f"[runner] task={task_id} batch_abort_rate_limit reason={exc.reason}", flush=True)
578
  log_runner.warning("[runner] task=%s batch abort rate limit: %s", task_id, exc)
579
  answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
580
  _write_checkpoint_atomic(
 
588
  _maybe_pause_for_batch_rate_limit()
589
  continue
590
  except Exception as exc:
591
+ print(f"[runner] task={task_id} agent_error type={type(exc).__name__} error={exc}", flush=True)
592
  log_runner.warning("[runner] task=%s agent error: %s", task_id, exc)
593
  answers.append(
594
  {
 
615
  llm_formatter_enabled=cfg.llm_formatter_enabled,
616
  )
617
  submitted_answer = _normalize_gaia_submission(prompt, submitted_answer)
618
+
619
  reasoning_trace = _render_reasoning_trace(result["messages"])
620
+ submitted_answer = _apply_answer_contract(
621
+ cheap_model,
622
+ prompt,
623
+ submitted_answer,
624
+ reasoning_trace,
625
+ enabled=cfg.answer_contract_enabled,
626
+ )
627
+ submitted_answer = _apply_give_up_recovery(
628
+ cheap_model,
629
+ prompt,
630
+ submitted_answer,
631
+ reasoning_trace,
632
+ enabled=cfg.give_up_recovery_enabled,
633
+ )
634
 
635
  checkpoint = {
636
  "task_id": task_id,
 
641
 
642
  if submitted_answer and not submitted_answer.startswith("AGENT ERROR"):
643
  _write_checkpoint_atomic(checkpoint_path, checkpoint)
644
+ print(f"[runner] task={task_id} checkpoint_written path={checkpoint_path}", flush=True)
645
 
646
  log_runner.info(
647
  "[runner] task=%s (%d/%d) answer=%r",
648
  task_id, idx, total,
649
  (submitted_answer[:160] + "…") if len(submitted_answer) > 160 else submitted_answer,
650
  )
651
+ answer_preview = (submitted_answer[:160] + "…") if len(submitted_answer) > 160 else submitted_answer
652
+ print(f"[runner] task={task_id} ({idx}/{total}) answer={answer_preview!r}", flush=True)
653
  answers.append({"task_id": task_id, "submitted_answer": submitted_answer.strip()})
654
  _maybe_pause_for_batch_rate_limit()
655
 
656
+ print(f"[runner] finished batch produced={len(answers)}", flush=True)
657
  return answers
658
 
659
 
src/lilith_agent/scoring_client.py CHANGED
@@ -81,6 +81,7 @@ class ScoringApiClient:
81
  f"Scoring API unavailable while trying to {action} ({detail}); "
82
  f"falling back to GAIA dataset."
83
  )
 
84
  log.warning(self.last_warning)
85
  return dataset_client
86
 
 
81
  f"Scoring API unavailable while trying to {action} ({detail}); "
82
  f"falling back to GAIA dataset."
83
  )
84
+ print(self.last_warning, flush=True)
85
  log.warning(self.last_warning)
86
  return dataset_client
87
 
tests/test_config.py CHANGED
@@ -40,3 +40,19 @@ def test_compact_summarize_defaults_on_and_is_env_overridable(monkeypatch):
40
 
41
  monkeypatch.setenv("GAIA_COMPACT_SUMMARIZE", "false")
42
  assert Config.from_env().compact_summarize is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  monkeypatch.setenv("GAIA_COMPACT_SUMMARIZE", "false")
42
  assert Config.from_env().compact_summarize is False
43
+
44
+
45
+ def test_answer_contract_defaults_on_and_is_env_overridable(monkeypatch):
46
+ monkeypatch.delenv("GAIA_ANSWER_CONTRACT_ENABLED", raising=False)
47
+ assert Config.from_env().answer_contract_enabled is True
48
+
49
+ monkeypatch.setenv("GAIA_ANSWER_CONTRACT_ENABLED", "false")
50
+ assert Config.from_env().answer_contract_enabled is False
51
+
52
+
53
+ def test_give_up_recovery_defaults_on_and_is_env_overridable(monkeypatch):
54
+ monkeypatch.delenv("GAIA_GIVE_UP_RECOVERY_ENABLED", raising=False)
55
+ assert Config.from_env().give_up_recovery_enabled is True
56
+
57
+ monkeypatch.setenv("GAIA_GIVE_UP_RECOVERY_ENABLED", "false")
58
+ assert Config.from_env().give_up_recovery_enabled is False
tests/test_formatter.py CHANGED
@@ -230,6 +230,44 @@ def test_gaia_submission_normalizer_leaves_risky_answers_unchanged(answer: str):
230
  assert _normalize_gaia_submission("Q", answer) == answer
231
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def test_config_llm_formatter_enabled_defaults_on_and_is_env_overridable(monkeypatch):
234
  from lilith_agent.config import Config
235
 
 
230
  assert _normalize_gaia_submission("Q", answer) == answer
231
 
232
 
233
+ @pytest.mark.parametrize(
234
+ ("question", "answer", "expected"),
235
+ [
236
+ # rounds excess decimal places down
237
+ ("Give answer to 3 decimal places.", "1.4560", "1.456"),
238
+ # pads too-short answer to required precision
239
+ ("Round to 3 decimal places.", "17.06", "17.060"),
240
+ # nearest-tenth keyword
241
+ ("Express to the nearest tenth.", "1.46", "1.5"),
242
+ # nearest-hundredth keyword
243
+ ("Round to the nearest hundredth.", "0.2690", "0.27"),
244
+ # nearest-thousandth keyword
245
+ ("Round to the nearest thousandth.", "0.2690", "0.269"),
246
+ # no precision requirement → untouched
247
+ ("What is the answer?", "1.456", "1.456"),
248
+ ],
249
+ )
250
+ def test_decimal_precision_normalization(question: str, answer: str, expected: str):
251
+ from lilith_agent.runner import _normalize_gaia_submission
252
+
253
+ assert _normalize_gaia_submission(question, answer) == expected
254
+
255
+
256
+ @pytest.mark.parametrize(
257
+ ("question", "answer"),
258
+ [
259
+ # non-numeric answer — precision check skipped
260
+ ("Give answer to 3 decimal places.", "Paris"),
261
+ # dotted identifier — not a bare scalar, precision check skipped
262
+ ("Round to 3 decimal places.", "3.1.3.1"),
263
+ ],
264
+ )
265
+ def test_decimal_precision_skipped_for_non_numeric(question: str, answer: str):
266
+ from lilith_agent.runner import _normalize_gaia_submission
267
+
268
+ assert _normalize_gaia_submission(question, answer) == answer
269
+
270
+
271
  def test_config_llm_formatter_enabled_defaults_on_and_is_env_overridable(monkeypatch):
272
  from lilith_agent.config import Config
273
 
tests/test_graph.py CHANGED
@@ -27,7 +27,7 @@ def test_router_ends_when_no_tool_calls():
27
  assert _route_after_model(state) == "extract_memory"
28
 
29
 
30
- def test_graph_returns_fail_safe_answer_when_hard_cap_hits_near_recursion_limit(monkeypatch, tmp_path):
31
  class FakeModel:
32
  def __init__(self):
33
  self.calls = 0
@@ -65,6 +65,9 @@ def test_graph_returns_fail_safe_answer_when_hard_cap_hits_near_recursion_limit(
65
  {"configurable": {"thread_id": "hard-cap-test"}},
66
  )
67
 
 
 
 
68
  assert result["messages"][-1].content == "Final Answer: best effort answer"
69
 
70
 
@@ -115,6 +118,53 @@ def test_fail_safe_uses_unbound_model_to_prevent_more_tool_calls(monkeypatch, tm
115
  assert not getattr(result["messages"][-1], "tool_calls", None)
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def test_supervisor_nudges_agent_to_answer_when_evidence_is_enough(monkeypatch, tmp_path):
119
  class FakeBoundModel:
120
  def __init__(self):
@@ -175,6 +225,58 @@ def test_supervisor_nudges_agent_to_answer_when_evidence_is_enough(monkeypatch,
175
  assert strong.bound.calls == 2
176
 
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  def test_supervisor_finalizes_when_agent_ignores_prior_nudge(monkeypatch, tmp_path):
179
  class FakeBoundModel:
180
  def __init__(self):
 
27
  assert _route_after_model(state) == "extract_memory"
28
 
29
 
30
+ def test_graph_returns_fail_safe_answer_when_hard_cap_hits_near_recursion_limit(monkeypatch, tmp_path, capsys):
31
  class FakeModel:
32
  def __init__(self):
33
  self.calls = 0
 
65
  {"configurable": {"thread_id": "hard-cap-test"}},
66
  )
67
 
68
+ captured = capsys.readouterr().out
69
+ assert "[route] recursion threshold reached" in captured
70
+ assert "[fail_safe] emergency override" in captured
71
  assert result["messages"][-1].content == "Final Answer: best effort answer"
72
 
73
 
 
118
  assert not getattr(result["messages"][-1], "tool_calls", None)
119
 
120
 
121
+ def test_fail_safe_prompt_reinforces_original_question_contract(monkeypatch, tmp_path):
122
+ class FakeBoundModel:
123
+ def invoke(self, messages):
124
+ return _ai_with_calls([
125
+ {
126
+ "id": "bound-call",
127
+ "name": "echo_tool",
128
+ "args": {"text": "intermediate"},
129
+ }
130
+ ])
131
+
132
+ class FakeModel:
133
+ def __init__(self):
134
+ self.bound = FakeBoundModel()
135
+ self.fail_safe_prompt = ""
136
+
137
+ def bind_tools(self, tools):
138
+ return self.bound
139
+
140
+ def invoke(self, messages):
141
+ self.fail_safe_prompt = str(messages[0].content)
142
+ return AIMessage(content="Final Answer: best effort")
143
+
144
+ fake_model = FakeModel()
145
+ cfg = Config.from_env()
146
+ cfg.recursion_limit = 4
147
+ cfg.budget_hard_cap = 1
148
+ cfg.budget_warn_at = 99
149
+ cfg.compact_summarize = False
150
+ monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
151
+ monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: fake_model)
152
+ monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
153
+ monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
154
+ monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
155
+
156
+ graph = build_react_agent(cfg)
157
+ graph.invoke(
158
+ {"messages": [HumanMessage(content="What country corresponds to this capital?")], "iterations": 0, "todos": []},
159
+ {"configurable": {"thread_id": "fail-safe-contract-prompt-test"}},
160
+ )
161
+
162
+ prompt = fake_model.fail_safe_prompt.lower()
163
+ assert "original question" in prompt
164
+ assert "not an intermediate" in prompt
165
+ assert "bare final answer" in prompt
166
+
167
+
168
  def test_supervisor_nudges_agent_to_answer_when_evidence_is_enough(monkeypatch, tmp_path):
169
  class FakeBoundModel:
170
  def __init__(self):
 
225
  assert strong.bound.calls == 2
226
 
227
 
228
+ def test_supervisor_finalizer_prompt_reinforces_original_question_contract(monkeypatch, tmp_path):
229
+ class FakeBoundModel:
230
+ def invoke(self, messages):
231
+ return _ai_with_calls([
232
+ {
233
+ "id": "call",
234
+ "name": "echo_tool",
235
+ "args": {"text": "evidence"},
236
+ }
237
+ ])
238
+
239
+ class FakeStrongModel:
240
+ def __init__(self):
241
+ self.bound = FakeBoundModel()
242
+ self.finalizer_prompt = ""
243
+
244
+ def bind_tools(self, tools):
245
+ return self.bound
246
+
247
+ def invoke(self, messages):
248
+ self.finalizer_prompt = str(messages[0].content)
249
+ return AIMessage(content="Final Answer: final")
250
+
251
+ class FakeSupervisorModel:
252
+ def invoke(self, messages):
253
+ return AIMessage(content='{"status":"finalize","best_answer":"","guidance":"Existing evidence is enough."}')
254
+
255
+ strong = FakeStrongModel()
256
+ cfg = Config.from_env()
257
+ cfg.recursion_limit = 10
258
+ cfg.budget_hard_cap = 99
259
+ cfg.budget_warn_at = 99
260
+ cfg.compact_summarize = False
261
+ monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
262
+ monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
263
+ monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: strong)
264
+ monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: FakeSupervisorModel())
265
+ monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
266
+ monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
267
+
268
+ graph = build_react_agent(cfg)
269
+ graph.invoke(
270
+ {"messages": [HumanMessage(content="What final entity answers the original question?")], "iterations": 0, "todos": []},
271
+ {"configurable": {"thread_id": "supervisor-finalizer-contract-prompt-test"}},
272
+ )
273
+
274
+ prompt = strong.finalizer_prompt.lower()
275
+ assert "original question" in prompt
276
+ assert "not an intermediate" in prompt
277
+ assert "bare final answer" in prompt
278
+
279
+
280
  def test_supervisor_finalizes_when_agent_ignores_prior_nudge(monkeypatch, tmp_path):
281
  class FakeBoundModel:
282
  def __init__(self):
tests/test_runner.py CHANGED
@@ -131,6 +131,23 @@ def test_runner_retries_same_question_once_after_cooldown(monkeypatch, tmp_path:
131
  assert (tmp_path / "task-1.json").exists()
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  class _GraphAlwaysCooldown:
135
  def __init__(self):
136
  self.calls = 0
@@ -256,6 +273,169 @@ class _GraphReturnsAssignmentAnswer:
256
  return {"messages": [AIMessage(content="x = 563.9")]}
257
 
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  def test_runner_applies_gaia_submission_normalizer(tmp_path: Path):
260
  answers = run_agent_on_questions(
261
  _GraphReturnsAssignmentAnswer(),
@@ -268,6 +448,38 @@ def test_runner_applies_gaia_submission_normalizer(tmp_path: Path):
268
  assert checkpoint["submitted_answer"] == "563.9"
269
 
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  def test_runner_pauses_batch_when_window_trips(monkeypatch, tmp_path: Path):
272
  pauses = [300, None]
273
  sleeps = []
 
131
  assert (tmp_path / "task-1.json").exists()
132
 
133
 
134
+ def test_runner_prints_hf_visible_progress_and_success(monkeypatch, tmp_path: Path, capsys):
135
+ monkeypatch.setattr("lilith_agent.runner._final_formatting_cleanup", lambda model, question, raw, llm_formatter_enabled=True: raw)
136
+
137
+ answers = run_agent_on_questions(
138
+ _GraphAlwaysSucceeds(),
139
+ [{"task_id": "task-print", "question": "What is visible?"}],
140
+ tmp_path,
141
+ )
142
+
143
+ captured = capsys.readouterr().out
144
+ assert "[runner] starting batch total=1" in captured
145
+ assert "[runner] task=task-print (1/1) starting" in captured
146
+ assert "[runner] task=task-print (1/1) answer='answer-1'" in captured
147
+ assert "[runner] finished batch produced=1" in captured
148
+ assert answers == [{"task_id": "task-print", "submitted_answer": "answer-1"}]
149
+
150
+
151
  class _GraphAlwaysCooldown:
152
  def __init__(self):
153
  self.calls = 0
 
273
  return {"messages": [AIMessage(content="x = 563.9")]}
274
 
275
 
276
+ class _GraphReturnsWrongTypeWithEvidence:
277
+ def invoke(self, state, config):
278
+ return {
279
+ "messages": [
280
+ AIMessage(content="Evidence: Dili is the capital of Timor-Leste. Naypyidaw is the capital of Myanmar."),
281
+ AIMessage(content="Final Answer: Dili, Naypyidaw"),
282
+ ]
283
+ }
284
+
285
+
286
+ class _GraphReturnsUnknownWithEvidence:
287
+ def invoke(self, state, config):
288
+ return {
289
+ "messages": [
290
+ AIMessage(content="Evidence gathered from the page: the exact UI label is Citations."),
291
+ AIMessage(content="Final Answer: unknown"),
292
+ ]
293
+ }
294
+
295
+
296
+ class _FakeContractModel:
297
+ def __init__(self, response: str):
298
+ self.response = response
299
+ self.called = False
300
+
301
+ def invoke(self, _messages):
302
+ self.called = True
303
+
304
+ class _Resp:
305
+ pass
306
+
307
+ r = _Resp()
308
+ r.content = self.response
309
+ return r
310
+
311
+
312
+ class _RaiseIfContractCalled:
313
+ def invoke(self, _messages):
314
+ raise AssertionError("contract verifier should not have been called")
315
+
316
+
317
+ def test_answer_contract_repairs_wrong_type_when_repair_is_supported_by_trace():
318
+ from lilith_agent.runner import _apply_answer_contract
319
+
320
+ model = _FakeContractModel('{"status":"repair","submitted_answer":"Timor-Leste, Myanmar"}')
321
+
322
+ out = _apply_answer_contract(
323
+ model,
324
+ "What countries have the capitals Dili and Naypyidaw?",
325
+ "Dili, Naypyidaw",
326
+ "Dili is the capital of Timor-Leste. Naypyidaw is the capital of Myanmar.",
327
+ )
328
+
329
+ assert model.called is True
330
+ assert out == "Timor-Leste, Myanmar"
331
+
332
+
333
+ def test_answer_contract_rejects_unsupported_repair():
334
+ from lilith_agent.runner import _apply_answer_contract
335
+
336
+ model = _FakeContractModel('{"status":"repair","submitted_answer":"Indonesia, Myanmar"}')
337
+
338
+ out = _apply_answer_contract(
339
+ model,
340
+ "What countries have the capitals Dili and Naypyidaw?",
341
+ "Dili, Naypyidaw",
342
+ "Dili is a capital city. Naypyidaw is the capital of Myanmar.",
343
+ )
344
+
345
+ assert model.called is True
346
+ assert out == "Dili, Naypyidaw"
347
+
348
+
349
+ def test_answer_contract_skips_unambiguous_scalar_answer():
350
+ from lilith_agent.runner import _apply_answer_contract
351
+
352
+ out = _apply_answer_contract(
353
+ _RaiseIfContractCalled(),
354
+ "What is 6*7?",
355
+ "42",
356
+ "",
357
+ )
358
+
359
+ assert out == "42"
360
+
361
+
362
+ def test_answer_contract_skips_generic_which_question_without_type_marker():
363
+ from lilith_agent.runner import _apply_answer_contract
364
+
365
+ model = _FakeContractModel('{"status":"ok"}')
366
+
367
+ out = _apply_answer_contract(
368
+ model,
369
+ "Which mountain is the tallest?",
370
+ "Mount Everest",
371
+ "Mount Everest is the tallest mountain.",
372
+ )
373
+
374
+ assert model.called is False
375
+ assert out == "Mount Everest"
376
+
377
+
378
+ def test_answer_contract_marker_matching_avoids_word_internal_false_positive():
379
+ from lilith_agent.runner import _apply_answer_contract
380
+
381
+ model = _FakeContractModel('{"status":"ok"}')
382
+
383
+ out = _apply_answer_contract(
384
+ model,
385
+ "Which candidate won the race?",
386
+ "Alice",
387
+ "Alice won the race.",
388
+ )
389
+
390
+ assert model.called is False
391
+ assert out == "Alice"
392
+
393
+
394
+ def test_give_up_recovery_uses_supported_trace_answer():
395
+ from lilith_agent.runner import _apply_give_up_recovery
396
+
397
+ model = _FakeContractModel('{"status":"answer","submitted_answer":"Citations"}')
398
+
399
+ out = _apply_give_up_recovery(
400
+ model,
401
+ "What is the exact UI label?",
402
+ "unknown",
403
+ "Evidence gathered from the page: the exact UI label is Citations.",
404
+ )
405
+
406
+ assert model.called is True
407
+ assert out == "Citations"
408
+
409
+
410
+ def test_give_up_recovery_rejects_unsupported_answer():
411
+ from lilith_agent.runner import _apply_give_up_recovery
412
+
413
+ model = _FakeContractModel('{"status":"answer","submitted_answer":"Downloads"}')
414
+
415
+ out = _apply_give_up_recovery(
416
+ model,
417
+ "What is the exact UI label?",
418
+ "unknown",
419
+ "Evidence gathered from the page: the exact UI label is Citations.",
420
+ )
421
+
422
+ assert model.called is True
423
+ assert out == "unknown"
424
+
425
+
426
+ def test_give_up_recovery_skips_confident_answer():
427
+ from lilith_agent.runner import _apply_give_up_recovery
428
+
429
+ out = _apply_give_up_recovery(
430
+ _RaiseIfContractCalled(),
431
+ "What is the exact UI label?",
432
+ "Citations",
433
+ "Evidence gathered from the page: the exact UI label is Citations.",
434
+ )
435
+
436
+ assert out == "Citations"
437
+
438
+
439
  def test_runner_applies_gaia_submission_normalizer(tmp_path: Path):
440
  answers = run_agent_on_questions(
441
  _GraphReturnsAssignmentAnswer(),
 
448
  assert checkpoint["submitted_answer"] == "563.9"
449
 
450
 
451
+ def test_runner_applies_answer_contract_repair(monkeypatch, tmp_path: Path):
452
+ model = _FakeContractModel('{"status":"repair","submitted_answer":"Timor-Leste, Myanmar"}')
453
+ monkeypatch.setattr("lilith_agent.models.get_cheap_model", lambda cfg: model)
454
+
455
+ answers = run_agent_on_questions(
456
+ _GraphReturnsWrongTypeWithEvidence(),
457
+ [{"task_id": "task-contract", "question": "What countries have the capitals Dili and Naypyidaw?"}],
458
+ tmp_path,
459
+ )
460
+
461
+ assert model.called is True
462
+ assert answers == [{"task_id": "task-contract", "submitted_answer": "Timor-Leste, Myanmar"}]
463
+ checkpoint = json.loads((tmp_path / "task-contract.json").read_text())
464
+ assert checkpoint["submitted_answer"] == "Timor-Leste, Myanmar"
465
+
466
+
467
+ def test_runner_applies_give_up_recovery(monkeypatch, tmp_path: Path):
468
+ model = _FakeContractModel('{"status":"answer","submitted_answer":"Citations"}')
469
+ monkeypatch.setattr("lilith_agent.models.get_cheap_model", lambda cfg: model)
470
+
471
+ answers = run_agent_on_questions(
472
+ _GraphReturnsUnknownWithEvidence(),
473
+ [{"task_id": "task-recovery", "question": "What is the exact UI label?"}],
474
+ tmp_path,
475
+ )
476
+
477
+ assert model.called is True
478
+ assert answers == [{"task_id": "task-recovery", "submitted_answer": "Citations"}]
479
+ checkpoint = json.loads((tmp_path / "task-recovery.json").read_text())
480
+ assert checkpoint["submitted_answer"] == "Citations"
481
+
482
+
483
  def test_runner_pauses_batch_when_window_trips(monkeypatch, tmp_path: Path):
484
  pauses = [300, None]
485
  sleeps = []
tests/test_scoring_client.py CHANGED
@@ -49,6 +49,30 @@ class ScoringApiClientTests(unittest.TestCase):
49
  self.assertIn("429", client.last_warning or "")
50
  dataset_client.get_questions.assert_called_once_with()
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def test_download_file_falls_back_to_dataset_on_429(self) -> None:
53
  response = Mock(status_code=429, headers={})
54
  error = requests.HTTPError("Too Many Requests", response=response)
 
49
  self.assertIn("429", client.last_warning or "")
50
  dataset_client.get_questions.assert_called_once_with()
51
 
52
+ def test_fallback_prints_hf_visible_warning(self) -> None:
53
+ response = Mock(status_code=503, headers={})
54
+ error = requests.HTTPError("Service Unavailable", response=response)
55
+ response.raise_for_status.side_effect = error
56
+
57
+ session = Mock()
58
+ session.get.return_value = response
59
+ dataset_client = Mock()
60
+ dataset_client.get_questions.return_value = []
61
+
62
+ client = ScoringApiClient(
63
+ api_url="https://example.com",
64
+ session=session,
65
+ dataset_client=dataset_client,
66
+ )
67
+
68
+ with patch("builtins.print") as printed:
69
+ client.get_questions()
70
+
71
+ printed.assert_any_call(
72
+ "Scoring API unavailable while trying to fetch questions (status=503); falling back to GAIA dataset.",
73
+ flush=True,
74
+ )
75
+
76
  def test_download_file_falls_back_to_dataset_on_429(self) -> None:
77
  response = Mock(status_code=429, headers={})
78
  error = requests.HTTPError("Too Many Requests", response=response)