tenseleyflow/loader / 74d4b92

Browse files

Scope observation dedup resets

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
74d4b92177df30b50c4f522ae7996b4c12aa2658
Parents
b7f9843
Tree
de5edb0

3 changed files

StatusFile+-
M src/loader/runtime/safeguard_services.py 210 27
M tests/test_safeguard_services.py 52 1
M tests/test_tool_batches.py 7 7
src/loader/runtime/safeguard_services.pymodified
@@ -193,9 +193,10 @@ class ActionTracker:
193193
         self._response_history: list[str] = []
194194
         self._action_index = 0
195195
         self._mutation_epoch = 0
196
-        self._recent_reads: dict[str, tuple[int, int, int]] = {}
197
-        self._recent_searches: dict[str, tuple[int, int, int]] = {}
198
-        self._recent_bash_observations: dict[str, tuple[int, int, int]] = {}
196
+        self._mutation_records: list[_MutationRecord] = []
197
+        self._recent_reads: dict[str, _ObservationRecord] = {}
198
+        self._recent_searches: dict[str, _ObservationRecord] = {}
199
+        self._recent_bash_observations: dict[str, _ObservationRecord] = {}
199200
         self._recent_path_contexts: list[str] = []
200201
 
201202
     def reset(self) -> None:
@@ -207,6 +208,7 @@ class ActionTracker:
207208
         self._response_history.clear()
208209
         self._action_index = 0
209210
         self._mutation_epoch = 0
211
+        self._mutation_records.clear()
210212
         self._recent_reads.clear()
211213
         self._recent_searches.clear()
212214
         self._recent_bash_observations.clear()
@@ -313,10 +315,11 @@ class ActionTracker:
313315
                     (
314316
                         "Already read "
315317
                         f"{str(arguments.get('file_path', '')).strip()} "
316
-                        "recently without any intervening changes; "
318
+                        "recently without any relevant intervening changes; "
317319
                         "reuse the earlier read result instead of rereading"
318320
                     ),
319321
                     repeat_threshold=self.READ_REPEAT_THRESHOLD,
322
+                    target_paths=self._read_target_paths(arguments),
320323
                 )
321324
                 if duplicate:
322325
                     return True, reason
@@ -328,10 +331,12 @@ class ActionTracker:
328331
                     self._recent_searches,
329332
                     observation_key,
330333
                     (
331
-                        "Already ran the same search recently without any intervening "
332
-                        "changes; reuse the earlier search result instead of rerunning it"
334
+                        "Already ran the same search recently without any relevant "
335
+                        "intervening changes; reuse the earlier search result instead of "
336
+                        "rerunning it"
333337
                     ),
334338
                     repeat_threshold=self.SEARCH_REPEAT_THRESHOLD,
339
+                    target_paths=self._search_target_paths(arguments),
335340
                 )
336341
                 if duplicate:
337342
                     return True, reason
@@ -344,9 +349,11 @@ class ActionTracker:
344349
                     self._normalize_command(command),
345350
                     (
346351
                         "Already ran the same read-only shell probe recently without any "
347
-                        "intervening changes; reuse the earlier shell output instead of rerunning it"
352
+                        "relevant intervening changes; reuse the earlier shell output instead "
353
+                        "of rerunning it"
348354
                     ),
349355
                     repeat_threshold=self.BASH_OBSERVATION_REPEAT_THRESHOLD,
356
+                    target_paths=self._bash_observation_target_paths(command),
350357
                 )
351358
                 if duplicate:
352359
                     return True, reason
@@ -368,7 +375,7 @@ class ActionTracker:
368375
             if file_path:
369376
                 self.record_file_create(file_path, content)
370377
                 self._record_path_context(file_path)
371
-                self._note_mutation()
378
+                self._note_mutation(file_path)
372379
 
373380
         elif tool_name == "edit":
374381
             file_path = arguments.get("file_path", "")
@@ -377,7 +384,7 @@ class ActionTracker:
377384
             if file_path:
