diff --git a/src/fi/alk/harness/HOW-IT-WORKS.md b/src/fi/alk/harness/HOW-IT-WORKS.md index 6e40b22e8..a267bf261 100644 --- a/src/fi/alk/harness/HOW-IT-WORKS.md +++ b/src/fi/alk/harness/HOW-IT-WORKS.md @@ -83,7 +83,7 @@ computer-use tool the code check found in the source. So does a chat or voice re where the check found one. The next submission stands: a browser answer is refused to `failure.json` and the build exits 78, and a chat or voice answer is saved. The look gives a value to send back, which only tells apart calls in one model message: those run before the model reads -any result. The rule and its state are `SecondLook` in `agent_type_check.py`. A browser answer to a chat or voice look is refused at once, since that look said browser stops the build. When calls in one message run one after another, or together once the first scan is done, a later call is taken as the answer before the model has read the look. +any result. The rule and its state are `SecondLook` in `agent_type_check.py`. A browser answer to a chat or voice look is refused at once, since that look said browser stops the build. When calls in one message run one after another, or together once the first scan is done, a later call is taken as the answer before the model has read the look. Once an answer with the value has come back, a later browser answer without it gets a look again, so answers that keep switching cost turns, up to the reading's turn limit (70, or 140 for a generic job read unattended), never a refusal without a look. **How it is changed later** (`amend.py`). The contract is not frozen, but every change is recorded with a reason in `amendments[]`, so months later you can still tell what came from the diff --git a/src/fi/alk/harness/agent_type_check.py b/src/fi/alk/harness/agent_type_check.py index 1679bb511..e8de3a1bd 100644 --- a/src/fi/alk/harness/agent_type_check.py +++ b/src/fi/alk/harness/agent_type_check.py @@ -283,11 +283,21 @@ def _not_the_agent_file(name: str) -> bool: return any(fnmatch.fnmatchcase(lowered, pattern) for pattern in _TEST_FILES) -def _unpacked_at_top(root: Path, left: list[int] | None = None) -> set[str]: +def _unpacked_at_top( + root: Path, + left: list[int] | None = None, + read: dict[tuple[tuple[str, int], ...], frozenset[str]] | None = None, +) -> set[str] | frozenset[str]: """The files ``pip install -t .`` put beside the agent's code, by each distribution's record. Exact paths: one distribution shipping ``tools/x.py`` says nothing about the customer's own ``tools/``. ``left`` is the record bytes still to read, shared across one walk's folders. + ``read`` holds, for one walk, the rows of each folder read whole, keyed by its records' names + and sizes: a folder holding exactly those records again, as in one Lambda folder after + another, shares them, unread, for an eighth of each record's size, at least 4 KiB each. + Shared, so never changed. Every ``_agent_files`` walk shares, the agent folder's own walk in + ``browser_tools_in_agent`` too; only its ``rows_of``, the records at and above the agent, + doesn't. """ names: set[str] = set() try: @@ -296,7 +306,15 @@ def _unpacked_at_top(root: Path, left: list[int] | None = None) -> set[str]: return names if left is None: left = [_LARGEST_RECORDS] + sized: list[tuple[str, Path, int]] = [] + whole = True for entry in records: + # Each record costs at least a block, read or shared, so a folder of more than are left + # can't be read whole, and its key could never be stored or matched. A spent budget stops + # here, before any record is looked at. + if len(sized) * _SMALLEST_RECORD > left[0]: + whole = False + break try: record = entry / "RECORD" # pip writes neither as a link, and following them reads one record once per link. @@ -305,14 +323,30 @@ def _unpacked_at_top(root: Path, left: list[int] | None = None) -> set[str]: # A link to a device or a named pipe reports no size, and reading it never ends. if not record.is_file() or record.stat().st_size > _LARGEST_RECORD: continue - left[0] -= max(record.stat().st_size, _SMALLEST_RECORD) + sized.append((entry.name, record, record.stat().st_size)) + except OSError: + continue + key = tuple((name, size) for name, _, size in sized) + shared = read.get(key) if read is not None and whole and key else None + if shared is not None: + left[0] -= sum(max(size // 8, _SMALLEST_RECORD) for _, _, size in sized) + return shared if left[0] >= 0 else names + for _, record, size in sized: + try: + left[0] -= max(size, _SMALLEST_RECORD) if left[0] < 0: + whole = False break names.update( row[0] for row in csv.reader(io.StringIO(record.read_text(encoding="utf-8"))) if row ) except (OSError, ValueError, csv.Error): + # Rows left out: another folder with this key must read its own. + whole = False continue + if read is not None and whole and key: + read[key] = frozenset(names) + return read[key] return names @@ -326,10 +360,11 @@ def _agent_files( # records hold them, as pip wrote them, so what is kept is the rows the budget lets in and that # folder's path once: how deep the folder sits doesn't multiply what each row costs. # ``at_top`` gets the rows of the records at the top, for a caller that would read them again. - unpacked: dict[str, set[str]] = {} + unpacked: dict[str, set[str] | frozenset[str]] = {} # The folders above this one whose records gave rows, nearest last. The walk goes top down, so # leaving a folder is seeing a directory that isn't under it. holding: list[str] = [] + read: dict[tuple[tuple[str, int], ...], frozenset[str]] = {} if left is None: left = [_LARGEST_RECORDS] top = os.fspath(root) @@ -337,7 +372,7 @@ def _agent_files( while holding and not directory.startswith(holding[-1] + os.sep): del unpacked[holding.pop()] if any(name.endswith(".dist-info") for name in subdirectories): - rows = _unpacked_at_top(Path(directory), left) + rows = _unpacked_at_top(Path(directory), left, read) if rows: unpacked[directory] = rows holding.append(directory) @@ -401,7 +436,7 @@ def _largest_read(path: Path) -> int: return _LARGEST_CODE_FILE -def _modules_at_top(root: Path, unpacked: set[str] | None = None) -> set[str]: +def _modules_at_top(root: Path, unpacked: set[str] | frozenset[str] | None = None) -> set[str]: """What sits ahead of an installed package on the import path, so isn't that package. ``unpacked`` is the folder's record rows, when the caller has read them already. @@ -442,7 +477,7 @@ def _python_markers(text: str, tokens: TokenBudget, path: Path) -> set[str]: return set() try: found = tokens.tokens(text) - except (ValueError, SyntaxError, tokenize.TokenError) as error: + except (ValueError, SyntaxError, SystemError, tokenize.TokenError) as error: # The type only: the error can quote a line of the customer's file. logger.debug("agent type scan read no markers from %s: %s", path, type(error).__name__) return set() @@ -504,8 +539,10 @@ def _signature(root: Path) -> tuple[int, int, int]: Every reopen asks, so records are looked at, not read: a record that starts or stops naming a file changes its own size or time. Files a record names count too, which only rescans more. + The times of every file read are summed, so one file dated ahead can't pin the key; the change + time is in the sum because a copy that puts a modified time back still moves it. """ - latest = count = size = 0 + times = count = size = 0 for directory, subdirectories, files in os.walk(root): subdirectories[:] = [ name for name in subdirectories if not _not_the_agent_dir(name, directory) @@ -518,10 +555,10 @@ def _signature(root: Path) -> tuple[int, int, int]: info = path.stat() except OSError: continue - latest = max(latest, info.st_mtime_ns) + times += info.st_mtime_ns + info.st_ctime_ns count += 1 size += info.st_size - return latest, count, size + return times, count, size @lru_cache(maxsize=8) @@ -549,6 +586,7 @@ def _tools_in( tokens = TokenBudget() escapes = EscapeBudget() searched = ParseBudget(_LARGEST_RAW_SEARCH) + skipped = 0 for path in sorted( filter(_is_read, paths), key=lambda path: (first is not None and not is_within(path, first), *_reading_order(path)), @@ -568,8 +606,8 @@ def _tools_in( python += size if python > _LARGEST_PYTHON_SCAN: continue - # One file at a time: the tokenizer raises more than it documents (SystemError on a NUL - # byte in 3.12), and one odd file mustn't stop every reading from being checked. + # The tokenizer's known faults are caught in `_python_markers`; this catch is for the ones + # nobody foresaw. try: if path.name == "package.json": found |= _packages_used(path) @@ -600,10 +638,17 @@ def _tools_in( named = {name for name in named if _WHOLE_NAMES[name].search(raw)} found |= _markers(raw) imported |= named - except Exception: - # A skipped file may be the agent's main one, and the reading then finds nothing. - logger.warning("agent type scan skipped %s", path, exc_info=True) + except Exception as error: + # A skipped file may be the agent's main one, and the reading then finds nothing. The + # type only at WARNING, once a scan: a trace can quote a line of the customer's code, + # and a path can name their business. + skipped += 1 + if skipped == 1: + logger.warning("agent type scan skipped a file: %s", type(error).__name__) + logger.debug("agent type scan skipped %s", path, exc_info=True) continue + if skipped > 1: + logger.warning("agent type scan skipped %d files", skipped) for name in imported: top = name.split(".")[0] if top in local: diff --git a/tests/harness/test_agent_type_refusal.py b/tests/harness/test_agent_type_refusal.py index 97d3cf884..a7f4f90d5 100644 --- a/tests/harness/test_agent_type_refusal.py +++ b/tests/harness/test_agent_type_refusal.py @@ -452,6 +452,17 @@ def test_a_test_named_folder_with_more_than_tests_is_read(tmp_path, files): assert browser_tools_found(tmp_path) == ["playwright"] +@pytest.mark.parametrize( + "inner", ["agent/core/run.py", "ui_tests/core/run.py"], ids=["folder", "test-word-folder"] +) +def test_a_folder_holding_only_a_folder_inside_a_test_named_folder_is_read(tmp_path, inner): + _tree( + tmp_path, + {"qa_e2e/test_flow.py": "def test_x():\n pass\n", f"qa_e2e/{inner}": "import playwright\n"}, + ) + assert browser_tools_found(tmp_path) == ["playwright"] + + def test_tools_found_skips_an_upper_case_test_file(tmp_path): (tmp_path / "Test_A.py").write_text("import playwright\n", encoding="utf-8") assert browser_tools_found(tmp_path) == [] @@ -603,8 +614,37 @@ def odd(path, *args, **kwargs): with caplog.at_level("WARNING", logger=agent_type_check.__name__): assert browser_tools_found(tmp_path) == ["playwright"] [record] = caplog.records - assert "a_odd.py" in record.getMessage() - assert record.exc_info[0] is LookupError + assert record.getMessage() == "agent type scan skipped a file: LookupError" + assert record.exc_info is None + + +def test_a_skip_logs_its_type_once_and_its_path_and_trace_at_debug_only( + tmp_path, monkeypatch, caplog +): + _tree(tmp_path, {"a_odd.py": "x = 1\n", "b_odd.py": "x = 1\n", "agent.py": "import playwright\n"}) + real = agent_type_check.read_module + + def odd(path, *args, **kwargs): + if "odd" in path.name: + raise LookupError("SOURCE-LINE") + return real(path, *args, **kwargs) + + monkeypatch.setattr(agent_type_check, "read_module", odd) + with caplog.at_level("DEBUG", logger=agent_type_check.__name__): + assert browser_tools_found(tmp_path) == ["playwright"] + loud = [record for record in caplog.records if record.levelno >= 20] + assert [record.getMessage() for record in loud] == [ + "agent type scan skipped a file: LookupError", + "agent type scan skipped 2 files", + ] + assert not any( + "SOURCE-LINE" in record.getMessage() or "odd" in record.getMessage() or record.exc_info + for record in loud + ) + quiet = [record for record in caplog.records if record.levelno < 20 and record.exc_info] + assert [record.exc_info[0] for record in quiet] == [LookupError, LookupError] + assert "a_odd.py" in quiet[0].getMessage() + assert "b_odd.py" in quiet[1].getMessage() @pytest.mark.skipif(sys.version_info < (3, 12), reason="3.11's tokenizer reads a NUL byte") @@ -618,6 +658,17 @@ def test_a_file_with_a_nul_byte_is_left_unread_without_stopping_the_scan(tmp_pat assert caplog.records == [] +@pytest.mark.skipif(sys.version_info < (3, 12), reason="3.11's tokenizer reads a NUL byte") +def test_a_nul_byte_the_tokenizer_breaks_on_reaches_no_warning(tmp_path, caplog): + for n in range(30): + (tmp_path / f"a{n:02}.py").write_bytes(b'# computer_use\n x = 1\nKEY = "SOURCE-LINE"\x00\n') + (tmp_path / "agent.py").write_text("import playwright\n", encoding="utf-8") + with caplog.at_level("DEBUG", logger=agent_type_check.__name__): + assert browser_tools_found(tmp_path) == ["playwright"] + assert [record for record in caplog.records if record.levelno >= 20] == [] + assert "SOURCE-LINE" not in caplog.text + + def test_a_python_file_with_a_coding_line_keeps_its_marker(tmp_path): (tmp_path / "agent.py").write_bytes( b"# -*- coding: latin-1 -*-\nimport playwright\nNAME = '\xe9'\n" @@ -871,6 +922,20 @@ def test_past_the_raw_search_total_a_name_counts_wherever_it_appears(tmp_path, m assert browser_tools_found(tmp_path) == ["steel"] +@pytest.mark.parametrize( + "line, expected", + [ + (b"import playwright", ["playwright"]), + (b'NAME = "computer_20241022"', ["computer-use tool"]), + (b"from langchain_community import PlayWrightBrowserToolkit", ["PlayWrightBrowserToolkit"]), + ], + ids=["import", "marker", "toolkit"], +) +def test_a_refused_codec_file_is_searched_whole(tmp_path, line, expected): + (tmp_path / "a.py").write_bytes(b"# coding: idna\n" + b"#" * 70_000 + b"\n" + line + b"\n") + assert browser_tools_found(tmp_path) == expected + + @pytest.mark.parametrize( "source", [ @@ -1207,6 +1272,230 @@ def test_every_folder_shares_one_record_budget(tmp_path, monkeypatch): assert [path.name for path in files].count("vendored.py") == 1 +_RECORD = "".join(f"pkg/m{n:04}.py,sha256=a,1\n" for n in range(400)) + + +def _same_three(root: Path) -> None: + for folder in ("fn0", "fn1", "fn2"): + _tree( + root, + { + f"{folder}/pkg-1.0.dist-info/RECORD": _RECORD, + f"{folder}/pkg/m0000.py": 'NAME = "computer_20241022"\n', + f"{folder}/handler.py": "import os\n", + }, + ) + + +def test_a_folder_holding_the_same_records_again_costs_an_eighth(tmp_path, monkeypatch): + _same_three(tmp_path) + monkeypatch.setattr( + agent_type_check, + "_LARGEST_RECORDS", + len(_RECORD.encode()) + 2 * agent_type_check._SMALLEST_RECORD, + ) + assert browser_tools_found(tmp_path) == [] + + +def test_a_folder_holding_the_same_records_again_does_not_read_them(tmp_path, monkeypatch): + _same_three(tmp_path) + read = _records_read(monkeypatch) + assert browser_tools_found(tmp_path) == [] + assert sum(read.values()) == 1 + + +def test_folders_holding_the_same_records_share_one_set_of_rows(tmp_path, monkeypatch): + _same_three(tmp_path) + got: list[set[str] | frozenset[str]] = [] + real = agent_type_check._unpacked_at_top + monkeypatch.setattr( + agent_type_check, "_unpacked_at_top", lambda *a, **k: got.append(real(*a, **k)) or got[-1] + ) + browser_tools_found(tmp_path) + assert len(got) == 3 + assert got[1] is got[0] + assert got[2] is got[0] + assert isinstance(got[0], frozenset) + + +def test_the_same_records_nested_deep_cost_the_memory_of_one(tmp_path): + import tracemalloc + + record = "".join(f"r{n:06}\n" for n in range(32_768)) + + def nested(root: Path, depth: int) -> Path: + folder = root + for _ in range(depth): + folder = folder / "d" + _tree(folder, {"pkg-1.0.dist-info/RECORD": record}) + return root + + def peak(root: Path) -> int: + agent_type_check._scan.cache_clear() + tracemalloc.start() + try: + browser_tools_found(root) + return tracemalloc.get_traced_memory()[1] + finally: + tracemalloc.stop() + + one = peak(nested(tmp_path / "one", 1)) + many = peak(nested(tmp_path / "many", 40)) + assert many < 2 * one, (one, many) + + +def test_a_record_of_another_name_and_the_same_size_is_read_on_its_own(tmp_path): + _tree( + tmp_path, + { + "a/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", + "a/pkg/x.py": "x = 1\n", + "b/qkg-1.0.dist-info/RECORD": "qkg/y.py,,\n", + "b/pkg/x.py": 'NAME = "computer_20241022"\n', + "b/qkg/y.py": "x = 1\n", + }, + ) + assert browser_tools_found(tmp_path) == ["computer-use tool"] + + +def test_a_record_of_the_same_name_and_another_size_is_read_on_its_own(tmp_path): + _tree( + tmp_path, + { + "a/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", + "a/pkg/x.py": "x = 1\n", + "b/pkg-1.0.dist-info/RECORD": "pkg/yy.py,,\n", + "b/pkg/x.py": 'NAME = "computer_20241022"\n', + "b/pkg/yy.py": "x = 1\n", + }, + ) + assert browser_tools_found(tmp_path) == ["computer-use tool"] + + +@pytest.mark.parametrize( + "extra, expected", + [("b", []), ("a", ["computer-use tool"])], + ids=["later-folder-has-one-more", "later-folder-has-one-fewer"], +) +def test_a_folder_holding_one_package_more_or_fewer_is_read_on_its_own(tmp_path, extra, expected): + # Only b holds qkg/y.py: skipped where b's own records name it, read where they don't. + _tree( + tmp_path, + { + "a/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", + "b/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", + f"{extra}/qkg-1.0.dist-info/RECORD": "qkg/y.py,,\n", + "b/qkg/y.py": 'NAME = "computer_20241022"\n', + }, + ) + assert browser_tools_found(tmp_path) == expected + + +def test_a_folder_whose_record_could_not_be_read_is_not_shared(tmp_path): + good = "pkg/x.py,,\n" + _tree( + tmp_path, + { + "a/ok-1.0.dist-info/RECORD": "ok/z.py,,\n", + "b/ok-1.0.dist-info/RECORD": "ok/z.py,,\n", + "b/pkg-1.0.dist-info/RECORD": good, + "b/pkg/x.py": 'NAME = "computer_20241022"\n', + }, + ) + (tmp_path / "a/pkg-1.0.dist-info").mkdir() + (tmp_path / "a/pkg-1.0.dist-info/RECORD").write_bytes(b"\xff" * len(good)) + assert browser_tools_found(tmp_path) == [] + + +def _records_looked_at(monkeypatch) -> list[Path]: + seen: list[Path] = [] + real = Path.is_symlink + + def looked(self): + if self.name == "RECORD": + seen.append(self) + return real(self) + + monkeypatch.setattr(Path, "is_symlink", looked) + return seen + + +def test_once_the_record_budget_is_spent_no_record_is_looked_at(tmp_path, monkeypatch): + _tree( + tmp_path, + { + "a/x-1.dist-info/RECORD": "", + "b/y-1.dist-info/RECORD": "", + **{f"c/z{n:02}-1.dist-info/RECORD": "" for n in range(20)}, + }, + ) + monkeypatch.setattr(agent_type_check, "_LARGEST_RECORDS", agent_type_check._SMALLEST_RECORD) + seen = _records_looked_at(monkeypatch) + browser_tools_found(tmp_path) + assert seen + assert not [path for path in seen if path.parent.parent.name == "c"] + + +def test_a_folder_of_more_records_than_are_left_is_looked_at_only_so_far(tmp_path, monkeypatch): + _tree(tmp_path, {f"z{n:02}-1.dist-info/RECORD": "" for n in range(20)}) + monkeypatch.setattr( + agent_type_check, "_LARGEST_RECORDS", 2 * agent_type_check._SMALLEST_RECORD + ) + seen = _records_looked_at(monkeypatch) + browser_tools_found(tmp_path) + assert len(seen) == 3 + + +def test_a_folder_the_budget_cuts_short_is_not_shared(tmp_path, monkeypatch): + # c holds a's two records and one more; the budget runs out inside c, so c reads what it can + # of its own and shares nothing. + _tree( + tmp_path, + { + "a/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", + "a/qkg-1.0.dist-info/RECORD": "qkg/y.py,,\n", + "c/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", + "c/qkg-1.0.dist-info/RECORD": "qkg/y.py,,\n", + "c/zkg-1.0.dist-info/RECORD": "zkg/z.py,,\n", + "c/pkg/x.py": 'NAME = "computer_20241022"\n', + }, + ) + monkeypatch.setattr( + agent_type_check, "_LARGEST_RECORDS", 3 * agent_type_check._SMALLEST_RECORD + ) + assert browser_tools_found(tmp_path) == [] + + +def test_a_nested_folders_records_dont_take_the_tops_own_modules(tmp_path): + _tree( + tmp_path, + { + "steel.py": "x = 1\n", + "agent.py": "import steel\n", + "lambda/x-1.0.dist-info/RECORD": "steel.py,,\n", + "lambda/steel.py": "x = 1\n", + }, + ) + assert browser_tools_found(tmp_path) == [] + + +def _sixteen_mib_of_records(folder: Path) -> None: + row = "p/" + "x" * 1000 + ".py,,\n" + body = (row * (1024 * 1024 // len(row) + 1))[: 1024 * 1024 - 1] + "\n" + assert len(body) == agent_type_check._LARGEST_RECORD + for n in range(16): + _tree(folder, {f"r{n:02}-1.0.dist-info/RECORD": body}) + + +def test_records_past_sixteen_mib_are_not_read(tmp_path): + _sixteen_mib_of_records(tmp_path / "a") + _tree( + tmp_path, + {"b/pkg-1.0.dist-info/RECORD": "pkg/x.py,,\n", "b/pkg/x.py": 'NAME = "computer_20241022"\n'}, + ) + assert browser_tools_found(tmp_path) == ["computer-use tool"] + + def test_a_deep_folders_records_cost_what_their_rows_do(tmp_path, monkeypatch): import tracemalloc @@ -1412,7 +1701,8 @@ def test_an_edit_outside_python_is_scanned_again(tmp_path, name, before, after): def test_one_file_fewer_is_scanned_again(tmp_path): - # The same total size and latest time, so only the count says it changed. + # The same total size and modified times: the count and the deleted file's change time say it + # changed. _tree(tmp_path, {"a.py": "import openai\n", "b.py": "import scrapy\n"}) for name in ("a.py", "b.py"): os.utime(tmp_path / name, ns=(1, 1)) @@ -1430,6 +1720,69 @@ def test_removing_a_nested_helper_is_scanned_again(tmp_path): assert browser_tools_found(tmp_path) == [] +def _rewrite_until_ctime_moves( + path: Path, text: str, before: os.stat_result, keep_mtime: bool +) -> os.stat_result: + # Two writes in one clock tick can share a change time, as on Linux, or a filesystem with + # 1 s times: write again until it moves, for at most 2 s. + deadline = time.monotonic() + 2 + while True: + path.write_text(text, encoding="utf-8") + if keep_mtime: + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + after = path.stat() + if after.st_ctime_ns != before.st_ctime_ns or time.monotonic() > deadline: + break + time.sleep(0.005) + assert ( + after.st_ctime_ns != before.st_ctime_ns + ), "the clock didn't move in 2 s: the test machine, not the scan" + return after + + +def test_a_record_rewritten_with_its_size_and_time_put_back_is_scanned_again(tmp_path): + _tree( + tmp_path, + {"x-1.dist-info/RECORD": "vendored.py,,\n", "vendored.py": 'NAME = "computer_20241022"\n'}, + ) + record = tmp_path / "x-1.dist-info/RECORD" + assert browser_tools_found(tmp_path) == [] + before = record.stat() + after = _rewrite_until_ctime_moves(record, "otherxdd.py,,\n", before, keep_mtime=True) + assert (after.st_size, after.st_mtime_ns) == (before.st_size, before.st_mtime_ns) + assert browser_tools_found(tmp_path) == ["computer-use tool"] + + +def test_an_edit_beside_a_file_dated_ahead_is_scanned_again(tmp_path): + _tree(tmp_path, {"agent.py": "#mport playwright\n", "util.py": "x = 1\n"}) + os.utime(tmp_path / "util.py", ns=(2**62, 2**62)) + assert browser_tools_found(tmp_path) == [] + agent = tmp_path / "agent.py" + _rewrite_until_ctime_moves(agent, "import playwright\n", agent.stat(), keep_mtime=False) + assert browser_tools_found(tmp_path) == ["playwright"] + + +def test_the_scan_key_is_each_files_times_summed_with_the_count_and_size(tmp_path): + _tree( + tmp_path, + { + "a.py": "x = 1\n", + "src/b.ts": "let b = 2;\n", + "x-1.dist-info/RECORD": "a.py,,\n", + "test_c.py": "x = 3\n", + "notes.txt": "n\n", + }, + ) + os.utime(tmp_path / "a.py", ns=(2**62, 2**62)) + # The test file and the .txt aren't read, so they aren't in the key. + stats = [(tmp_path / name).stat() for name in ("a.py", "src/b.ts", "x-1.dist-info/RECORD")] + assert agent_type_check._signature(tmp_path) == ( + sum(info.st_mtime_ns + info.st_ctime_ns for info in stats), + 3, + sum(info.st_size for info in stats), + ) + + def test_a_customers_other_package_names_never_appear(tmp_path): _tree( tmp_path, @@ -3460,6 +3813,30 @@ def test_the_scan_searches_python_only_up_to_its_own_total(tmp_path, monkeypatch assert browser_tools_found(repo) == expected +def test_a_refused_codec_file_past_the_python_total_is_not_read(tmp_path, monkeypatch): + body = b"# coding: idna\n" + b"import os # omputer\n" * 50 + for n in range(5): + (tmp_path / f"m{n}.py").write_bytes(body) + monkeypatch.setattr(agent_type_check, "_LARGEST_PYTHON_SCAN", 2 * len(body)) + read: list[str] = [] + real = agent_type_check.read_module + monkeypatch.setattr( + agent_type_check, + "read_module", + lambda path, *a, **k: read.append(path.name) or real(path, *a, **k), + ) + browser_tools_found(tmp_path) + assert read == ["m0.py", "m1.py"] + + +def test_python_past_thirty_two_mib_is_not_read(tmp_path): + body = b"#" * (agent_type_check.LARGEST_PARSED_MODULE - 1) + b"\n" + for n in range(32 * 1024 * 1024 // len(body)): + (tmp_path / f"a{n:03}.py").write_bytes(body) + (tmp_path / "zz_agent.py").write_text("import playwright\n", encoding="utf-8") + assert browser_tools_found(tmp_path) == [] + + def test_a_long_run_of_combining_marks_is_not_parsed(): from fi.alk.harness.python_source import parsed @@ -3722,20 +4099,33 @@ def test_the_folders_at_and_above_the_agent_share_one_record_budget( assert browser_tools_in_agent(repo, "agents/a") == expected +def test_records_at_and_above_an_agent_past_sixteen_mib_are_not_read(tmp_path): + _sixteen_mib_of_records(tmp_path / "agent") + _tree( + tmp_path, + { + "agent/run.py": "import steel\n", + "agent/steel.py": "x = 1\n", + "z-1.0.dist-info/RECORD": "z.py,,\n", + }, + ) + assert browser_tools_in_agent(tmp_path, "agent") == ["steel"] + + def test_each_folder_above_the_agent_has_its_records_read_once(tmp_path, monkeypatch): repo = _vendored_above(tmp_path, 1) - read: list[Path] = [] + looked: list[Path] = [] real = agent_type_check._unpacked_at_top monkeypatch.setattr( agent_type_check, "_unpacked_at_top", - lambda root, left=None: read.append(Path(root)) or real(root, left), + lambda root, left=None, read=None: looked.append(Path(root)) or real(root, left, read), ) assert browser_tools_in_agent(repo, "agents/a") == [] - assert read.count(repo.resolve()) == 1 + assert looked.count(repo.resolve()) == 1 # The agent's folder: once here, and once in its own walk's look at its packages. - assert read.count((repo / "agents" / "a").resolve()) == 2 + assert looked.count((repo / "agents" / "a").resolve()) == 2 @pytest.mark.parametrize(