Return bounded source excerpts instead of rejecting oversized reads
This commit is contained in:
1 parent
ab31924b79
commit
ecec96ce14
5 files changed
+232
-6
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user