Spaces:
Sleeping
Sleeping
yc1838 commited on
Commit ·
6aa62ca
1
Parent(s): 9d361f6
feat: YouTube fallback strategy + dataset file logging + BLAS thread safety
Browse files- Add YouTube fallback strategy to agent prompt: try transcript first, then search for video ID/title/dialogue if blocked
- Log all GAIA dataset file operations: missing rows, downloads, local copies, failures
- Scoring client: fallback to dataset on 404 and invalid JSON payloads
- Force BLAS thread env defaults in Python sandbox to prevent threading conflicts
- Move sys.path.append before imports and apply safe thread env
- app.py +5 -1
- src/lilith_agent/app.py +9 -2
- src/lilith_agent/gaia_dataset.py +24 -4
- src/lilith_agent/runtime_env.py +17 -0
- src/lilith_agent/scoring_client.py +44 -4
- src/lilith_agent/tools/python_exec.py +12 -1
- tests/test_gaia_dataset.py +46 -0
- tests/test_graph.py +34 -0
- test_memory_pick_up.py → tests/test_memory_pick_up.py +0 -0
- tests/test_python_sandbox.py +12 -0
- tests/test_runtime_env.py +13 -0
- tests/test_scoring_client.py +61 -0
app.py
CHANGED
|
@@ -3,12 +3,16 @@ import sys
|
|
| 3 |
import traceback
|
| 4 |
from pathlib import Path
|
| 5 |
|
|
|
|
|
|
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
| 8 |
env_path = Path(__file__).parent / ".env"
|
| 9 |
load_dotenv(dotenv_path=env_path, override=True)
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
|
| 13 |
import gradio as gr
|
| 14 |
import pandas as pd
|
|
|
|
| 3 |
import traceback
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
+
sys.path.append(str(Path(__file__).parent / "src"))
|
| 7 |
+
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
|
| 10 |
env_path = Path(__file__).parent / ".env"
|
| 11 |
load_dotenv(dotenv_path=env_path, override=True)
|
| 12 |
|
| 13 |
+
from lilith_agent.runtime_env import apply_safe_thread_env
|
| 14 |
+
|
| 15 |
+
apply_safe_thread_env()
|
| 16 |
|
| 17 |
import gradio as gr
|
| 18 |
import pandas as pd
|
src/lilith_agent/app.py
CHANGED
|
@@ -595,8 +595,15 @@ def build_react_agent(cfg: Config):
|
|
| 595 |
"If `find_files` returns 'No files found' for an exact filename, do NOT escalate to `find_files(root='/')` — "
|
| 596 |
"broaden instead: `grep` for a substring, `find_files` with a shorter/partial name, or `ls` the parent "
|
| 597 |
"directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
|
| 598 |
-
"8.
|
| 599 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
)
|
| 601 |
|
| 602 |
if memory_context:
|
|
|
|
| 595 |
"If `find_files` returns 'No files found' for an exact filename, do NOT escalate to `find_files(root='/')` — "
|
| 596 |
"broaden instead: `grep` for a substring, `find_files` with a shorter/partial name, or `ls` the parent "
|
| 597 |
"directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
|
| 598 |
+
"8. YOUTUBE FALLBACK STRATEGY: First try `youtube_transcript` for spoken captions. If it is blocked, unavailable, "
|
| 599 |
+
"or says YouTube is blocking requests, do not repeatedly retry transcript or yt-dlp. Extract the video ID and "
|
| 600 |
+
"search for `\"<video id>\" transcript`, `\"<video id>\"`, the exact video title, and distinctive quoted dialogue "
|
| 601 |
+
"or on-screen phrases. Use search snippets, cached transcripts in Hugging Face Spaces/datasets, and reliable web "
|
| 602 |
+
"pages as evidence. For visual questions, try `youtube_frame_at` only when a timestamp is needed; if video download "
|
| 603 |
+
"is blocked, pivot to title/time/object searches and answer from the strongest available evidence."
|
| 604 |
+
"directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
|
| 605 |
+
"9. MATHEMATICAL PRECISION: If the question requires math, double-check your algebraic calculations carefully. If a specific decimal precision or rounding is asked for (e.g., 'to 2 decimal places', 'nearest tenth'), you MUST calculate precisely and round STRICTLY AT THE VERY END. Do NOT prematurely round intermediate numbers.\n"
|
| 606 |
+
"10. FINAL ANSWER FORMAT: When you have the final answer, output ONLY the value itself. Do not say 'The answer is...', do not provide explanations in your final output. Just output the bare minimum exact string, number, or list."
|
| 607 |
)
|
| 608 |
|
| 609 |
if memory_context:
|
src/lilith_agent/gaia_dataset.py
CHANGED
|
@@ -58,10 +58,18 @@ class GaiaDatasetClient:
|
|
| 58 |
"""Copy the dataset's local file_path into dest_dir."""
|
| 59 |
row = next((r for r in self._rows if r["task_id"] == task_id), None)
|
| 60 |
if row is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return None
|
| 62 |
|
| 63 |
file_name = row.get("file_name")
|
| 64 |
if not file_name:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return None
|
| 66 |
|
| 67 |
dest_root = Path(dest_dir)
|
|
@@ -69,16 +77,16 @@ class GaiaDatasetClient:
|
|
| 69 |
dest = dest_root / file_name
|
| 70 |
|
| 71 |
if dest.exists() and dest.stat().st_size > 300:
|
|
|
|
| 72 |
return dest
|
| 73 |
|
| 74 |
-
# 1. Try local data dir fallback
|
| 75 |
level = row.get("Level", "1")
|
| 76 |
local_data_path = Path("data") / f"gaia_level{level}" / "files" / task_id
|
| 77 |
if local_data_path.exists() and local_data_path.stat().st_size > 300:
|
| 78 |
shutil.copy(local_data_path, dest)
|
|
|
|
| 79 |
return dest
|
| 80 |
|
| 81 |
-
# 2. Direct download
|
| 82 |
year = "2023"
|
| 83 |
if hasattr(self, "config") and "_" in self.config:
|
| 84 |
year = self.config.split("_")[0]
|
|
@@ -96,8 +104,20 @@ class GaiaDatasetClient:
|
|
| 96 |
if response.status_code == 200:
|
| 97 |
with open(dest, "wb") as f:
|
| 98 |
shutil.copyfileobj(response.raw, f)
|
|
|
|
| 99 |
return dest
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
return None
|
|
|
|
| 58 |
"""Copy the dataset's local file_path into dest_dir."""
|
| 59 |
row = next((r for r in self._rows if r["task_id"] == task_id), None)
|
| 60 |
if row is None:
|
| 61 |
+
print(
|
| 62 |
+
f"[gaia_dataset] file row missing task={task_id} split={self.split} config={self.config}",
|
| 63 |
+
flush=True,
|
| 64 |
+
)
|
| 65 |
return None
|
| 66 |
|
| 67 |
file_name = row.get("file_name")
|
| 68 |
if not file_name:
|
| 69 |
+
print(
|
| 70 |
+
f"[gaia_dataset] file_name missing task={task_id} split={self.split} config={self.config}",
|
| 71 |
+
flush=True,
|
| 72 |
+
)
|
| 73 |
return None
|
| 74 |
|
| 75 |
dest_root = Path(dest_dir)
|
|
|
|
| 77 |
dest = dest_root / file_name
|
| 78 |
|
| 79 |
if dest.exists() and dest.stat().st_size > 300:
|
| 80 |
+
print(f"[gaia_dataset] file already exists task={task_id} path={dest}", flush=True)
|
| 81 |
return dest
|
| 82 |
|
|
|
|
| 83 |
level = row.get("Level", "1")
|
| 84 |
local_data_path = Path("data") / f"gaia_level{level}" / "files" / task_id
|
| 85 |
if local_data_path.exists() and local_data_path.stat().st_size > 300:
|
| 86 |
shutil.copy(local_data_path, dest)
|
| 87 |
+
print(f"[gaia_dataset] copied local file task={task_id} source={local_data_path} dest={dest}", flush=True)
|
| 88 |
return dest
|
| 89 |
|
|
|
|
| 90 |
year = "2023"
|
| 91 |
if hasattr(self, "config") and "_" in self.config:
|
| 92 |
year = self.config.split("_")[0]
|
|
|
|
| 104 |
if response.status_code == 200:
|
| 105 |
with open(dest, "wb") as f:
|
| 106 |
shutil.copyfileobj(response.raw, f)
|
| 107 |
+
print(f"[gaia_dataset] downloaded hf file task={task_id} path={dest} url={hf_url}", flush=True)
|
| 108 |
return dest
|
| 109 |
+
print(
|
| 110 |
+
f"[gaia_dataset] hf download failed task={task_id} status={response.status_code} url={hf_url}",
|
| 111 |
+
flush=True,
|
| 112 |
+
)
|
| 113 |
+
except Exception as exc:
|
| 114 |
+
print(
|
| 115 |
+
f"[gaia_dataset] hf download exception task={task_id} type={type(exc).__name__} error={exc} url={hf_url}",
|
| 116 |
+
flush=True,
|
| 117 |
+
)
|
| 118 |
|
| 119 |
+
print(
|
| 120 |
+
f"[gaia_dataset] file unavailable task={task_id} file_name={file_name} split={self.split} config={self.config}",
|
| 121 |
+
flush=True,
|
| 122 |
+
)
|
| 123 |
return None
|
src/lilith_agent/runtime_env.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
SAFE_THREAD_ENV = {
|
| 6 |
+
"OPENBLAS_NUM_THREADS": "1",
|
| 7 |
+
"OMP_NUM_THREADS": "1",
|
| 8 |
+
"MKL_NUM_THREADS": "1",
|
| 9 |
+
"NUMEXPR_NUM_THREADS": "1",
|
| 10 |
+
"VECLIB_MAXIMUM_THREADS": "1",
|
| 11 |
+
"TOKENIZERS_PARALLELISM": "false",
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def apply_safe_thread_env() -> None:
|
| 16 |
+
for key, value in SAFE_THREAD_ENV.items():
|
| 17 |
+
os.environ[key] = value
|
src/lilith_agent/scoring_client.py
CHANGED
|
@@ -48,18 +48,51 @@ class ScoringApiClient:
|
|
| 48 |
response = self.session.get(f"{self.api_url}/files/{task_id}", timeout=60)
|
| 49 |
response.raise_for_status()
|
| 50 |
except requests.RequestException as exc:
|
| 51 |
-
fallback = self._fallback_dataset_client_for(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
if fallback is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return None
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
|
| 56 |
filename = task_id
|
| 57 |
cd = response.headers.get("content-disposition", "")
|
| 58 |
if "filename=" in cd:
|
| 59 |
filename = cd.split("filename=")[-1].strip().strip('"')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
out = dest_dir / filename
|
| 61 |
out.write_bytes(response.content)
|
| 62 |
self.last_warning = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
return out
|
| 64 |
|
| 65 |
def _fallback_dataset_client_for(
|
|
@@ -67,8 +100,9 @@ class ScoringApiClient:
|
|
| 67 |
exc: requests.RequestException,
|
| 68 |
*,
|
| 69 |
action: str,
|
|
|
|
| 70 |
) -> Any | None:
|
| 71 |
-
if not self._should_use_dataset_fallback(exc):
|
| 72 |
return None
|
| 73 |
try:
|
| 74 |
dataset_client = self._get_dataset_client()
|
|
@@ -99,8 +133,14 @@ class ScoringApiClient:
|
|
| 99 |
return self._dataset_client
|
| 100 |
|
| 101 |
@staticmethod
|
| 102 |
-
def _should_use_dataset_fallback(exc: requests.RequestException) -> bool:
|
| 103 |
if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
|
| 104 |
return True
|
| 105 |
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
|
|
|
|
|
|
| 106 |
return status_code in {429, 502, 503, 504}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
response = self.session.get(f"{self.api_url}/files/{task_id}", timeout=60)
|
| 49 |
response.raise_for_status()
|
| 50 |
except requests.RequestException as exc:
|
| 51 |
+
fallback = self._fallback_dataset_client_for(
|
| 52 |
+
exc,
|
| 53 |
+
action=f"download file for {task_id}",
|
| 54 |
+
missing_is_fallback=True,
|
| 55 |
+
)
|
| 56 |
if fallback is None:
|
| 57 |
+
print(
|
| 58 |
+
f"[scoring] file download failed without fallback task={task_id} error={exc.__class__.__name__}",
|
| 59 |
+
flush=True,
|
| 60 |
+
)
|
| 61 |
return None
|
| 62 |
+
path = fallback.download_file(task_id, dest_dir)
|
| 63 |
+
print(f"[scoring] fallback file result task={task_id} path={path}", flush=True)
|
| 64 |
+
return path
|
| 65 |
|
| 66 |
filename = task_id
|
| 67 |
cd = response.headers.get("content-disposition", "")
|
| 68 |
if "filename=" in cd:
|
| 69 |
filename = cd.split("filename=")[-1].strip().strip('"')
|
| 70 |
+
content_type = response.headers.get("content-type", "")
|
| 71 |
+
if self._is_invalid_file_payload(response.content, content_type):
|
| 72 |
+
print(
|
| 73 |
+
f"[scoring] invalid file payload task={task_id} status={response.status_code} "
|
| 74 |
+
f"bytes={len(response.content)} content_type={content_type!r}; trying dataset fallback",
|
| 75 |
+
flush=True,
|
| 76 |
+
)
|
| 77 |
+
try:
|
| 78 |
+
fallback = self._get_dataset_client()
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
print(
|
| 81 |
+
f"[scoring] dataset fallback unavailable task={task_id} type={type(exc).__name__} error={exc}",
|
| 82 |
+
flush=True,
|
| 83 |
+
)
|
| 84 |
+
return None
|
| 85 |
+
path = fallback.download_file(task_id, dest_dir)
|
| 86 |
+
print(f"[scoring] fallback file result task={task_id} path={path}", flush=True)
|
| 87 |
+
return path
|
| 88 |
out = dest_dir / filename
|
| 89 |
out.write_bytes(response.content)
|
| 90 |
self.last_warning = None
|
| 91 |
+
print(
|
| 92 |
+
f"[scoring] file downloaded task={task_id} path={out} bytes={len(response.content)} "
|
| 93 |
+
f"content_type={response.headers.get('content-type', '')!r}",
|
| 94 |
+
flush=True,
|
| 95 |
+
)
|
| 96 |
return out
|
| 97 |
|
| 98 |
def _fallback_dataset_client_for(
|
|
|
|
| 100 |
exc: requests.RequestException,
|
| 101 |
*,
|
| 102 |
action: str,
|
| 103 |
+
missing_is_fallback: bool = False,
|
| 104 |
) -> Any | None:
|
| 105 |
+
if not self._should_use_dataset_fallback(exc, missing_is_fallback=missing_is_fallback):
|
| 106 |
return None
|
| 107 |
try:
|
| 108 |
dataset_client = self._get_dataset_client()
|
|
|
|
| 133 |
return self._dataset_client
|
| 134 |
|
| 135 |
@staticmethod
|
| 136 |
+
def _should_use_dataset_fallback(exc: requests.RequestException, *, missing_is_fallback: bool = False) -> bool:
|
| 137 |
if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
|
| 138 |
return True
|
| 139 |
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
| 140 |
+
if missing_is_fallback and status_code == 404:
|
| 141 |
+
return True
|
| 142 |
return status_code in {429, 502, 503, 504}
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def _is_invalid_file_payload(content: bytes, content_type: str) -> bool:
|
| 146 |
+
return "application/json" in content_type.lower() and len(content) < 4096
|
src/lilith_agent/tools/python_exec.py
CHANGED
|
@@ -27,9 +27,12 @@ import sys
|
|
| 27 |
import tempfile
|
| 28 |
import textwrap
|
| 29 |
|
|
|
|
|
|
|
| 30 |
_OUTPUT_CAP_CHARS = 200_000
|
| 31 |
_DOCKER_IMAGE_DEFAULT = "lilith-pysandbox:latest"
|
| 32 |
_DOCKER_STARTUP_HEADROOM_S = 5
|
|
|
|
| 33 |
|
| 34 |
# Env vars that are safe (or necessary) to forward. Everything else is dropped.
|
| 35 |
_ENV_ALLOWLIST = frozenset({
|
|
@@ -46,6 +49,12 @@ _ENV_ALLOWLIST = frozenset({
|
|
| 46 |
"PYTHONPATH",
|
| 47 |
"PYTHONHOME",
|
| 48 |
"PYTHONIOENCODING",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# Proxy config is routinely needed for scraping; not a secret.
|
| 50 |
"HTTP_PROXY",
|
| 51 |
"HTTPS_PROXY",
|
|
@@ -102,7 +111,9 @@ _RUNNER_SCRIPT = textwrap.dedent(
|
|
| 102 |
|
| 103 |
def _scrubbed_env() -> dict[str, str]:
|
| 104 |
"""Return a fresh env dict containing only allowlisted keys from os.environ."""
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
def _truncate_output(text: str) -> str:
|
|
|
|
| 27 |
import tempfile
|
| 28 |
import textwrap
|
| 29 |
|
| 30 |
+
from lilith_agent.runtime_env import SAFE_THREAD_ENV
|
| 31 |
+
|
| 32 |
_OUTPUT_CAP_CHARS = 200_000
|
| 33 |
_DOCKER_IMAGE_DEFAULT = "lilith-pysandbox:latest"
|
| 34 |
_DOCKER_STARTUP_HEADROOM_S = 5
|
| 35 |
+
BLAS_THREAD_ENV_DEFAULTS = SAFE_THREAD_ENV
|
| 36 |
|
| 37 |
# Env vars that are safe (or necessary) to forward. Everything else is dropped.
|
| 38 |
_ENV_ALLOWLIST = frozenset({
|
|
|
|
| 49 |
"PYTHONPATH",
|
| 50 |
"PYTHONHOME",
|
| 51 |
"PYTHONIOENCODING",
|
| 52 |
+
"OPENBLAS_NUM_THREADS",
|
| 53 |
+
"OMP_NUM_THREADS",
|
| 54 |
+
"MKL_NUM_THREADS",
|
| 55 |
+
"NUMEXPR_NUM_THREADS",
|
| 56 |
+
"VECLIB_MAXIMUM_THREADS",
|
| 57 |
+
"TOKENIZERS_PARALLELISM",
|
| 58 |
# Proxy config is routinely needed for scraping; not a secret.
|
| 59 |
"HTTP_PROXY",
|
| 60 |
"HTTPS_PROXY",
|
|
|
|
| 111 |
|
| 112 |
def _scrubbed_env() -> dict[str, str]:
|
| 113 |
"""Return a fresh env dict containing only allowlisted keys from os.environ."""
|
| 114 |
+
env = {k: v for k, v in os.environ.items() if k in _ENV_ALLOWLIST}
|
| 115 |
+
env.update(BLAS_THREAD_ENV_DEFAULTS)
|
| 116 |
+
return env
|
| 117 |
|
| 118 |
|
| 119 |
def _truncate_output(text: str) -> str:
|
tests/test_gaia_dataset.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from unittest.mock import Mock, patch
|
| 4 |
+
|
| 5 |
+
from lilith_agent.gaia_dataset import GaiaDatasetClient
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _client_with_rows(rows):
|
| 9 |
+
with patch("lilith_agent.gaia_dataset.load_dataset", return_value=rows):
|
| 10 |
+
return GaiaDatasetClient(config="2023_all", split="test", level=None, token="hf-token")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_download_file_prints_when_task_row_is_missing(tmp_path):
|
| 14 |
+
client = _client_with_rows([])
|
| 15 |
+
|
| 16 |
+
with patch("builtins.print") as printed:
|
| 17 |
+
path = client.download_file("missing-task", tmp_path)
|
| 18 |
+
|
| 19 |
+
assert path is None
|
| 20 |
+
printed.assert_any_call(
|
| 21 |
+
"[gaia_dataset] file row missing task=missing-task split=test config=2023_all",
|
| 22 |
+
flush=True,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_download_file_prints_hf_download_status_on_failure(tmp_path):
|
| 27 |
+
client = _client_with_rows([
|
| 28 |
+
{
|
| 29 |
+
"task_id": "task-img",
|
| 30 |
+
"Question": "Q",
|
| 31 |
+
"Level": "1",
|
| 32 |
+
"file_name": "task-img.png",
|
| 33 |
+
"Final answer": "",
|
| 34 |
+
}
|
| 35 |
+
])
|
| 36 |
+
response = Mock(status_code=404)
|
| 37 |
+
|
| 38 |
+
with patch("lilith_agent.gaia_dataset.requests.get", return_value=response) as get, patch("builtins.print") as printed:
|
| 39 |
+
path = client.download_file("task-img", tmp_path)
|
| 40 |
+
|
| 41 |
+
assert path is None
|
| 42 |
+
assert get.call_args.args[0] == "https://huggingface.co/datasets/gaia-benchmark/GAIA/resolve/main/2023/test/task-img.png"
|
| 43 |
+
printed.assert_any_call(
|
| 44 |
+
"[gaia_dataset] hf download failed task=task-img status=404 url=https://huggingface.co/datasets/gaia-benchmark/GAIA/resolve/main/2023/test/task-img.png",
|
| 45 |
+
flush=True,
|
| 46 |
+
)
|
tests/test_graph.py
CHANGED
|
@@ -91,6 +91,40 @@ def test_build_react_agent_prints_effective_recursion_limit(monkeypatch, tmp_pat
|
|
| 91 |
assert "[graph] effective_recursion_limit=79 logical_recursion_limit=50 budget_hard_cap=25 headroom=4" in captured
|
| 92 |
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
def test_fail_safe_uses_unbound_model_to_prevent_more_tool_calls(monkeypatch, tmp_path):
|
| 95 |
class FakeBoundModel:
|
| 96 |
def __init__(self):
|
|
|
|
| 91 |
assert "[graph] effective_recursion_limit=79 logical_recursion_limit=50 budget_hard_cap=25 headroom=4" in captured
|
| 92 |
|
| 93 |
|
| 94 |
+
def test_model_prompt_includes_youtube_fallback_strategy(monkeypatch, tmp_path):
|
| 95 |
+
class FakeModel:
|
| 96 |
+
def __init__(self):
|
| 97 |
+
self.system_prompt = ""
|
| 98 |
+
|
| 99 |
+
def bind_tools(self, tools):
|
| 100 |
+
return self
|
| 101 |
+
|
| 102 |
+
def invoke(self, messages):
|
| 103 |
+
self.system_prompt = str(messages[0].content)
|
| 104 |
+
return AIMessage(content="Final Answer: inspected")
|
| 105 |
+
|
| 106 |
+
fake_model = FakeModel()
|
| 107 |
+
cfg = Config.from_env()
|
| 108 |
+
cfg.compact_summarize = False
|
| 109 |
+
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
|
| 110 |
+
monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: fake_model)
|
| 111 |
+
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
|
| 112 |
+
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
|
| 113 |
+
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
|
| 114 |
+
|
| 115 |
+
graph = build_react_agent(cfg)
|
| 116 |
+
graph.invoke(
|
| 117 |
+
{"messages": [HumanMessage(content="What happens in https://www.youtube.com/watch?v=abcdefghijk?")], "iterations": 0, "todos": []},
|
| 118 |
+
{"configurable": {"thread_id": "youtube-fallback-prompt-test"}},
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
prompt = fake_model.system_prompt.lower()
|
| 122 |
+
assert "youtube fallback strategy" in prompt
|
| 123 |
+
assert "video id" in prompt
|
| 124 |
+
assert "transcript" in prompt
|
| 125 |
+
assert "do not repeatedly retry" in prompt
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def test_fail_safe_uses_unbound_model_to_prevent_more_tool_calls(monkeypatch, tmp_path):
|
| 129 |
class FakeBoundModel:
|
| 130 |
def __init__(self):
|
test_memory_pick_up.py → tests/test_memory_pick_up.py
RENAMED
|
File without changes
|
tests/test_python_sandbox.py
CHANGED
|
@@ -75,6 +75,18 @@ def test_allowlisted_env_passes_through():
|
|
| 75 |
assert "HAS_PATH=True" in result
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def test_cwd_is_not_repo_root():
|
| 79 |
"""Subprocess runs in a scratch dir so relative-path writes cannot hit the repo."""
|
| 80 |
repo_root = str(Path(__file__).resolve().parent.parent)
|
|
|
|
| 75 |
assert "HAS_PATH=True" in result
|
| 76 |
|
| 77 |
|
| 78 |
+
def test_blas_thread_env_defaults_are_forced_in_sandbox(monkeypatch):
|
| 79 |
+
from lilith_agent.tools import python_exec as pe
|
| 80 |
+
|
| 81 |
+
for key in pe.BLAS_THREAD_ENV_DEFAULTS:
|
| 82 |
+
monkeypatch.delenv(key, raising=False)
|
| 83 |
+
|
| 84 |
+
env = pe._scrubbed_env()
|
| 85 |
+
|
| 86 |
+
for key, value in pe.BLAS_THREAD_ENV_DEFAULTS.items():
|
| 87 |
+
assert env[key] == value
|
| 88 |
+
|
| 89 |
+
|
| 90 |
def test_cwd_is_not_repo_root():
|
| 91 |
"""Subprocess runs in a scratch dir so relative-path writes cannot hit the repo."""
|
| 92 |
repo_root = str(Path(__file__).resolve().parent.parent)
|
tests/test_runtime_env.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_apply_safe_thread_env_overrides_existing_values(monkeypatch):
|
| 5 |
+
from lilith_agent.runtime_env import SAFE_THREAD_ENV, apply_safe_thread_env
|
| 6 |
+
|
| 7 |
+
for key in SAFE_THREAD_ENV:
|
| 8 |
+
monkeypatch.setenv(key, "99")
|
| 9 |
+
|
| 10 |
+
apply_safe_thread_env()
|
| 11 |
+
|
| 12 |
+
for key, value in SAFE_THREAD_ENV.items():
|
| 13 |
+
assert __import__("os").environ[key] == value
|
tests/test_scoring_client.py
CHANGED
|
@@ -99,6 +99,67 @@ class ScoringApiClientTests(unittest.TestCase):
|
|
| 99 |
self.assertIn("429", client.last_warning or "")
|
| 100 |
dataset_client.download_file.assert_called_once_with("task-123", Path(tmpdir))
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def test_default_dataset_client_is_built_lazily(self) -> None:
|
| 103 |
session = Mock()
|
| 104 |
response = Mock(status_code=429, headers={})
|
|
|
|
| 99 |
self.assertIn("429", client.last_warning or "")
|
| 100 |
dataset_client.download_file.assert_called_once_with("task-123", Path(tmpdir))
|
| 101 |
|
| 102 |
+
def test_download_file_falls_back_to_dataset_on_404(self) -> None:
|
| 103 |
+
response = Mock(status_code=404, headers={})
|
| 104 |
+
error = requests.HTTPError("Not Found", response=response)
|
| 105 |
+
response.raise_for_status.side_effect = error
|
| 106 |
+
|
| 107 |
+
session = Mock()
|
| 108 |
+
session.get.return_value = response
|
| 109 |
+
dataset_client = Mock()
|
| 110 |
+
|
| 111 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 112 |
+
fallback_path = Path(tmpdir) / "from-dataset.png"
|
| 113 |
+
fallback_path.write_bytes(b"image-bytes")
|
| 114 |
+
dataset_client.download_file.return_value = fallback_path
|
| 115 |
+
|
| 116 |
+
client = ScoringApiClient(
|
| 117 |
+
api_url="https://example.com",
|
| 118 |
+
session=session,
|
| 119 |
+
dataset_client=dataset_client,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
with patch("builtins.print") as printed:
|
| 123 |
+
payload = client.download_file("task-404", tmpdir)
|
| 124 |
+
|
| 125 |
+
self.assertEqual(payload, fallback_path)
|
| 126 |
+
self.assertIn("404", client.last_warning or "")
|
| 127 |
+
dataset_client.download_file.assert_called_once_with("task-404", Path(tmpdir))
|
| 128 |
+
printed.assert_any_call(
|
| 129 |
+
"Scoring API unavailable while trying to download file for task-404 (status=404); falling back to GAIA dataset.",
|
| 130 |
+
flush=True,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
def test_download_file_falls_back_when_api_returns_tiny_json_payload(self) -> None:
|
| 134 |
+
response = Mock(status_code=200, headers={"content-type": "application/json"})
|
| 135 |
+
response.content = b'{"detail":"file not found"}'
|
| 136 |
+
response.raise_for_status.return_value = None
|
| 137 |
+
|
| 138 |
+
session = Mock()
|
| 139 |
+
session.get.return_value = response
|
| 140 |
+
dataset_client = Mock()
|
| 141 |
+
|
| 142 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 143 |
+
fallback_path = Path(tmpdir) / "from-dataset.png"
|
| 144 |
+
fallback_path.write_bytes(b"image-bytes")
|
| 145 |
+
dataset_client.download_file.return_value = fallback_path
|
| 146 |
+
|
| 147 |
+
client = ScoringApiClient(
|
| 148 |
+
api_url="https://example.com",
|
| 149 |
+
session=session,
|
| 150 |
+
dataset_client=dataset_client,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
with patch("builtins.print") as printed:
|
| 154 |
+
payload = client.download_file("task-json", tmpdir)
|
| 155 |
+
|
| 156 |
+
self.assertEqual(payload, fallback_path)
|
| 157 |
+
dataset_client.download_file.assert_called_once_with("task-json", Path(tmpdir))
|
| 158 |
+
printed.assert_any_call(
|
| 159 |
+
"[scoring] invalid file payload task=task-json status=200 bytes=27 content_type='application/json'; trying dataset fallback",
|
| 160 |
+
flush=True,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
def test_default_dataset_client_is_built_lazily(self) -> None:
|
| 164 |
session = Mock()
|
| 165 |
response = Mock(status_code=429, headers={})
|