378385
                 self.record_edit(file_path, old_string, new_string)
379386
                 self._record_path_context(file_path)
380
-                self._note_mutation()
387
+                self._note_mutation(file_path)
381388
 
382389
         elif tool_name == "patch":
383390
             file_path = arguments.get("file_path", "")
@@ -389,7 +396,7 @@ class ActionTracker:
389396
                 elif isinstance(raw_patch, str) and raw_patch.strip():
390397
                     self.record_edit(file_path, raw_patch, "raw_patch")
391398
                 self._record_path_context(file_path)
392
-                self._note_mutation()
399
+                self._note_mutation(file_path)
393400
 
394401
         elif tool_name == "read":
395402
             read_key = self._make_read_key(arguments)
@@ -397,6 +404,7 @@ class ActionTracker:
397404
                 self._record_observation(
398405
                     self._recent_reads,
399406
                     read_key,
407
+                    target_paths=self._read_target_paths(arguments),
400408
                 )
401409
             file_path = str(arguments.get("file_path", "")).strip()
402410
             if file_path:
@@ -408,6 +416,7 @@ class ActionTracker:
408416
                 self._record_observation(
409417
                     self._recent_searches,
410418
                     observation_key,
419
+                    target_paths=self._search_target_paths(arguments),
411420
                 )
412421
             search_path = str(arguments.get("path", "")).strip()
413422
             if search_path:
@@ -418,11 +427,12 @@ class ActionTracker:
418427
             if command:
419428
                 self.record_command(command)
420429
                 if self._is_mutating_bash(command):
421
-                    self._note_mutation()
430
+                    self._note_mutation(paths=self._bash_mutation_target_paths(command))
422431
                 elif self._is_observational_bash(command):
423432
                     self._record_observation(
424433
                         self._recent_bash_observations,
425434
                         self._normalize_command(command),
435
+                        target_paths=self._bash_observation_target_paths(command),
426436
                     )
427437
 
428438
     def detect_loop(self) -> tuple[bool, str]:
@@ -502,54 +512,213 @@ class ActionTracker:
502512
     def _normalize_command(command: str) -> str:
503513
         return " ".join(command.split())
504514
 
505
-    def _note_mutation(self) -> None:
515
+    def _note_mutation(
516
+        self,
517
+        path_value: str | None = None,
518
+        *,
519
+        paths: tuple[str, ...] | list[str] | None = None,
520
+    ) -> None:
506521
         self._mutation_epoch += 1
522
+        candidate_paths = list(paths or ())
523
+        if path_value:
524
+            candidate_paths.append(path_value)
525
+        normalized_paths = tuple(
526
+            dict.fromkeys(
527
+                self._normalize_path(str(path))
528
+                for path in candidate_paths
529
+                if str(path).strip()
530
+            )
531
+        )
532
+        self._mutation_records.append(
533
+            _MutationRecord(epoch=self._mutation_epoch, paths=normalized_paths)
534
+        )
535
+        if len(self._mutation_records) > self.MAX_SEQUENCE_LENGTH:
536
+            del self._mutation_records[: -self.MAX_SEQUENCE_LENGTH]
507537
 
508538
     def _check_recent_observation(
509539
         self,
510
-        cache: dict[str, tuple[int, int, int]],
540
+        cache: dict[str, _ObservationRecord],
511541
         key: str,
512542
         reason: str,
513543
         *,
514544
         repeat_threshold: int,
545
+        target_paths: tuple[str, ...],
515546
     ) -> tuple[bool, str]:
516547
         last_seen = cache.get(key)
517548
         if last_seen is None:
518549
             return False, ""
519550
 
520
-        last_epoch, last_index, repeat_count = last_seen
521
-        if last_epoch != self._mutation_epoch:
551
+        if (
552
+            last_seen.mutation_epoch != self._mutation_epoch
553
+            and self._has_relevant_mutation_since(
554
+                last_seen.mutation_epoch,
555
+                target_paths or last_seen.target_paths,
556
+            )
557
+        ):
522558
             return False, ""
