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/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