flash-context 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flash_context/__init__.py +7 -0
- flash_context/agent.py +1425 -0
- flash_context/cli.py +86 -0
- flash_context/config.py +391 -0
- flash_context/llm.py +87 -0
- flash_context/logging.py +84 -0
- flash_context/mcp_server.py +89 -0
- flash_context/tools/__init__.py +5 -0
- flash_context/tools/glob.py +29 -0
- flash_context/tools/grep.py +67 -0
- flash_context/tools/read.py +77 -0
- flash_context/tools/safety.py +168 -0
- flash_context/types.py +210 -0
- flash_context-0.1.0.dist-info/METADATA +81 -0
- flash_context-0.1.0.dist-info/RECORD +18 -0
- flash_context-0.1.0.dist-info/WHEEL +4 -0
- flash_context-0.1.0.dist-info/entry_points.txt +2 -0
- flash_context-0.1.0.dist-info/licenses/LICENSE +202 -0
flash_context/agent.py
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal, cast
|
|
9
|
+
|
|
10
|
+
from flash_context.config import Settings
|
|
11
|
+
from flash_context.llm import ChatClient
|
|
12
|
+
from flash_context.logging import TrajectoryRecorder
|
|
13
|
+
from flash_context.tools import read_file, repo_glob, repo_grep
|
|
14
|
+
from flash_context.types import (
|
|
15
|
+
Citation,
|
|
16
|
+
ExploreRequest,
|
|
17
|
+
ExplorerError,
|
|
18
|
+
ExploreResult,
|
|
19
|
+
RawLocation,
|
|
20
|
+
ToolCall,
|
|
21
|
+
ToolObservation,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ToolName = Literal["read_file", "repo_glob", "repo_grep"]
|
|
25
|
+
|
|
26
|
+
ALLOWED_TOOLS: set[ToolName] = {"read_file", "repo_glob", "repo_grep"}
|
|
27
|
+
MAX_FINAL_CITATIONS = 5
|
|
28
|
+
NO_CITATIONS_FOUND = "NO_CITATIONS_FOUND"
|
|
29
|
+
CITATION_RE = re.compile(
|
|
30
|
+
r"(?P<path>(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+)"
|
|
31
|
+
r":(?P<start>[1-9][0-9]*)(?:-(?P<end>[1-9][0-9]*))?"
|
|
32
|
+
)
|
|
33
|
+
SOURCE_LINES_RE = re.compile(
|
|
34
|
+
r"`?(?P<path>(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)`?"
|
|
35
|
+
r",?\s+lines?\s+"
|
|
36
|
+
r"(?P<start>[1-9][0-9]*)"
|
|
37
|
+
r"(?:\s*[-\u2013\u2014]\s*(?P<end>[1-9][0-9]*))?",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
LINES_OF_SOURCE_RE = re.compile(
|
|
41
|
+
r"lines?\s+"
|
|
42
|
+
r"(?P<start>[1-9][0-9]*)"
|
|
43
|
+
r"(?:\s*[-\u2013\u2014]\s*(?P<end>[1-9][0-9]*))?"
|
|
44
|
+
r"\s+of\s+"
|
|
45
|
+
r"`?(?P<path>(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)`?",
|
|
46
|
+
re.IGNORECASE,
|
|
47
|
+
)
|
|
48
|
+
MARKDOWN_FILE_LINES_RE = re.compile(
|
|
49
|
+
r"File\**\s*:\s*`?"
|
|
50
|
+
r"(?P<path>(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)`?"
|
|
51
|
+
r".{0,120}?"
|
|
52
|
+
r"Lines\**\s*:\s*"
|
|
53
|
+
r"(?P<start>[1-9][0-9]*)"
|
|
54
|
+
r"(?:\s*[-\u2013\u2014]\s*(?P<end>[1-9][0-9]*))?",
|
|
55
|
+
re.IGNORECASE | re.DOTALL,
|
|
56
|
+
)
|
|
57
|
+
EXPLICIT_PATH_RE = re.compile(
|
|
58
|
+
r"`?(?P<path>(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)`?"
|
|
59
|
+
)
|
|
60
|
+
BACKTICK_OR_QUOTE_RE = re.compile(
|
|
61
|
+
r"[`'\"](?P<token>[A-Za-z_][A-Za-z0-9_]*)[`'\"]"
|
|
62
|
+
)
|
|
63
|
+
EXACT_TEXT_RE = re.compile(r"[`'\"](?P<target>[^`'\"]{1,120})[`'\"]")
|
|
64
|
+
TOKEN_RE = re.compile(r"\b(?P<token>[A-Za-z_][A-Za-z0-9_]*)\b")
|
|
65
|
+
SEMANTIC_QUERY_RE = re.compile(
|
|
66
|
+
r"\b(why|how|compare|comparison|synthesize|interpret|explain|behavior|"
|
|
67
|
+
r"behaviour|architecture|owner|ownership|relationship|tradeoff|flow)\b",
|
|
68
|
+
re.IGNORECASE,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class EvidenceState:
|
|
74
|
+
reads: list[ToolObservation] = field(default_factory=list)
|
|
75
|
+
citations: list[Citation] = field(default_factory=list)
|
|
76
|
+
seen_tool_calls: set[str] = field(default_factory=set)
|
|
77
|
+
repeated_tool_calls: int = 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def explore(
|
|
81
|
+
request: ExploreRequest,
|
|
82
|
+
settings: Settings,
|
|
83
|
+
*,
|
|
84
|
+
client: ChatClient | None = None,
|
|
85
|
+
) -> ExploreResult:
|
|
86
|
+
request.validate()
|
|
87
|
+
repo_root = request.repo_root.resolve()
|
|
88
|
+
fast_result = _try_exact_fast_path(
|
|
89
|
+
request,
|
|
90
|
+
repo_root=repo_root,
|
|
91
|
+
settings=settings,
|
|
92
|
+
)
|
|
93
|
+
if fast_result is not None:
|
|
94
|
+
return fast_result
|
|
95
|
+
settings.require_endpoint()
|
|
96
|
+
effective_max_turns = min(request.max_turns, settings.max_turns)
|
|
97
|
+
recorder = TrajectoryRecorder(settings.traj_dir, request)
|
|
98
|
+
owns_client = client is None
|
|
99
|
+
chat_client = client or ChatClient(settings)
|
|
100
|
+
file_sample = repo_glob(
|
|
101
|
+
repo_root=repo_root,
|
|
102
|
+
pattern="**/*",
|
|
103
|
+
max_results=settings.max_grep_results,
|
|
104
|
+
ignore=settings.ignore,
|
|
105
|
+
)
|
|
106
|
+
messages = _initial_messages(request, file_sample, settings)
|
|
107
|
+
tools = tool_schemas()
|
|
108
|
+
truncated = False
|
|
109
|
+
warnings: list[str] = []
|
|
110
|
+
last_content = ""
|
|
111
|
+
evidence = EvidenceState()
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
for turn in range(1, effective_max_turns + 1):
|
|
115
|
+
response = chat_client.chat_completion(messages=messages, tools=tools)
|
|
116
|
+
message = _extract_message(response)
|
|
117
|
+
recorder.record_model_turn(turn, message)
|
|
118
|
+
content_text = str(message.get("content") or "").strip()
|
|
119
|
+
if content_text:
|
|
120
|
+
last_content = content_text
|
|
121
|
+
citations = _validated_citations_from_content(
|
|
122
|
+
content_text,
|
|
123
|
+
repo_root=repo_root,
|
|
124
|
+
settings=settings,
|
|
125
|
+
evidence=evidence,
|
|
126
|
+
warnings=warnings,
|
|
127
|
+
)
|
|
128
|
+
if citations:
|
|
129
|
+
evidence.citations = citations
|
|
130
|
+
if _message_has_tool_calls(message):
|
|
131
|
+
_append_warning_once(
|
|
132
|
+
warnings,
|
|
133
|
+
"ignored trailing tool calls after valid citations",
|
|
134
|
+
)
|
|
135
|
+
result = _finalize_result(
|
|
136
|
+
request=request,
|
|
137
|
+
repo_root=repo_root,
|
|
138
|
+
settings=settings,
|
|
139
|
+
answer=content_text,
|
|
140
|
+
citations=citations,
|
|
141
|
+
turns_used=turn,
|
|
142
|
+
truncated=truncated,
|
|
143
|
+
warnings=warnings,
|
|
144
|
+
)
|
|
145
|
+
recorder.finish(result=result)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
tool_calls = _extract_tool_calls(message)
|
|
149
|
+
if not tool_calls:
|
|
150
|
+
citations = _best_current_citations(evidence)
|
|
151
|
+
if request.citation and content_text != NO_CITATIONS_FOUND:
|
|
152
|
+
_append_warning_once(
|
|
153
|
+
warnings,
|
|
154
|
+
"final answer did not include valid citations",
|
|
155
|
+
)
|
|
156
|
+
result = _finalize_result(
|
|
157
|
+
request=request,
|
|
158
|
+
repo_root=repo_root,
|
|
159
|
+
settings=settings,
|
|
160
|
+
answer=content_text,
|
|
161
|
+
citations=citations,
|
|
162
|
+
turns_used=turn,
|
|
163
|
+
truncated=truncated,
|
|
164
|
+
warnings=warnings,
|
|
165
|
+
)
|
|
166
|
+
recorder.finish(result=result)
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
if _should_finalize_before_tool_calls(
|
|
170
|
+
request,
|
|
171
|
+
evidence,
|
|
172
|
+
tool_calls,
|
|
173
|
+
settings,
|
|
174
|
+
):
|
|
175
|
+
_append_warning_once(
|
|
176
|
+
warnings,
|
|
177
|
+
"sufficient narrow evidence stopped broad read",
|
|
178
|
+
)
|
|
179
|
+
result = _finalize_result(
|
|
180
|
+
request=request,
|
|
181
|
+
repo_root=repo_root,
|
|
182
|
+
settings=settings,
|
|
183
|
+
answer=last_content,
|
|
184
|
+
citations=_best_current_citations(evidence),
|
|
185
|
+
turns_used=turn,
|
|
186
|
+
truncated=truncated,
|
|
187
|
+
warnings=warnings,
|
|
188
|
+
)
|
|
189
|
+
recorder.finish(result=result)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
repeated_call = _first_repeated_tool_call(evidence, tool_calls)
|
|
193
|
+
if repeated_call is not None:
|
|
194
|
+
evidence.repeated_tool_calls += 1
|
|
195
|
+
_append_warning_once(
|
|
196
|
+
warnings,
|
|
197
|
+
"repeated tool call stopped exploration",
|
|
198
|
+
)
|
|
199
|
+
citations = _best_current_citations(evidence)
|
|
200
|
+
result = _finalize_result(
|
|
201
|
+
request=request,
|
|
202
|
+
repo_root=repo_root,
|
|
203
|
+
settings=settings,
|
|
204
|
+
answer=last_content,
|
|
205
|
+
citations=citations,
|
|
206
|
+
turns_used=turn,
|
|
207
|
+
truncated=truncated,
|
|
208
|
+
warnings=warnings,
|
|
209
|
+
)
|
|
210
|
+
recorder.finish(result=result)
|
|
211
|
+
return result
|
|
212
|
+
for tool_call in tool_calls:
|
|
213
|
+
evidence.seen_tool_calls.add(_tool_call_fingerprint(tool_call))
|
|
214
|
+
|
|
215
|
+
messages.append(_assistant_tool_message(message))
|
|
216
|
+
observations = _execute_tool_calls_parallel(
|
|
217
|
+
tool_calls,
|
|
218
|
+
repo_root=repo_root,
|
|
219
|
+
settings=settings,
|
|
220
|
+
)
|
|
221
|
+
for tool_call, observation in zip(tool_calls, observations, strict=True):
|
|
222
|
+
observation.tool_call_id = tool_call.id
|
|
223
|
+
truncated = truncated or observation.truncated
|
|
224
|
+
if (
|
|
225
|
+
tool_call.name == "read_file"
|
|
226
|
+
and observation.ok
|
|
227
|
+
and observation.path
|
|
228
|
+
and observation.line_range
|
|
229
|
+
):
|
|
230
|
+
evidence.reads.append(observation)
|
|
231
|
+
recorder.record_observation(turn, tool_call.name, observation)
|
|
232
|
+
messages.append(
|
|
233
|
+
{
|
|
234
|
+
"role": "tool",
|
|
235
|
+
"tool_call_id": tool_call.id,
|
|
236
|
+
"name": tool_call.name,
|
|
237
|
+
"content": json.dumps(
|
|
238
|
+
_model_observation_payload(
|
|
239
|
+
observation,
|
|
240
|
+
max_chars=settings.max_observation_chars,
|
|
241
|
+
),
|
|
242
|
+
sort_keys=True,
|
|
243
|
+
),
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
if last_content:
|
|
247
|
+
citations = _validated_citations_from_content(
|
|
248
|
+
last_content,
|
|
249
|
+
repo_root=repo_root,
|
|
250
|
+
settings=settings,
|
|
251
|
+
evidence=evidence,
|
|
252
|
+
warnings=warnings,
|
|
253
|
+
) or _citations_from_observations(
|
|
254
|
+
last_content,
|
|
255
|
+
evidence.reads,
|
|
256
|
+
)
|
|
257
|
+
_append_warning_once(warnings, "max turns exceeded after partial answer")
|
|
258
|
+
if request.citation and not citations:
|
|
259
|
+
_append_warning_once(
|
|
260
|
+
warnings,
|
|
261
|
+
"partial answer did not include valid citations",
|
|
262
|
+
)
|
|
263
|
+
result = _finalize_result(
|
|
264
|
+
request=request,
|
|
265
|
+
repo_root=repo_root,
|
|
266
|
+
settings=settings,
|
|
267
|
+
answer=last_content,
|
|
268
|
+
citations=citations,
|
|
269
|
+
turns_used=effective_max_turns,
|
|
270
|
+
truncated=truncated,
|
|
271
|
+
warnings=warnings,
|
|
272
|
+
)
|
|
273
|
+
recorder.finish(result=result)
|
|
274
|
+
return result
|
|
275
|
+
citations = _best_current_citations(evidence)
|
|
276
|
+
_append_warning_once(warnings, "max turns exceeded without final answer")
|
|
277
|
+
result = _finalize_result(
|
|
278
|
+
request=request,
|
|
279
|
+
repo_root=repo_root,
|
|
280
|
+
settings=settings,
|
|
281
|
+
answer="",
|
|
282
|
+
citations=citations,
|
|
283
|
+
turns_used=effective_max_turns,
|
|
284
|
+
truncated=truncated,
|
|
285
|
+
warnings=warnings,
|
|
286
|
+
)
|
|
287
|
+
recorder.finish(result=result)
|
|
288
|
+
return result
|
|
289
|
+
except ExplorerError as exc:
|
|
290
|
+
recorder.finish(error=exc)
|
|
291
|
+
raise
|
|
292
|
+
finally:
|
|
293
|
+
if owns_client:
|
|
294
|
+
chat_client.close()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def tool_schemas() -> list[dict[str, Any]]:
|
|
298
|
+
return [
|
|
299
|
+
{
|
|
300
|
+
"type": "function",
|
|
301
|
+
"function": {
|
|
302
|
+
"name": "read_file",
|
|
303
|
+
"description": "Read a bounded line range from a repository file.",
|
|
304
|
+
"parameters": {
|
|
305
|
+
"type": "object",
|
|
306
|
+
"properties": {
|
|
307
|
+
"path": {
|
|
308
|
+
"type": "string",
|
|
309
|
+
"description": (
|
|
310
|
+
"Repository-relative file path with no leading "
|
|
311
|
+
"slash, e.g. src/flash_context/types.py."
|
|
312
|
+
),
|
|
313
|
+
},
|
|
314
|
+
"start_line": {"type": "integer", "minimum": 1},
|
|
315
|
+
"end_line": {"type": "integer", "minimum": 1},
|
|
316
|
+
},
|
|
317
|
+
"required": ["path"],
|
|
318
|
+
"additionalProperties": False,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"type": "function",
|
|
324
|
+
"function": {
|
|
325
|
+
"name": "repo_glob",
|
|
326
|
+
"description": "List repository files matching a glob pattern.",
|
|
327
|
+
"parameters": {
|
|
328
|
+
"type": "object",
|
|
329
|
+
"properties": {
|
|
330
|
+
"pattern": {
|
|
331
|
+
"type": "string",
|
|
332
|
+
"description": (
|
|
333
|
+
"Repository-relative glob with no leading "
|
|
334
|
+
"slash, e.g. src/**/*.py."
|
|
335
|
+
),
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
"required": ["pattern"],
|
|
339
|
+
"additionalProperties": False,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
"type": "function",
|
|
345
|
+
"function": {
|
|
346
|
+
"name": "repo_grep",
|
|
347
|
+
"description": (
|
|
348
|
+
"Search repository text files with a regular expression."
|
|
349
|
+
),
|
|
350
|
+
"parameters": {
|
|
351
|
+
"type": "object",
|
|
352
|
+
"properties": {
|
|
353
|
+
"pattern": {"type": "string"},
|
|
354
|
+
"glob": {
|
|
355
|
+
"type": "string",
|
|
356
|
+
"description": (
|
|
357
|
+
"Optional repository-relative glob with no "
|
|
358
|
+
"leading slash, e.g. src/**/*.py."
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
"max_results": {"type": "integer", "minimum": 1},
|
|
362
|
+
},
|
|
363
|
+
"required": ["pattern"],
|
|
364
|
+
"additionalProperties": False,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def format_text_result(result: ExploreResult, *, citation: bool) -> str:
|
|
372
|
+
if citation:
|
|
373
|
+
return _citation_text(result.citations)
|
|
374
|
+
return result.answer
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _finalize_result(
|
|
378
|
+
*,
|
|
379
|
+
request: ExploreRequest,
|
|
380
|
+
repo_root: Path,
|
|
381
|
+
settings: Settings,
|
|
382
|
+
answer: str,
|
|
383
|
+
citations: list[Citation],
|
|
384
|
+
turns_used: int,
|
|
385
|
+
truncated: bool,
|
|
386
|
+
warnings: list[str],
|
|
387
|
+
) -> ExploreResult:
|
|
388
|
+
citations = _merge_adjacent_citations(citations)[:MAX_FINAL_CITATIONS]
|
|
389
|
+
if request.citation or (not answer and citations):
|
|
390
|
+
answer = _citation_text(citations)
|
|
391
|
+
raw_locations = _extract_raw_locations(
|
|
392
|
+
citations,
|
|
393
|
+
repo_root=repo_root,
|
|
394
|
+
settings=settings,
|
|
395
|
+
warnings=warnings,
|
|
396
|
+
)
|
|
397
|
+
return ExploreResult(
|
|
398
|
+
query=request.query,
|
|
399
|
+
repo_root=str(repo_root),
|
|
400
|
+
answer=answer,
|
|
401
|
+
citations=citations,
|
|
402
|
+
raw_locations=raw_locations,
|
|
403
|
+
turns_used=turns_used,
|
|
404
|
+
truncated=truncated,
|
|
405
|
+
warnings=warnings,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _citation_text(citations: list[Citation]) -> str:
|
|
410
|
+
if not citations:
|
|
411
|
+
return NO_CITATIONS_FOUND
|
|
412
|
+
return "\n".join(citation.label() for citation in citations)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
|
|
416
|
+
grouped: dict[str, list[tuple[int, Citation]]] = {}
|
|
417
|
+
path_order: list[str] = []
|
|
418
|
+
non_mergeable: list[Citation] = []
|
|
419
|
+
for index, citation in enumerate(citations):
|
|
420
|
+
if citation.start_line is None or citation.end_line is None:
|
|
421
|
+
non_mergeable.append(citation)
|
|
422
|
+
continue
|
|
423
|
+
if citation.path not in grouped:
|
|
424
|
+
grouped[citation.path] = []
|
|
425
|
+
path_order.append(citation.path)
|
|
426
|
+
grouped[citation.path].append((index, citation))
|
|
427
|
+
|
|
428
|
+
merged: list[Citation] = []
|
|
429
|
+
for path in path_order:
|
|
430
|
+
ranges = sorted(
|
|
431
|
+
grouped[path],
|
|
432
|
+
key=lambda item: (
|
|
433
|
+
item[1].start_line or 0,
|
|
434
|
+
item[1].end_line or 0,
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
current_start: int | None = None
|
|
438
|
+
current_end: int | None = None
|
|
439
|
+
current_reason: str | None = None
|
|
440
|
+
current_reason_index: int | None = None
|
|
441
|
+
|
|
442
|
+
for index, citation in ranges:
|
|
443
|
+
start = citation.start_line
|
|
444
|
+
end = citation.end_line
|
|
445
|
+
if start is None or end is None:
|
|
446
|
+
continue
|
|
447
|
+
if current_start is None or current_end is None:
|
|
448
|
+
current_start = start
|
|
449
|
+
current_end = end
|
|
450
|
+
current_reason = citation.reason
|
|
451
|
+
current_reason_index = index if citation.reason else None
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
if start <= current_end + 1:
|
|
455
|
+
current_end = max(current_end, end)
|
|
456
|
+
if citation.reason and (
|
|
457
|
+
current_reason_index is None or index < current_reason_index
|
|
458
|
+
):
|
|
459
|
+
current_reason = citation.reason
|
|
460
|
+
current_reason_index = index
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
merged.append(
|
|
464
|
+
Citation(path, current_start, current_end, current_reason)
|
|
465
|
+
)
|
|
466
|
+
current_start = start
|
|
467
|
+
current_end = end
|
|
468
|
+
current_reason = citation.reason
|
|
469
|
+
current_reason_index = index if citation.reason else None
|
|
470
|
+
|
|
471
|
+
if current_start is not None and current_end is not None:
|
|
472
|
+
merged.append(Citation(path, current_start, current_end, current_reason))
|
|
473
|
+
|
|
474
|
+
merged.extend(non_mergeable)
|
|
475
|
+
return merged
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _extract_raw_locations(
|
|
479
|
+
citations: list[Citation],
|
|
480
|
+
*,
|
|
481
|
+
repo_root: Path,
|
|
482
|
+
settings: Settings,
|
|
483
|
+
warnings: list[str],
|
|
484
|
+
) -> list[RawLocation]:
|
|
485
|
+
raw_locations: list[RawLocation] = []
|
|
486
|
+
for citation in citations:
|
|
487
|
+
if citation.start_line is None or citation.end_line is None:
|
|
488
|
+
_append_warning_once(warnings, "raw location missing line range")
|
|
489
|
+
continue
|
|
490
|
+
try:
|
|
491
|
+
observation = read_file(
|
|
492
|
+
repo_root=repo_root,
|
|
493
|
+
path=citation.path,
|
|
494
|
+
start_line=citation.start_line,
|
|
495
|
+
end_line=citation.end_line,
|
|
496
|
+
max_bytes=settings.max_read_bytes,
|
|
497
|
+
ignore=settings.ignore,
|
|
498
|
+
)
|
|
499
|
+
except ExplorerError:
|
|
500
|
+
_append_warning_once(warnings, "raw location could not be read")
|
|
501
|
+
continue
|
|
502
|
+
start, end = _parse_line_range(observation.line_range or "")
|
|
503
|
+
if (
|
|
504
|
+
observation.path is None
|
|
505
|
+
or observation.content is None
|
|
506
|
+
or start is None
|
|
507
|
+
or end is None
|
|
508
|
+
):
|
|
509
|
+
_append_warning_once(warnings, "raw location could not be read")
|
|
510
|
+
continue
|
|
511
|
+
if observation.truncated:
|
|
512
|
+
_append_warning_once(warnings, "raw location truncated")
|
|
513
|
+
raw_locations.append(
|
|
514
|
+
RawLocation(
|
|
515
|
+
path=observation.path,
|
|
516
|
+
start_line=start,
|
|
517
|
+
end_line=end,
|
|
518
|
+
text=observation.content,
|
|
519
|
+
truncated=observation.truncated,
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
return raw_locations
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _try_exact_fast_path(
|
|
526
|
+
request: ExploreRequest,
|
|
527
|
+
*,
|
|
528
|
+
repo_root: Path,
|
|
529
|
+
settings: Settings,
|
|
530
|
+
) -> ExploreResult | None:
|
|
531
|
+
if _is_semantic_query(request.query):
|
|
532
|
+
return None
|
|
533
|
+
evidence = EvidenceState()
|
|
534
|
+
warnings: list[str] = []
|
|
535
|
+
citations = _validated_citations_from_content(
|
|
536
|
+
request.query,
|
|
537
|
+
repo_root=repo_root,
|
|
538
|
+
settings=settings,
|
|
539
|
+
evidence=evidence,
|
|
540
|
+
warnings=warnings,
|
|
541
|
+
)
|
|
542
|
+
if citations:
|
|
543
|
+
return _finalize_result(
|
|
544
|
+
request=request,
|
|
545
|
+
repo_root=repo_root,
|
|
546
|
+
settings=settings,
|
|
547
|
+
answer="",
|
|
548
|
+
citations=citations,
|
|
549
|
+
turns_used=0,
|
|
550
|
+
truncated=any(observation.truncated for observation in evidence.reads),
|
|
551
|
+
warnings=[],
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
explicit_path = _extract_explicit_path(request.query)
|
|
555
|
+
if explicit_path is not None:
|
|
556
|
+
target = _extract_exact_target(request.query, explicit_path)
|
|
557
|
+
if target is None:
|
|
558
|
+
return None
|
|
559
|
+
citations = _exact_matches_in_file(
|
|
560
|
+
repo_root=repo_root,
|
|
561
|
+
settings=settings,
|
|
562
|
+
path=explicit_path,
|
|
563
|
+
target=target,
|
|
564
|
+
allow_assignment=True,
|
|
565
|
+
)
|
|
566
|
+
if citations:
|
|
567
|
+
return _finalize_result(
|
|
568
|
+
request=request,
|
|
569
|
+
repo_root=repo_root,
|
|
570
|
+
settings=settings,
|
|
571
|
+
answer="",
|
|
572
|
+
citations=citations,
|
|
573
|
+
turns_used=0,
|
|
574
|
+
truncated=False,
|
|
575
|
+
warnings=[],
|
|
576
|
+
)
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
symbol = _extract_pathless_symbol(request.query)
|
|
580
|
+
if symbol is None:
|
|
581
|
+
return None
|
|
582
|
+
citation = _unique_definition_citation(
|
|
583
|
+
repo_root=repo_root,
|
|
584
|
+
settings=settings,
|
|
585
|
+
symbol=symbol,
|
|
586
|
+
)
|
|
587
|
+
if citation is None:
|
|
588
|
+
return None
|
|
589
|
+
return _finalize_result(
|
|
590
|
+
request=request,
|
|
591
|
+
repo_root=repo_root,
|
|
592
|
+
settings=settings,
|
|
593
|
+
answer="",
|
|
594
|
+
citations=[citation],
|
|
595
|
+
turns_used=0,
|
|
596
|
+
truncated=False,
|
|
597
|
+
warnings=[],
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _is_semantic_query(query: str) -> bool:
|
|
602
|
+
return bool(SEMANTIC_QUERY_RE.search(query))
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _extract_explicit_path(query: str) -> str | None:
|
|
606
|
+
for match in EXPLICIT_PATH_RE.finditer(query):
|
|
607
|
+
path = match.group("path").rstrip(".,;:)")
|
|
608
|
+
if _is_repository_relative_path(path):
|
|
609
|
+
return path
|
|
610
|
+
return None
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _extract_exact_target(query: str, explicit_path: str) -> str | None:
|
|
614
|
+
for match in EXACT_TEXT_RE.finditer(query):
|
|
615
|
+
target = match.group("target").strip()
|
|
616
|
+
if target and target != explicit_path and target != Path(explicit_path).stem:
|
|
617
|
+
return target
|
|
618
|
+
without_path = query.replace(explicit_path, " ")
|
|
619
|
+
ignored = {
|
|
620
|
+
"in",
|
|
621
|
+
"find",
|
|
622
|
+
"the",
|
|
623
|
+
"where",
|
|
624
|
+
"return",
|
|
625
|
+
"citations",
|
|
626
|
+
"only",
|
|
627
|
+
"line",
|
|
628
|
+
"lines",
|
|
629
|
+
"file",
|
|
630
|
+
}
|
|
631
|
+
tokens = [
|
|
632
|
+
match.group("token")
|
|
633
|
+
for match in TOKEN_RE.finditer(without_path)
|
|
634
|
+
if match.group("token").lower() not in ignored
|
|
635
|
+
]
|
|
636
|
+
return tokens[-1] if len(tokens) == 1 else None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _extract_pathless_symbol(query: str) -> str | None:
|
|
640
|
+
for match in BACKTICK_OR_QUOTE_RE.finditer(query):
|
|
641
|
+
return match.group("token")
|
|
642
|
+
ignored = {
|
|
643
|
+
"find",
|
|
644
|
+
"the",
|
|
645
|
+
"definition",
|
|
646
|
+
"of",
|
|
647
|
+
"return",
|
|
648
|
+
"citations",
|
|
649
|
+
"only",
|
|
650
|
+
"class",
|
|
651
|
+
"function",
|
|
652
|
+
"def",
|
|
653
|
+
"symbol",
|
|
654
|
+
}
|
|
655
|
+
candidates = [
|
|
656
|
+
match.group("token")
|
|
657
|
+
for match in TOKEN_RE.finditer(query)
|
|
658
|
+
if match.group("token").lower() not in ignored
|
|
659
|
+
]
|
|
660
|
+
return candidates[0] if len(candidates) == 1 else None
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _exact_matches_in_file(
|
|
664
|
+
*,
|
|
665
|
+
repo_root: Path,
|
|
666
|
+
settings: Settings,
|
|
667
|
+
path: str,
|
|
668
|
+
target: str,
|
|
669
|
+
allow_assignment: bool,
|
|
670
|
+
) -> list[Citation]:
|
|
671
|
+
grep_result = _grep_exact_target(
|
|
672
|
+
repo_root=repo_root,
|
|
673
|
+
settings=settings,
|
|
674
|
+
target=target,
|
|
675
|
+
glob=path,
|
|
676
|
+
)
|
|
677
|
+
if grep_result.truncated:
|
|
678
|
+
return []
|
|
679
|
+
citations = _citations_for_target_hits(
|
|
680
|
+
grep_result,
|
|
681
|
+
target=target,
|
|
682
|
+
allow_assignment=allow_assignment,
|
|
683
|
+
definitions_only=False,
|
|
684
|
+
)
|
|
685
|
+
return citations if 0 < len(citations) <= MAX_FINAL_CITATIONS else []
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _unique_definition_citation(
|
|
689
|
+
*,
|
|
690
|
+
repo_root: Path,
|
|
691
|
+
settings: Settings,
|
|
692
|
+
symbol: str,
|
|
693
|
+
) -> Citation | None:
|
|
694
|
+
grep_result = _grep_exact_target(
|
|
695
|
+
repo_root=repo_root,
|
|
696
|
+
settings=settings,
|
|
697
|
+
target=symbol,
|
|
698
|
+
glob="**/*.py",
|
|
699
|
+
)
|
|
700
|
+
pyi_result = _grep_exact_target(
|
|
701
|
+
repo_root=repo_root,
|
|
702
|
+
settings=settings,
|
|
703
|
+
target=symbol,
|
|
704
|
+
glob="**/*.pyi",
|
|
705
|
+
)
|
|
706
|
+
citations = _citations_for_target_hits(
|
|
707
|
+
grep_result,
|
|
708
|
+
target=symbol,
|
|
709
|
+
allow_assignment=False,
|
|
710
|
+
definitions_only=True,
|
|
711
|
+
)
|
|
712
|
+
citations.extend(
|
|
713
|
+
_citations_for_target_hits(
|
|
714
|
+
pyi_result,
|
|
715
|
+
target=symbol,
|
|
716
|
+
allow_assignment=False,
|
|
717
|
+
definitions_only=True,
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
if grep_result.truncated or pyi_result.truncated or len(citations) > 1:
|
|
721
|
+
return None
|
|
722
|
+
return citations[0] if len(citations) == 1 else None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _grep_exact_target(
|
|
726
|
+
*,
|
|
727
|
+
repo_root: Path,
|
|
728
|
+
settings: Settings,
|
|
729
|
+
target: str,
|
|
730
|
+
glob: str,
|
|
731
|
+
) -> ToolObservation:
|
|
732
|
+
return repo_grep(
|
|
733
|
+
repo_root=repo_root,
|
|
734
|
+
pattern=re.escape(target),
|
|
735
|
+
glob=glob,
|
|
736
|
+
max_results=MAX_FINAL_CITATIONS + 1,
|
|
737
|
+
ignore=settings.ignore,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _citations_for_target_hits(
|
|
742
|
+
observation: ToolObservation,
|
|
743
|
+
*,
|
|
744
|
+
target: str,
|
|
745
|
+
allow_assignment: bool,
|
|
746
|
+
definitions_only: bool,
|
|
747
|
+
) -> list[Citation]:
|
|
748
|
+
citations: list[Citation] = []
|
|
749
|
+
for hit in observation.hits:
|
|
750
|
+
if _line_matches_target(
|
|
751
|
+
hit.text,
|
|
752
|
+
target=target,
|
|
753
|
+
allow_assignment=allow_assignment,
|
|
754
|
+
definitions_only=definitions_only,
|
|
755
|
+
):
|
|
756
|
+
citations.append(Citation(hit.path, hit.line, hit.line))
|
|
757
|
+
return citations
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _line_matches_target(
|
|
761
|
+
line: str,
|
|
762
|
+
*,
|
|
763
|
+
target: str,
|
|
764
|
+
allow_assignment: bool,
|
|
765
|
+
definitions_only: bool,
|
|
766
|
+
) -> bool:
|
|
767
|
+
escaped = re.escape(target)
|
|
768
|
+
definition_pattern = (
|
|
769
|
+
rf"^\s*(?:async\s+def|def|class)\s+{escaped}\b"
|
|
770
|
+
)
|
|
771
|
+
if re.search(definition_pattern, line):
|
|
772
|
+
return True
|
|
773
|
+
if definitions_only:
|
|
774
|
+
return False
|
|
775
|
+
if allow_assignment and re.search(rf"^\s*{escaped}\s*(?::|=)", line):
|
|
776
|
+
return True
|
|
777
|
+
return bool(re.search(rf"\b{escaped}\b", line))
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _validated_citations_from_content(
|
|
781
|
+
content: str,
|
|
782
|
+
*,
|
|
783
|
+
repo_root: Path,
|
|
784
|
+
settings: Settings,
|
|
785
|
+
evidence: EvidenceState,
|
|
786
|
+
warnings: list[str],
|
|
787
|
+
) -> list[Citation]:
|
|
788
|
+
parsed = extract_citations(content)
|
|
789
|
+
if not parsed:
|
|
790
|
+
return []
|
|
791
|
+
validated = _validate_citations(
|
|
792
|
+
parsed,
|
|
793
|
+
repo_root=repo_root,
|
|
794
|
+
settings=settings,
|
|
795
|
+
evidence=evidence,
|
|
796
|
+
)
|
|
797
|
+
if not validated:
|
|
798
|
+
_append_warning_once(warnings, "invalid citation rejected")
|
|
799
|
+
return validated
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _validate_citations(
|
|
803
|
+
citations: list[Citation],
|
|
804
|
+
*,
|
|
805
|
+
repo_root: Path,
|
|
806
|
+
settings: Settings,
|
|
807
|
+
evidence: EvidenceState,
|
|
808
|
+
) -> list[Citation]:
|
|
809
|
+
validated: list[Citation] = []
|
|
810
|
+
for citation in citations:
|
|
811
|
+
normalized = _normalize_citation(citation)
|
|
812
|
+
if normalized is None:
|
|
813
|
+
continue
|
|
814
|
+
if _citation_has_read_coverage(normalized, evidence.reads):
|
|
815
|
+
validated.append(normalized)
|
|
816
|
+
continue
|
|
817
|
+
verified = _verify_citation_by_read(
|
|
818
|
+
normalized,
|
|
819
|
+
repo_root=repo_root,
|
|
820
|
+
settings=settings,
|
|
821
|
+
evidence=evidence,
|
|
822
|
+
)
|
|
823
|
+
if verified is not None:
|
|
824
|
+
validated.append(verified)
|
|
825
|
+
return _merge_adjacent_citations(_dedupe_citations(validated))[
|
|
826
|
+
:MAX_FINAL_CITATIONS
|
|
827
|
+
]
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _normalize_citation(citation: Citation) -> Citation | None:
|
|
831
|
+
if (
|
|
832
|
+
not citation.path
|
|
833
|
+
or citation.start_line is None
|
|
834
|
+
or citation.end_line is None
|
|
835
|
+
or citation.start_line <= 0
|
|
836
|
+
or citation.end_line <= 0
|
|
837
|
+
or citation.end_line < citation.start_line
|
|
838
|
+
or not _is_repository_relative_path(citation.path)
|
|
839
|
+
):
|
|
840
|
+
return None
|
|
841
|
+
return Citation(
|
|
842
|
+
path=citation.path,
|
|
843
|
+
start_line=citation.start_line,
|
|
844
|
+
end_line=citation.end_line,
|
|
845
|
+
reason=citation.reason,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _is_repository_relative_path(path: str) -> bool:
|
|
850
|
+
raw = Path(path)
|
|
851
|
+
return not raw.is_absolute() and ".." not in raw.parts
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _citation_has_read_coverage(
|
|
855
|
+
citation: Citation,
|
|
856
|
+
observations: list[ToolObservation],
|
|
857
|
+
) -> bool:
|
|
858
|
+
for observation in observations:
|
|
859
|
+
if not _citation_overlaps_read(citation, observation):
|
|
860
|
+
continue
|
|
861
|
+
read_start, read_end = _parse_line_range(observation.line_range or "")
|
|
862
|
+
if (
|
|
863
|
+
read_start is not None
|
|
864
|
+
and read_end is not None
|
|
865
|
+
and citation.start_line is not None
|
|
866
|
+
and citation.end_line is not None
|
|
867
|
+
and read_start <= citation.start_line
|
|
868
|
+
and read_end >= citation.end_line
|
|
869
|
+
):
|
|
870
|
+
return True
|
|
871
|
+
return False
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _citation_overlaps_read(
|
|
875
|
+
citation: Citation,
|
|
876
|
+
observation: ToolObservation,
|
|
877
|
+
) -> bool:
|
|
878
|
+
if (
|
|
879
|
+
observation.path != citation.path
|
|
880
|
+
or observation.line_range is None
|
|
881
|
+
or citation.start_line is None
|
|
882
|
+
or citation.end_line is None
|
|
883
|
+
):
|
|
884
|
+
return False
|
|
885
|
+
read_start, read_end = _parse_line_range(observation.line_range)
|
|
886
|
+
if read_start is None or read_end is None:
|
|
887
|
+
return False
|
|
888
|
+
return citation.start_line <= read_end and citation.end_line >= read_start
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _verify_citation_by_read(
|
|
892
|
+
citation: Citation,
|
|
893
|
+
*,
|
|
894
|
+
repo_root: Path,
|
|
895
|
+
settings: Settings,
|
|
896
|
+
evidence: EvidenceState,
|
|
897
|
+
) -> Citation | None:
|
|
898
|
+
if citation.start_line is None or citation.end_line is None:
|
|
899
|
+
return None
|
|
900
|
+
try:
|
|
901
|
+
observation = read_file(
|
|
902
|
+
repo_root=repo_root,
|
|
903
|
+
path=citation.path,
|
|
904
|
+
start_line=citation.start_line,
|
|
905
|
+
end_line=citation.end_line,
|
|
906
|
+
max_bytes=settings.max_read_bytes,
|
|
907
|
+
ignore=settings.ignore,
|
|
908
|
+
)
|
|
909
|
+
except ExplorerError:
|
|
910
|
+
return None
|
|
911
|
+
verified = Citation(
|
|
912
|
+
path=observation.path or citation.path,
|
|
913
|
+
start_line=citation.start_line,
|
|
914
|
+
end_line=citation.end_line,
|
|
915
|
+
reason=citation.reason,
|
|
916
|
+
)
|
|
917
|
+
if verified.path != citation.path:
|
|
918
|
+
return None
|
|
919
|
+
if not _citation_has_read_coverage(verified, [observation]):
|
|
920
|
+
return None
|
|
921
|
+
evidence.reads.append(observation)
|
|
922
|
+
return verified
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _dedupe_citations(citations: list[Citation]) -> list[Citation]:
|
|
926
|
+
deduped: list[Citation] = []
|
|
927
|
+
seen: set[tuple[str, int | None, int | None]] = set()
|
|
928
|
+
for citation in citations:
|
|
929
|
+
key = (citation.path, citation.start_line, citation.end_line)
|
|
930
|
+
if key in seen:
|
|
931
|
+
continue
|
|
932
|
+
seen.add(key)
|
|
933
|
+
deduped.append(citation)
|
|
934
|
+
return deduped
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _best_current_citations(evidence: EvidenceState) -> list[Citation]:
|
|
938
|
+
if evidence.citations:
|
|
939
|
+
return evidence.citations[:MAX_FINAL_CITATIONS]
|
|
940
|
+
return _citations_from_reads(evidence.reads)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _citations_from_reads(observations: list[ToolObservation]) -> list[Citation]:
|
|
944
|
+
candidates: list[tuple[bool, int, int, Citation]] = []
|
|
945
|
+
seen: set[tuple[str, int | None, int | None]] = set()
|
|
946
|
+
for index, observation in enumerate(observations):
|
|
947
|
+
if observation.path is None or observation.line_range is None:
|
|
948
|
+
continue
|
|
949
|
+
start, end = _parse_line_range(observation.line_range)
|
|
950
|
+
if start is None or end is None:
|
|
951
|
+
continue
|
|
952
|
+
key = (observation.path, start, end)
|
|
953
|
+
if key in seen:
|
|
954
|
+
continue
|
|
955
|
+
seen.add(key)
|
|
956
|
+
candidates.append(
|
|
957
|
+
(
|
|
958
|
+
observation.truncated,
|
|
959
|
+
end - start,
|
|
960
|
+
index,
|
|
961
|
+
Citation(observation.path, start, end),
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
return [
|
|
965
|
+
citation
|
|
966
|
+
for _, _, _, citation in sorted(candidates, key=lambda candidate: candidate[:3])
|
|
967
|
+
][:MAX_FINAL_CITATIONS]
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _should_finalize_before_tool_calls(
|
|
971
|
+
request: ExploreRequest,
|
|
972
|
+
evidence: EvidenceState,
|
|
973
|
+
tool_calls: list[ToolCall],
|
|
974
|
+
settings: Settings,
|
|
975
|
+
) -> bool:
|
|
976
|
+
if not request.citation or _best_narrow_read_citation(evidence, settings) is None:
|
|
977
|
+
return False
|
|
978
|
+
return any(
|
|
979
|
+
_is_broad_read_on_seen_path(tool_call, evidence, settings)
|
|
980
|
+
for tool_call in tool_calls
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _best_narrow_read_citation(
|
|
985
|
+
evidence: EvidenceState,
|
|
986
|
+
settings: Settings,
|
|
987
|
+
) -> Citation | None:
|
|
988
|
+
for citation in _citations_from_reads(evidence.reads):
|
|
989
|
+
for observation in evidence.reads:
|
|
990
|
+
start, end = _parse_line_range(observation.line_range or "")
|
|
991
|
+
if (
|
|
992
|
+
observation.path == citation.path
|
|
993
|
+
and not observation.truncated
|
|
994
|
+
and citation.start_line is not None
|
|
995
|
+
and citation.end_line is not None
|
|
996
|
+
and start == citation.start_line
|
|
997
|
+
and end == citation.end_line
|
|
998
|
+
and citation.end_line - citation.start_line + 1
|
|
999
|
+
<= settings.max_read_lines
|
|
1000
|
+
):
|
|
1001
|
+
return citation
|
|
1002
|
+
return None
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _is_broad_read_on_seen_path(
|
|
1006
|
+
tool_call: ToolCall,
|
|
1007
|
+
evidence: EvidenceState,
|
|
1008
|
+
settings: Settings,
|
|
1009
|
+
) -> bool:
|
|
1010
|
+
if tool_call.name != "read_file":
|
|
1011
|
+
return False
|
|
1012
|
+
path = tool_call.arguments.get("path")
|
|
1013
|
+
if not isinstance(path, str) or not path:
|
|
1014
|
+
return False
|
|
1015
|
+
read_paths = {
|
|
1016
|
+
observation.path for observation in evidence.reads if observation.path
|
|
1017
|
+
}
|
|
1018
|
+
if path not in read_paths:
|
|
1019
|
+
return False
|
|
1020
|
+
start_line = _int_argument(tool_call.arguments, "start_line")
|
|
1021
|
+
end_line = _int_argument(tool_call.arguments, "end_line")
|
|
1022
|
+
if start_line is None or end_line is None:
|
|
1023
|
+
return True
|
|
1024
|
+
return end_line - start_line + 1 > settings.max_read_lines
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _tool_call_fingerprint(tool_call: ToolCall) -> str:
|
|
1028
|
+
return json.dumps(
|
|
1029
|
+
{
|
|
1030
|
+
"name": tool_call.name,
|
|
1031
|
+
"arguments": tool_call.arguments,
|
|
1032
|
+
},
|
|
1033
|
+
sort_keys=True,
|
|
1034
|
+
separators=(",", ":"),
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _first_repeated_tool_call(
|
|
1039
|
+
evidence: EvidenceState,
|
|
1040
|
+
tool_calls: list[ToolCall],
|
|
1041
|
+
) -> ToolCall | None:
|
|
1042
|
+
batch_fingerprints: set[str] = set()
|
|
1043
|
+
for tool_call in tool_calls:
|
|
1044
|
+
fingerprint = _tool_call_fingerprint(tool_call)
|
|
1045
|
+
if (
|
|
1046
|
+
fingerprint in evidence.seen_tool_calls
|
|
1047
|
+
or fingerprint in batch_fingerprints
|
|
1048
|
+
):
|
|
1049
|
+
return tool_call
|
|
1050
|
+
batch_fingerprints.add(fingerprint)
|
|
1051
|
+
return None
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _message_has_tool_calls(message: dict[str, Any]) -> bool:
|
|
1055
|
+
return bool(message.get("tool_calls"))
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _append_warning_once(warnings: list[str], warning: str) -> None:
|
|
1059
|
+
if warning not in warnings:
|
|
1060
|
+
warnings.append(warning)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def extract_citations(answer: str) -> list[Citation]:
|
|
1064
|
+
citations: list[Citation] = []
|
|
1065
|
+
seen: set[tuple[str, int, int | None]] = set()
|
|
1066
|
+
for regex in (
|
|
1067
|
+
CITATION_RE,
|
|
1068
|
+
SOURCE_LINES_RE,
|
|
1069
|
+
LINES_OF_SOURCE_RE,
|
|
1070
|
+
MARKDOWN_FILE_LINES_RE,
|
|
1071
|
+
):
|
|
1072
|
+
for match in regex.finditer(answer):
|
|
1073
|
+
citation = _citation_from_match(match)
|
|
1074
|
+
key = (citation.path, citation.start_line or 0, citation.end_line)
|
|
1075
|
+
if key in seen:
|
|
1076
|
+
continue
|
|
1077
|
+
seen.add(key)
|
|
1078
|
+
citations.append(citation)
|
|
1079
|
+
return citations
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _citation_from_match(match: re.Match[str]) -> Citation:
|
|
1083
|
+
path = match.group("path")
|
|
1084
|
+
start = int(match.group("start"))
|
|
1085
|
+
end_text = match.group("end")
|
|
1086
|
+
end = int(end_text) if end_text is not None else start
|
|
1087
|
+
return Citation(path=path, start_line=start, end_line=end)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _citations_from_observations(
|
|
1091
|
+
answer: str,
|
|
1092
|
+
observations: list[ToolObservation],
|
|
1093
|
+
) -> list[Citation]:
|
|
1094
|
+
citations: list[Citation] = []
|
|
1095
|
+
seen: set[tuple[str, int | None, int | None]] = set()
|
|
1096
|
+
for observation in observations:
|
|
1097
|
+
if observation.path is None or observation.line_range is None:
|
|
1098
|
+
continue
|
|
1099
|
+
if (
|
|
1100
|
+
observation.path not in answer
|
|
1101
|
+
and Path(observation.path).name not in answer
|
|
1102
|
+
):
|
|
1103
|
+
continue
|
|
1104
|
+
start, end = _parse_line_range(observation.line_range)
|
|
1105
|
+
key = (observation.path, start, end)
|
|
1106
|
+
if key in seen:
|
|
1107
|
+
continue
|
|
1108
|
+
seen.add(key)
|
|
1109
|
+
citations.append(Citation(observation.path, start, end))
|
|
1110
|
+
return citations[:MAX_FINAL_CITATIONS]
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _parse_line_range(line_range: str) -> tuple[int | None, int | None]:
|
|
1114
|
+
start_text, _, end_text = line_range.partition("-")
|
|
1115
|
+
try:
|
|
1116
|
+
start = int(start_text)
|
|
1117
|
+
except ValueError:
|
|
1118
|
+
return None, None
|
|
1119
|
+
if not end_text:
|
|
1120
|
+
return start, start
|
|
1121
|
+
try:
|
|
1122
|
+
end = int(end_text)
|
|
1123
|
+
except ValueError:
|
|
1124
|
+
return start, start
|
|
1125
|
+
return start, end
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _initial_messages(
|
|
1129
|
+
request: ExploreRequest,
|
|
1130
|
+
file_sample: ToolObservation,
|
|
1131
|
+
settings: Settings,
|
|
1132
|
+
) -> list[dict[str, Any]]:
|
|
1133
|
+
files = "\n".join(f"- {path}" for path in file_sample.matches)
|
|
1134
|
+
if not files:
|
|
1135
|
+
files = "- <no safe files listed>"
|
|
1136
|
+
truncated_note = (
|
|
1137
|
+
"\nThe file list is truncated; use repo_glob/repo_grep for more paths."
|
|
1138
|
+
if file_sample.truncated
|
|
1139
|
+
else ""
|
|
1140
|
+
)
|
|
1141
|
+
output_contract = (
|
|
1142
|
+
"Final answer format is strict: end with a <final_answer> block "
|
|
1143
|
+
"containing only 1-5 repository-relative path:start-end citation "
|
|
1144
|
+
"lines, then </final_answer>. Do not include prose, Markdown, code "
|
|
1145
|
+
"blocks, bullets, or explanations inside the block. If no citation "
|
|
1146
|
+
"is supported, return exactly NO_CITATIONS_FOUND."
|
|
1147
|
+
if request.citation
|
|
1148
|
+
else "Final answer may include concise prose with file-line citations."
|
|
1149
|
+
)
|
|
1150
|
+
return [
|
|
1151
|
+
{
|
|
1152
|
+
"role": "system",
|
|
1153
|
+
"content": (
|
|
1154
|
+
"You answer focused questions about a local repository. "
|
|
1155
|
+
"Use only the provided read-only tools. Return compact "
|
|
1156
|
+
"repository-relative citations as path:start-end. Use the "
|
|
1157
|
+
"listed repository paths as grounding and do not invent paths. "
|
|
1158
|
+
"Tool path arguments must be repository-relative with no "
|
|
1159
|
+
"leading slash and must not include the repository directory "
|
|
1160
|
+
"name as a prefix. Example: use src/app.py, not "
|
|
1161
|
+
"/repo/src/app.py or repo/src/app.py. "
|
|
1162
|
+
"If a read returns PATH_NOT_FOUND, INVALID_TOOL_ARGUMENTS, or "
|
|
1163
|
+
"PATH_OUTSIDE_ROOT, correct the path once using the known file "
|
|
1164
|
+
"list or broaden search; do not repeat the same failing call. "
|
|
1165
|
+
"When searches or reads are independent, issue them as multiple "
|
|
1166
|
+
"tool calls in the same turn. "
|
|
1167
|
+
"For exact symbol, function, or class names, use repo_grep "
|
|
1168
|
+
"before reading broad file ranges. "
|
|
1169
|
+
f"Use read_file line ranges of at most {settings.max_read_lines} "
|
|
1170
|
+
"lines unless no narrower range is known. "
|
|
1171
|
+
f"{output_contract}"
|
|
1172
|
+
),
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
"role": "user",
|
|
1176
|
+
"content": (
|
|
1177
|
+
f"{request.query}\n\n"
|
|
1178
|
+
f"Known safe repository files:\n{files}{truncated_note}"
|
|
1179
|
+
),
|
|
1180
|
+
},
|
|
1181
|
+
]
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _extract_message(response: dict[str, Any]) -> dict[str, Any]:
|
|
1185
|
+
choices = response.get("choices")
|
|
1186
|
+
if not isinstance(choices, list) or not choices:
|
|
1187
|
+
raise ExplorerError(
|
|
1188
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
1189
|
+
"Endpoint response has no choices",
|
|
1190
|
+
)
|
|
1191
|
+
first = choices[0]
|
|
1192
|
+
if not isinstance(first, dict):
|
|
1193
|
+
raise ExplorerError(
|
|
1194
|
+
"ENDPOINT_BAD_RESPONSE", "Endpoint choice must be an object"
|
|
1195
|
+
)
|
|
1196
|
+
message = first.get("message")
|
|
1197
|
+
if not isinstance(message, dict):
|
|
1198
|
+
raise ExplorerError(
|
|
1199
|
+
"ENDPOINT_BAD_RESPONSE", "Endpoint message must be an object"
|
|
1200
|
+
)
|
|
1201
|
+
return cast(dict[str, Any], message)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _extract_tool_calls(message: dict[str, Any]) -> list[ToolCall]:
|
|
1205
|
+
raw_calls = message.get("tool_calls")
|
|
1206
|
+
if raw_calls is None:
|
|
1207
|
+
return []
|
|
1208
|
+
if not isinstance(raw_calls, list):
|
|
1209
|
+
raise ExplorerError("ENDPOINT_BAD_RESPONSE", "tool_calls must be a list")
|
|
1210
|
+
calls: list[ToolCall] = []
|
|
1211
|
+
for raw_call in raw_calls:
|
|
1212
|
+
calls.append(_parse_tool_call(raw_call))
|
|
1213
|
+
return calls
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _parse_tool_call(raw_call: object) -> ToolCall:
|
|
1217
|
+
if not isinstance(raw_call, dict):
|
|
1218
|
+
raise ExplorerError("ENDPOINT_BAD_RESPONSE", "tool call must be an object")
|
|
1219
|
+
call_id = raw_call.get("id")
|
|
1220
|
+
function = raw_call.get("function")
|
|
1221
|
+
if not isinstance(call_id, str) or not isinstance(function, dict):
|
|
1222
|
+
raise ExplorerError(
|
|
1223
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
1224
|
+
"tool call is missing id/function",
|
|
1225
|
+
)
|
|
1226
|
+
name = function.get("name")
|
|
1227
|
+
if not isinstance(name, str) or name not in ALLOWED_TOOLS:
|
|
1228
|
+
raise ExplorerError(
|
|
1229
|
+
"UNSUPPORTED_TOOL_CALL",
|
|
1230
|
+
"Endpoint requested an unsupported tool",
|
|
1231
|
+
details={"tool": name or "<missing>"},
|
|
1232
|
+
)
|
|
1233
|
+
raw_arguments = function.get("arguments", "{}")
|
|
1234
|
+
if not isinstance(raw_arguments, str):
|
|
1235
|
+
raise ExplorerError(
|
|
1236
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
1237
|
+
"tool arguments must be JSON text",
|
|
1238
|
+
)
|
|
1239
|
+
try:
|
|
1240
|
+
arguments = json.loads(raw_arguments)
|
|
1241
|
+
except json.JSONDecodeError as exc:
|
|
1242
|
+
raise ExplorerError(
|
|
1243
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
1244
|
+
"tool arguments must be valid JSON",
|
|
1245
|
+
) from exc
|
|
1246
|
+
if not isinstance(arguments, dict):
|
|
1247
|
+
raise ExplorerError(
|
|
1248
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
1249
|
+
"tool arguments must be an object",
|
|
1250
|
+
)
|
|
1251
|
+
return ToolCall(
|
|
1252
|
+
id=call_id,
|
|
1253
|
+
name=name,
|
|
1254
|
+
arguments=cast(dict[str, object], arguments),
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _assistant_tool_message(message: dict[str, Any]) -> dict[str, Any]:
|
|
1259
|
+
return {
|
|
1260
|
+
"role": "assistant",
|
|
1261
|
+
"content": message.get("content"),
|
|
1262
|
+
"tool_calls": message.get("tool_calls"),
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _model_observation_payload(
|
|
1267
|
+
observation: ToolObservation,
|
|
1268
|
+
*,
|
|
1269
|
+
max_chars: int | None = None,
|
|
1270
|
+
) -> dict[str, object]:
|
|
1271
|
+
payload = observation.to_dict()
|
|
1272
|
+
if (
|
|
1273
|
+
observation.ok
|
|
1274
|
+
and observation.content is not None
|
|
1275
|
+
and observation.line_range is not None
|
|
1276
|
+
):
|
|
1277
|
+
payload["content"] = _with_line_numbers(
|
|
1278
|
+
observation.content,
|
|
1279
|
+
observation.line_range,
|
|
1280
|
+
)
|
|
1281
|
+
if max_chars is not None:
|
|
1282
|
+
_cap_payload_content(payload, max_chars)
|
|
1283
|
+
return payload
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _cap_payload_content(payload: dict[str, object], max_chars: int) -> None:
|
|
1287
|
+
content = payload.get("content")
|
|
1288
|
+
if not isinstance(content, str) or len(content) <= max_chars:
|
|
1289
|
+
return
|
|
1290
|
+
payload["content"] = content[:max_chars]
|
|
1291
|
+
payload["content_truncated_by_chars"] = True
|
|
1292
|
+
payload["truncated"] = True
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _with_line_numbers(content: str, line_range: str) -> str:
|
|
1296
|
+
start, _ = _parse_line_range(line_range)
|
|
1297
|
+
line_number = start or 1
|
|
1298
|
+
lines: list[str] = []
|
|
1299
|
+
for offset, line in enumerate(content.splitlines(), start=0):
|
|
1300
|
+
lines.append(f"{line_number + offset}: {line}")
|
|
1301
|
+
if content.endswith("\n"):
|
|
1302
|
+
return "\n".join(lines) + "\n"
|
|
1303
|
+
return "\n".join(lines)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _execute_tool_calls_parallel(
|
|
1307
|
+
tool_calls: list[ToolCall],
|
|
1308
|
+
*,
|
|
1309
|
+
repo_root: Path,
|
|
1310
|
+
settings: Settings,
|
|
1311
|
+
) -> list[ToolObservation]:
|
|
1312
|
+
if len(tool_calls) <= 1 or settings.max_parallel_tools <= 1:
|
|
1313
|
+
return [
|
|
1314
|
+
_execute_tool_call(
|
|
1315
|
+
tool_call,
|
|
1316
|
+
repo_root=repo_root,
|
|
1317
|
+
settings=settings,
|
|
1318
|
+
)
|
|
1319
|
+
for tool_call in tool_calls
|
|
1320
|
+
]
|
|
1321
|
+
worker_count = min(settings.max_parallel_tools, len(tool_calls))
|
|
1322
|
+
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
|
1323
|
+
futures = [
|
|
1324
|
+
executor.submit(
|
|
1325
|
+
_execute_tool_call,
|
|
1326
|
+
tool_call,
|
|
1327
|
+
repo_root=repo_root,
|
|
1328
|
+
settings=settings,
|
|
1329
|
+
)
|
|
1330
|
+
for tool_call in tool_calls
|
|
1331
|
+
]
|
|
1332
|
+
return [future.result() for future in futures]
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _execute_tool_call(
|
|
1336
|
+
tool_call: ToolCall,
|
|
1337
|
+
*,
|
|
1338
|
+
repo_root: Path,
|
|
1339
|
+
settings: Settings,
|
|
1340
|
+
) -> ToolObservation:
|
|
1341
|
+
try:
|
|
1342
|
+
if tool_call.name == "read_file":
|
|
1343
|
+
start_line, end_line, clamped = _bounded_read_lines(
|
|
1344
|
+
tool_call.arguments,
|
|
1345
|
+
settings,
|
|
1346
|
+
)
|
|
1347
|
+
observation = read_file(
|
|
1348
|
+
repo_root=repo_root,
|
|
1349
|
+
path=_required_str(tool_call.arguments, "path"),
|
|
1350
|
+
start_line=start_line,
|
|
1351
|
+
end_line=end_line,
|
|
1352
|
+
max_bytes=settings.max_read_bytes,
|
|
1353
|
+
ignore=settings.ignore,
|
|
1354
|
+
)
|
|
1355
|
+
if clamped:
|
|
1356
|
+
observation.truncated = True
|
|
1357
|
+
return observation
|
|
1358
|
+
if tool_call.name == "repo_glob":
|
|
1359
|
+
return repo_glob(
|
|
1360
|
+
repo_root=repo_root,
|
|
1361
|
+
pattern=_required_str(tool_call.arguments, "pattern"),
|
|
1362
|
+
max_results=settings.max_grep_results,
|
|
1363
|
+
ignore=settings.ignore,
|
|
1364
|
+
)
|
|
1365
|
+
if tool_call.name == "repo_grep":
|
|
1366
|
+
requested_max = _optional_int(tool_call.arguments, "max_results")
|
|
1367
|
+
return repo_grep(
|
|
1368
|
+
repo_root=repo_root,
|
|
1369
|
+
pattern=_required_str(tool_call.arguments, "pattern"),
|
|
1370
|
+
glob=_optional_str(tool_call.arguments, "glob") or "**/*",
|
|
1371
|
+
max_results=requested_max or settings.max_grep_results,
|
|
1372
|
+
configured_max_results=settings.max_grep_results,
|
|
1373
|
+
ignore=settings.ignore,
|
|
1374
|
+
)
|
|
1375
|
+
except ExplorerError as exc:
|
|
1376
|
+
return ToolObservation.from_error(exc, tool_call_id=tool_call.id)
|
|
1377
|
+
raise AssertionError(f"unhandled tool: {tool_call.name}")
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _required_str(arguments: dict[str, object], key: str) -> str:
|
|
1381
|
+
value = arguments.get(key)
|
|
1382
|
+
if not isinstance(value, str) or not value:
|
|
1383
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", f"{key} is required")
|
|
1384
|
+
return value
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _optional_str(arguments: dict[str, object], key: str) -> str | None:
|
|
1388
|
+
value = arguments.get(key)
|
|
1389
|
+
if value is None:
|
|
1390
|
+
return None
|
|
1391
|
+
if not isinstance(value, str):
|
|
1392
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", f"{key} must be a string")
|
|
1393
|
+
return value
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _optional_int(arguments: dict[str, object], key: str) -> int | None:
|
|
1397
|
+
value = arguments.get(key)
|
|
1398
|
+
if value is None:
|
|
1399
|
+
return None
|
|
1400
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1401
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", f"{key} must be an integer")
|
|
1402
|
+
return value
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _int_argument(arguments: dict[str, object], key: str) -> int | None:
|
|
1406
|
+
value = arguments.get(key)
|
|
1407
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1408
|
+
return None
|
|
1409
|
+
return value
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _bounded_read_lines(
|
|
1413
|
+
arguments: dict[str, object],
|
|
1414
|
+
settings: Settings,
|
|
1415
|
+
) -> tuple[int | None, int | None, bool]:
|
|
1416
|
+
start_line = _optional_int(arguments, "start_line")
|
|
1417
|
+
end_line = _optional_int(arguments, "end_line")
|
|
1418
|
+
if start_line is not None and end_line is not None:
|
|
1419
|
+
max_end_line = start_line + settings.max_read_lines - 1
|
|
1420
|
+
if end_line > max_end_line:
|
|
1421
|
+
return start_line, max_end_line, True
|
|
1422
|
+
return start_line, end_line, False
|
|
1423
|
+
if start_line is None:
|
|
1424
|
+
start_line = 1
|
|
1425
|
+
return start_line, start_line + settings.max_read_lines - 1, True
|