523
-        gap = self._action_index - last_index
559
+        gap = self._action_index - last_seen.action_index
524560
         if gap > self.OBSERVATION_REPEAT_WINDOW:
525561
             return False, ""
526562
         if gap <= 0:
527563
             return True, reason
528
-        if repeat_count >= repeat_threshold:
564
+        if last_seen.repeat_count >= repeat_threshold:
529565
             return True, reason
530566
         return False, ""
531567
 
532568
     def _record_observation(
533569
         self,
534
-        cache: dict[str, tuple[int, int, int]],
570
+        cache: dict[str, _ObservationRecord],
535571
         key: str,
572
+        *,
573
+        target_paths: tuple[str, ...],
536574
     ) -> None:
537575
         last_seen = cache.get(key)
538576
         if last_seen is None:
539
-            cache[key] = (self._mutation_epoch, self._action_index, 1)
577
+            cache[key] = _ObservationRecord(
578
+                mutation_epoch=self._mutation_epoch,
579
+                action_index=self._action_index,
580
+                repeat_count=1,
581
+                target_paths=target_paths,
582
+            )
540583
             return
541584
 
542
-        last_epoch, last_index, repeat_count = last_seen
543
-        gap = self._action_index - last_index
544
-        if last_epoch != self._mutation_epoch or gap > self.OBSERVATION_REPEAT_WINDOW:
545
-            cache[key] = (self._mutation_epoch, self._action_index, 1)
585
+        gap = self._action_index - last_seen.action_index
586
+        relevant_mutation = (
587
+            last_seen.mutation_epoch != self._mutation_epoch
588
+            and self._has_relevant_mutation_since(
589
+                last_seen.mutation_epoch,
590
+                target_paths or last_seen.target_paths,
591
+            )
592
+        )
593
+        if relevant_mutation or gap > self.OBSERVATION_REPEAT_WINDOW:
594
+            cache[key] = _ObservationRecord(
595
+                mutation_epoch=self._mutation_epoch,
596
+                action_index=self._action_index,
597
+                repeat_count=1,
598
+                target_paths=target_paths,
599
+            )
546600
             return
547601
 
