From ecec96ce1434fceb5bbdce46d7839153ebcd40bc Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Tue, 20 Oct 2026 21:12:03 +0200 Subject: [PATCH] Return bounded source excerpts instead of rejecting oversized reads --- docs/source-read-windows.md | 57 ++++++++ .../analysis/investigation_window.py | 5 + .../schemas/project-capture-v1.schema.json | 29 ++++ src/viano_bug_analysis/tools/repository.py | 21 ++- tests/test_source_read_window.py | 126 ++++++++++++++++++ 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 docs/source-read-windows.md create mode 100644 tests/test_source_read_window.py diff --git a/docs/source-read-windows.md b/docs/source-read-windows.md new file mode 100644 index 0000000..a13e94b --- /dev/null +++ b/docs/source-read-windows.md @@ -0,0 +1,57 @@ +# Begrenzte Source-Lesefenster + +`project.query` mit `kind: source.read` liest weiterhin ausschließlich aus dem +konfigurierten Git-Snapshot. Übersteigt ein ansonsten gültiger inklusiver +Zeilenbereich `ProjectLimits.max_lines` (Default 80), wird er ab der angeforderten +Startzeile begrenzt ausgeführt. Die ursprüngliche Anfrage wird nicht verändert. +Die Grenze ist konfigurierbar und keine Grenze des Case-Paketformats. + +Beispiel: 115–215 fordert 101 Zeilen an. Geliefert werden höchstens 115–194. +Das Capture enthält zusätzlich: + +- `requested_line_count`, `line_limit`, `line_limit_exceeded` +- `returned_line_count` sowie angeforderten und gelieferten Start-/Endbereich +- `requested_range_complete`, `next_start_line` und einen kurzen `read_notice` + +`truncated: true` und `requested_range_complete: false` bedeuten, dass noch +existierende Zeilen des angeforderten Bereichs fehlen. `next_start_line` nennt +dann die nächste lesbare Fortsetzungszeile. Zum Weiterlesen ursprünglichen Endwert +beibehalten; keine Zeilen überspringen oder erneut als neue Erkenntnis werten. +`eof` bezieht sich auf den tatsächlich gelieferten Ausschnitt, nicht auf den +ursprünglich gewünschten Endwert. + +Erreicht das begrenzte Fenster bereits das Dateiende, ist keine weitere Ausgabe +nötig: `eof: true`, `truncated: false`, `next_start_line: null`. Dies gilt auch bei +einem Start hinter dem Dateiende mit leerem Ergebnis; das bisherige kontrollierte +EOF-Verhalten bleibt erhalten. Vollständigkeit bezieht sich ausschließlich auf +den angefragten Zeilenbereich bis EOF, nicht auf andere Dateien oder die gesamte +Implementierung. Bestehende `text_truncated`-Angaben einzelner langer Codezeilen +bleiben unabhängig davon erhalten. + +Originalquery und Ergebnis werden gemeinsam unveränderlich im Capture gesichert. +Das Ergebnisschema erlaubt die zusätzlichen Metadaten optional, damit historische +Captures weiterhin gültig bleiben. Die begrenzte Investigation-Darstellung erhält +diese Bereichs-/Fortsetzungsdaten auch außerhalb paginierter JSON-Fragmente. +Normale Anfragen innerhalb der Grenze behalten ihre bisherige Ergebnisform. + +Abgelehnt bleiben insbesondere nicht ganzzahlige oder nicht positive Startwerte, +umgekehrte Bereiche, unsichere Pfade und nicht vorhandene Dateien. Die anderen +Tooloperationen und deren Grenzen werden hierdurch nicht gelockert. Es findet +keine automatische Folgeabfrage statt und kein Inhalt wird erfunden. + +Lokale Tests verwenden beide kuratierten Bug-17-Antworten mit 101 und 91 Zeilen, +prüfen exakt die zurückgelieferten Zeilen und lückenlose Fortsetzung, konfigurierbare +Grenzen, EOF, sichere Ablehnung, Capture-Persistenz, kompakte Scope-Angaben und drei +Controller-Startabfragen ohne Korrekturversuch. Keine bezahlten LLM-Aufrufe. + +## Prüfergebnis am 20.10.2026 + +16 neue Tests bestanden. Gemeinsame Prüfung mit Projekt-Recherche, Source-Kontext, +begrenzter Investigation v1/v2 und Startup: 126 bestanden, 1 übersprungen, +5 fehlgeschlagen. Alle fünf Fehler wurden zusätzlich gegen den unveränderten +Commit `ab31924` reproduziert: ältere unbeschränkte Prompt-Tests treffen die +bereits bestehende 20.000-Zeichen-Sicherung statt ihres erwarteten Folgeschritts. +Betroffen sind `test_model_loop_uses_project_tools_and_compact_previous_result` +(Varianten True, unknown, upgrade), `test_started_project_loop_keeps_legacy_prompt_protocol` +und `test_budgeted_evidence_and_portable_hybrid_resume`. Keine dieser bestehenden +Grenzen wurde für diese Änderung gelockert. `git diff --check` ohne Befund. diff --git a/src/viano_bug_analysis/analysis/investigation_window.py b/src/viano_bug_analysis/analysis/investigation_window.py index d9767ed..06c1189 100644 --- a/src/viano_bug_analysis/analysis/investigation_window.py +++ b/src/viano_bug_analysis/analysis/investigation_window.py @@ -110,6 +110,11 @@ def evidence_page(entry, index=0): result = capture['result'] item = {'query':capture['query'], **{k:result[k] for k in ('coverage','truncated','interpretation') if k in result}} + if capture['query'].get('kind') == 'source.read' and 'line_limit' in result: + item.update({k:result[k] for k in ('requested_start_line','requested_end_line', + 'requested_line_count','line_limit','line_limit_exceeded','returned_start_line', + 'returned_end_line','returned_line_count','requested_range_complete','next_start_line','eof') + if k in result}) if capture['query'].get('kind') == 'database.relations': item['nodes'] = [{k:node[k] for k in ('node','table','complete','upstream_complete', 'local_truncated','returned_rows','distribution','skip_reason') if k in node} diff --git a/src/viano_bug_analysis/schemas/project-capture-v1.schema.json b/src/viano_bug_analysis/schemas/project-capture-v1.schema.json index 5160a87..f95c555 100644 --- a/src/viano_bug_analysis/schemas/project-capture-v1.schema.json +++ b/src/viano_bug_analysis/schemas/project-capture-v1.schema.json @@ -404,6 +404,35 @@ }, "selection": { "type": "object" + }, + "requested_line_count": { + "type": "integer", + "minimum": 1 + }, + "line_limit": { + "type": "integer", + "minimum": 1 + }, + "line_limit_exceeded": { + "type": "boolean" + }, + "returned_line_count": { + "type": "integer", + "minimum": 0 + }, + "requested_range_complete": { + "type": "boolean" + }, + "next_start_line": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "read_notice": { + "type": "string", + "maxLength": 300 } } } diff --git a/src/viano_bug_analysis/tools/repository.py b/src/viano_bug_analysis/tools/repository.py index 64ba30b..0c20ad3 100644 --- a/src/viano_bug_analysis/tools/repository.py +++ b/src/viano_bug_analysis/tools/repository.py @@ -69,8 +69,7 @@ class GitSourceProvider: if args["path"] not in self.inventory(): raise ToolError("PROJECT_SOURCE_NOT_FOUND") if (any(type(args[k]) is not int for k in ('start_line', 'end_line')) - or args['start_line'] < 1 or args["end_line"] < args["start_line"] - or args["end_line"]-args["start_line"]+1 > self.limits.max_lines): + or args['start_line'] < 1 or args["end_line"] < args["start_line"]): raise ToolError("PROJECT_SOURCE_RANGE") elif "path_prefix" in args: safe_path(args["path_prefix"], empty=True) @@ -204,14 +203,24 @@ class GitSourceProvider: if kind == "source.read": text, source = self._read(args["path"]) lines = text.splitlines() - end = min(args['end_line'], len(lines)) - return {"items": [{**source, "line": i+1, "text": lines[i][:2048], "text_truncated":len(lines[i])>2048} + requested_count = args['end_line'] - args['start_line'] + 1 + end = min(args['end_line'], args['start_line'] + self.limits.max_lines - 1, len(lines)) + truncated = end < min(args['end_line'], len(lines)) + result = {"items": [{**source, "line": i+1, "text": lines[i][:2048], "text_truncated":len(lines[i])>2048} for i in range(args["start_line"]-1, end)], - "coverage":"selected_lines", "truncated":False, - "total_lines":len(lines), "eof":args['end_line'] >= len(lines), + "coverage":"selected_lines", "truncated":truncated, + "total_lines":len(lines), "eof":end >= len(lines), "requested_start_line":args['start_line'], "requested_end_line":args['end_line'], "returned_start_line":args['start_line'] if args['start_line'] <= end else None, "returned_end_line":end if args['start_line'] <= end else None} + if requested_count > self.limits.max_lines: + result.update(requested_line_count=requested_count, line_limit=self.limits.max_lines, + line_limit_exceeded=True, returned_line_count=len(result['items']), + requested_range_complete=not truncated, + next_start_line=end+1 if truncated else None, + read_notice=f"Requested {requested_count} lines; at most {self.limits.max_lines} allowed. " + f"Returned {len(result['items'])} lines; inspect returned range and EOF before drawing conclusions.") + return result return self._search(args) @staticmethod diff --git a/tests/test_source_read_window.py b/tests/test_source_read_window.py new file mode 100644 index 0000000..f31bf69 --- /dev/null +++ b/tests/test_source_read_window.py @@ -0,0 +1,126 @@ +import copy +import json +import subprocess +from pathlib import Path + +import pytest + +from test_project_research import source +from test_case_builder import case_setup +from test_investigation_tools import tool_case +from test_bounded_contract_v2 import loop_for +from test_investigation_startup import Adapter +from test_investigator import run, state +from viano_bug_analysis.llm import ProviderResponse +from viano_bug_analysis.tools import ToolError +from viano_bug_analysis.tools.project_query import ProjectResearch, ProjectLimits +from viano_bug_analysis.package import PackageValidator + +FILE = 'src/server/scripts/EasternKingdoms/arathi_highlands.cpp' + + +@pytest.fixture +def long_source(source): + path = source.path / FILE + path.parent.mkdir(parents=True) + path.write_text(''.join(f'code line {i}\n' for i in range(1, 301)), encoding='utf-8') + def git(*args): + return subprocess.run(['git', '-C', str(source.path), *args], check=True, + capture_output=True).stdout.decode().strip() + git('add', FILE) + git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'long fixture') + source.commit = git('rev-parse', 'HEAD') + source._inventory = None + return source + + +def read(start=115, end=215): + return {'kind':'source.read', 'path':FILE, 'start_line':start, 'end_line':end} + + +def test_clamp_is_explicit_and_next_page_recovers_requested_lines(long_source): + query = read() + before = copy.deepcopy(query) + result = long_source.query(query) + assert query == before + assert len(result['items']) == 80 + assert result['requested_line_count'] == 101 and result['line_limit'] == 80 + assert result['requested_start_line'] == 115 and result['requested_end_line'] == 215 + assert result['returned_start_line'] == 115 and result['returned_end_line'] == 194 + assert result['truncated'] and not result['requested_range_complete'] and not result['eof'] + assert result['next_start_line'] == 195 + assert '101' in result['read_notice'] and '80' in result['read_notice'] + following = long_source.query(read(result['next_start_line'], query['end_line'])) + assert not following['truncated'] + assert [v['line'] for v in result['items'] + following['items']] == list(range(115,216)) + + +@pytest.mark.parametrize('start,end,count,truncated,eof,next_line', [ + (1,80,80,False,False,None), (1,81,80,True,False,81), + (221,1000,80,False,True,None), (290,1000,11,False,True,None), + (301,1000,0,False,True,None), +]) +def test_boundaries_and_eof(long_source,start,end,count,truncated,eof,next_line): + result = long_source.query(read(start,end)) + assert len(result['items']) == count and result['truncated'] == truncated + assert result['eof'] == eof and result.get('next_start_line') == next_line + if end-start+1 > 80: + assert result['requested_range_complete'] == (not truncated) + if not count: + assert result['returned_start_line'] is None and result['returned_end_line'] is None + + +@pytest.mark.parametrize('start,end', [(0,80),(-1,80),(50,49),(True,80),(1,80.0)]) +def test_invalid_ranges_still_rejected(long_source,start,end): + with pytest.raises(ToolError,match='PROJECT_SOURCE_RANGE'): + long_source.query(read(start,end)) + + +def test_configurable_limit_and_unsafe_path(long_source): + long_source.limits = ProjectLimits(max_lines=7) + result = long_source.query(read(2,99)) + assert len(result['items']) == 7 and result['next_start_line'] == 9 + with pytest.raises(ToolError): + long_source.query({**read(), 'path':'../outside.cpp'}) + + +@pytest.mark.parametrize('name', ['range101','range91']) +def test_actual_bug17_rejections_now_produce_frozen_partial_capture(long_source,name): + corpus = Path(__file__).parent / 'fixtures/llm_rejections/20261020/bug17-source-range' / name + answer = json.loads((corpus/'parsed-answer.json').read_text(encoding='utf-8')) + query = answer['next']['arguments'] + before = copy.deepcopy(query) + service = ProjectResearch({}, source=long_source) + capture = json.loads(service.collect(query)) + assert capture['query'] == query == before + assert capture['result']['truncated'] and len(capture['result']['items']) == 80 + assert service.collect(query) == service.collect(query) + + +def test_partial_scope_survives_bounded_evidence_projection(long_source): + from viano_bug_analysis.analysis.investigation_window import evidence_page + capture = json.loads(ProjectResearch({},source=long_source).collect(read())) + page = evidence_page({'record':{'hypothesis_id':'H1'},'content':{'items':[capture]}}) + assert page['encoding'] == 'json_fragment' and not page['complete_payload'] + scope = page['scope'][0] + assert scope['truncated'] and not scope['requested_range_complete'] + assert scope['returned_end_line'] == 194 and scope['next_start_line'] == 195 + assert scope['line_limit'] == 80 and scope['requested_line_count'] == 101 + + +def test_oversized_read_runs_in_controller_without_invalid_retry(tool_case,long_source): + class Read(Adapter): + async def generate(self,config,request,**kwargs): + body = json.loads(request['messages'][1]['content']) + if body['task'] != 'startup_choose_query': + return await super().generate(config,request,**kwargs) + self.calls.append(body) + return ProviderResponse({'assessment':None,'observation':'Read a bounded source excerpt.', + 'next':{'kind':'tool','operation':'project.query','arguments':read()}},'fixture',100,50) + adapter = Read() + result = run(loop_for(tool_case,adapter,source=long_source),steps=3) + saved = state(result) + assert saved['startup']['explored'] == ['H1','H2','H3'] + assert len(adapter.calls) == 3 + assert all(h['invalid_responses'] == 0 for h in saved['hypotheses'].values()) + assert PackageValidator().validate(result.package).valid