Spaces:
Sleeping
Sleeping
yc1838 commited on
Commit ·
0bfffbc
1
Parent(s): c516e08
updated to print
Browse files- .gitignore +1 -0
- app.py +63 -27
- src/lilith_agent/app.py +52 -4
- src/lilith_agent/config.py +4 -0
- src/lilith_agent/runner.py +275 -1
- src/lilith_agent/scoring_client.py +1 -0
- tests/test_config.py +16 -0
- tests/test_formatter.py +38 -0
- tests/test_graph.py +103 -1
- tests/test_runner.py +212 -0
- tests/test_scoring_client.py +24 -0
.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 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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
|
| 653 |
-
"
|
|
|
|
| 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.
|
| 721 |
-
|
|
|
|
|
|
|
| 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)
|