Return bounded source excerpts instead of rejecting oversized reads

This commit is contained in:
Nico committed 2026-10-20 21:12:03 +02:00
1 parent ab31924b79
commit ecec96ce14
5 files changed
+232 -6

No files matched your search

+57
View File
@@ -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.
@@ -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}
@@ -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
}
}
}
+15 -6
View File
@@ -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
+126
View File
@@ -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