548
-        cache[key] = (
549
-            self._mutation_epoch,
550
-            self._action_index,
551
-            repeat_count + 1,
602
+        cache[key] = _ObservationRecord(
603
+            mutation_epoch=self._mutation_epoch,
604
+            action_index=self._action_index,
605
+            repeat_count=last_seen.repeat_count + 1,
606
+            target_paths=target_paths or last_seen.target_paths,
607
+        )
608
+
609
+    def _has_relevant_mutation_since(
610
+        self,
611
+        epoch: int,
612
+        target_paths: tuple[str, ...],
613
+    ) -> bool:
614
+        if not target_paths:
615
+            return True
616
+        for mutation in self._mutation_records:
617
+            if mutation.epoch <= epoch:
618
+                continue
619
+            if not mutation.paths:
620
+                return True
621
+            for mutation_path in mutation.paths:
622
+                if any(self._paths_overlap(target_path, mutation_path) for target_path in target_paths):
623
+                    return True
624
+        return False
625
+
626
+    @staticmethod
627
+    def _paths_overlap(first: str, second: str) -> bool:
628
+        try:
629
+            common = os.path.commonpath([first, second])
630
+        except ValueError:
631
+            return False
632
+        return common == first or common == second
633
+
634
+    def _read_target_paths(self, arguments: dict) -> tuple[str, ...]:
635
+        file_path = str(arguments.get("file_path", "")).strip()
636
+        return (self._normalize_path(file_path),) if file_path else ()
637
+
638
+    def _search_target_paths(self, arguments: dict) -> tuple[str, ...]:
639
+        path = str(arguments.get("path", "")).strip()
640
+        if path:
641
+            return (self._normalize_path(path),)
642
+        pattern = str(arguments.get("pattern", "")).strip()
643
+        inferred_path = self._path_from_glob_pattern(pattern)
644
+        return (self._normalize_path(inferred_path),) if inferred_path else ()
645
+
646
+    @staticmethod
647
+    def _path_from_glob_pattern(pattern: str) -> str:
648
+        if not pattern:
649
+            return "."
650
+        first_glob_index = min(
651
+            (index for index in (pattern.find("*"), pattern.find("?"), pattern.find("[")) if index >= 0),
652
+            default=-1,
552653
         )
654
+        if first_glob_index < 0:
655
+            path = Path(pattern)
656
+            return str(path.parent if path.parent != Path("") else Path("."))
657
+        prefix = pattern[:first_glob_index]
658
+        if prefix.endswith(("/", "\\")):
659
+            return prefix.rstrip("/\\") or "/"
660
+        path = Path(prefix)
661
+        return str(path.parent if path.parent != Path("") else Path("."))
662
+
663
+    def _bash_observation_target_paths(self, command: str) -> tuple[str, ...]:
664
+        try:
665
+            argv = shlex.split(self._normalize_command(command))
666
+        except ValueError:
667
+            return ()
668
+        if not argv:
669
+            return ()
670
+
671
+        program = Path(argv[0]).name
672
+        operands = self._shell_path_operands(argv[1:])
673
+        if program in {"ls", "find"}:
674
+            return tuple(self._normalize_path(path) for path in (operands or ["."]))
675
+        if program in {"cat", "head", "tail", "stat"}:
676
+            return tuple(self._normalize_path(path) for path in operands)
677
+        if program == "rg":
678
+            return tuple(self._normalize_path(path) for path in operands[1:] or ["."])
679
+        if program == "pwd":
680
+            return (self._normalize_path("."),)
681
+        return ()
682
+
683
+    def _bash_mutation_target_paths(self, command: str) -> tuple[str, ...]:
684
+        normalized = self._normalize_command(command)
685
+        rewrite_target = extract_shell_text_rewrite_target(normalized)
686
+        if rewrite_target is not None:
687
+            return (self._normalize_path(rewrite_target),)
688
+
689
+        try:
690
+            argv = shlex.split(normalized)
691
+        except ValueError:
692
+            return ()
693
+        if not argv:
694
+            return ()
695
+
696
+        program = Path(argv[0]).name
697
+        operands = self._shell_path_operands(argv[1:])
698
+        if program == "cp":
699
+            return (self._normalize_path(operands[-1]),) if operands else ()
700
+        if program in {"mkdir", "touch", "rm", "mv", "chmod", "chown"}:
701
+            return tuple(self._normalize_path(path) for path in operands)
702
+        return ()
703
+
704
+    @staticmethod
705
+    def _shell_path_operands(tokens: list[str]) -> list[str]:
706
+        operands: list[str] = []
707
+        skip_next = False
708
+        option_value_flags = {"-n", "--lines", "-c", "--bytes", "-m", "--max-count"}
709
+        for token in tokens:
710
+            if skip_next:
711
+                skip_next = False
712
+                continue
713
+            if token in option_value_flags:
714
+                skip_next = True
715
+                continue
716
+            if token.startswith("-"):
717
+                continue
718
+            if re.fullmatch(r"\d+", token):
719
+                continue
720
+            operands.append(token)
721
+        return operands
553722
 
554723
     def _make_search_key(self, tool_name: str, arguments: dict) -> str | None:
555724
         pattern = str(arguments.get("pattern", "")).strip()
@@ -633,6 +802,20 @@ class ActionTracker:
633802
         if len(self._recent_path_contexts) > self.RECENT_PATH_CONTEXT_LIMIT:
634803
             del self._recent_path_contexts[self.RECENT_PATH_CONTEXT_LIMIT :]
635804
 
805
+@dataclass
806
+class _MutationRecord:
807
+    epoch: int
808
+    paths: tuple[str, ...]
809
+
810
+
811
+@dataclass
812
+class _ObservationRecord:
813
+    mutation_epoch: int
814
+    action_index: int
815
+    repeat_count: int
816
+    target_paths: tuple[str, ...]
817
+
818
+
636819
 @dataclass
637820
 class ValidationResult:
638821
     """Result of pre-action validation."""
tests/test_safeguard_services.pymodified
@@ -182,7 +182,7 @@ def test_action_tracker_allows_repeated_bash_observation_after_mutation() -> Non
182182
     tracker = ActionTracker()
183183
     bash_args = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
184184
     patch_args = {
185
-        "file_path": "index.html",
185
+        "file_path": "~/Loader/guides/fortran/chapters/01-introduction.html",
186186
         "hunks": [
187187
             {
188188
                 "old_start": 1,
@@ -200,6 +200,21 @@ def test_action_tracker_allows_repeated_bash_observation_after_mutation() -> Non
200200
     assert tracker.check_tool_call("bash", bash_args) == (False, "")
201201
 
202202
 
203
+def test_action_tracker_blocks_repeated_bash_observation_after_unrelated_mutation() -> None:
204
+    tracker = ActionTracker()
205
+    bash_args = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
206
+    mkdir_args = {"command": "mkdir -p ~/Loader/guides/nginx/chapters"}
207
+
208
+    tracker.record_tool_call("bash", bash_args)
209
+    tracker.record_tool_call("bash", bash_args)
210
+    tracker.record_tool_call("bash", mkdir_args)
211
+
212
+    is_duplicate, reason = tracker.check_tool_call("bash", bash_args)
213
+
214
+    assert is_duplicate is True
215
+    assert "relevant intervening changes" in reason
216
+
217
+
203218
 def test_action_tracker_blocks_repeated_read_without_changes(tmp_path) -> None:
204219
     tracker = ActionTracker()
205220
     file_path = tmp_path / "index.html"
@@ -256,6 +271,42 @@ def test_action_tracker_blocks_fourth_interleaved_reread_without_changes(tmp_pat
256271
     assert str(index_path) in reason
257272
 
258273
 
274
+def test_action_tracker_blocks_reread_after_unrelated_target_mutation(tmp_path) -> None:
275
+    tracker = ActionTracker()
276
+    reference_index = tmp_path / "fortran" / "index.html"
277
+    target_chapters = tmp_path / "nginx" / "chapters"
278
+    chapter_a = tmp_path / "fortran" / "chapters" / "chapter-1.html"
279
+    chapter_b = tmp_path / "fortran" / "chapters" / "chapter-2.html"
280
+
281
+    tracker.record_tool_call("read", {"file_path": str(reference_index)})
282
+    tracker.record_tool_call("read", {"file_path": str(chapter_a)})
283
+    tracker.record_tool_call("read", {"file_path": str(reference_index)})
284
+    tracker.record_tool_call("read", {"file_path": str(chapter_b)})
285
+    tracker.record_tool_call("read", {"file_path": str(reference_index)})
286
+    tracker.record_tool_call("bash", {"command": f"mkdir -p {target_chapters}"})
287
+
288
+    is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(reference_index)})
289
+
290
+    assert is_duplicate is True
291
+    assert "relevant intervening changes" in reason
292
+
293
+
294
+def test_action_tracker_blocks_repeated_search_after_unrelated_target_mutation(tmp_path) -> None:
295
+    tracker = ActionTracker()
296
+    reference_chapters = tmp_path / "fortran" / "chapters"
297
+    target_chapters = tmp_path / "nginx" / "chapters"
298
+    search_args = {"pattern": "*.html", "path": str(reference_chapters)}
299
+
300
+    tracker.record_tool_call("glob", search_args)
301
+    tracker.record_tool_call("glob", search_args)
302
+    tracker.record_tool_call("bash", {"command": f"mkdir -p {target_chapters}"})
303
+
304
+    is_duplicate, reason = tracker.check_tool_call("glob", search_args)
305
+
306
+    assert is_duplicate is True
307
+    assert "relevant intervening changes" in reason
308
+
309
+
259310
 def test_action_tracker_allows_one_target_index_reread_after_chapter_discovery(tmp_path) -> None:
260311
     tracker = ActionTracker()
261312
     index_path = tmp_path / "index.html"
tests/test_tool_batches.pymodified
@@ -651,7 +651,7 @@ async def test_tool_batch_runner_queues_duplicate_observation_nudge(
651651
     )
652652
     duplicate_message = (
653653
         "[Skipped - duplicate action: Already read "
654
-        f"{temp_dir / 'index.html'} recently without any intervening changes; "
654
+        f"{temp_dir / 'index.html'} recently without any relevant intervening changes; "
655655
         "reuse the earlier read result instead of rereading]"
656656
     )
657657
     executor = FakeExecutor(
@@ -778,7 +778,7 @@ async def test_tool_batch_runner_duplicate_read_keeps_root_declared_missing_html
778778
     )
779779
     duplicate_message = (
780780
         "[Skipped - duplicate action: Already read "
781
-        f"{index} recently without any intervening changes; "
781
+        f"{index} recently without any relevant intervening changes; "
782782
         "reuse the earlier read result instead of rereading]"
783783
     )
784784
     executor = FakeExecutor(
@@ -888,7 +888,7 @@ async def test_tool_batch_runner_duplicate_read_after_edit_mismatch_steers_to_mu
888888
     )
889889
     duplicate_message = (
890890
         "[Skipped - duplicate action: Already read "
891
-        f"{target} recently without any intervening changes; "
891
+        f"{target} recently without any relevant intervening changes; "
892892
         "reuse the earlier read result instead of rereading]"
893893
     )
894894
     executor = FakeExecutor(
@@ -1651,7 +1651,7 @@ async def test_tool_batch_runner_duplicate_reference_read_prefers_next_pending_t
16511651
     )
16521652
     duplicate_message = (
16531653
         "[Skipped - duplicate action: Already read "
1654
-        f"{reference} recently without any intervening changes; "
1654
+        f"{reference} recently without any relevant intervening changes; "
16551655
         "reuse the earlier read result instead of rereading]"
16561656
     )
16571657
     executor = FakeExecutor(
@@ -1907,7 +1907,7 @@ async def test_tool_batch_runner_duplicate_read_ignores_unplanned_expansion_afte
19071907
     )
19081908
     duplicate_message = (
19091909
         "[Skipped - duplicate action: Already read "
1910
-        f"{chapter_one} recently without any intervening changes; "
1910
+        f"{chapter_one} recently without any relevant intervening changes; "
19111911
         "reuse the earlier read result instead of rereading]"
19121912
     )
19131913
     executor = FakeExecutor(
@@ -2023,7 +2023,7 @@ async def test_tool_batch_runner_duplicate_read_after_plan_complete_pushes_verif
20232023
     )
20242024
     duplicate_message = (
20252025
         "[Skipped - duplicate action: Already read "
2026
-        f"{chapter_one} recently without any intervening changes; "
2026
+        f"{chapter_one} recently without any relevant intervening changes; "
20272027
         "reuse the earlier read result instead of rereading]"
20282028
     )
20292029
     executor = FakeExecutor(
@@ -2144,7 +2144,7 @@ async def test_tool_batch_runner_duplicate_read_after_plan_complete_ignores_stal
21442144
     )
21452145
     duplicate_message = (
21462146
         "[Skipped - duplicate action: Already read "
2147
-        f"{chapter_one} recently without any intervening changes; "
2147
+        f"{chapter_one} recently without any relevant intervening changes; "
21482148
         "reuse the earlier read result instead of rereading]"
21492149
     )
21502150
     executor = FakeExecutor(