flash-context 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (23) hide show
  1. {flash_context-0.1.0 → flash_context-0.2.0}/.env.example +5 -1
  2. {flash_context-0.1.0 → flash_context-0.2.0}/PKG-INFO +19 -6
  3. {flash_context-0.1.0 → flash_context-0.2.0}/README.md +17 -5
  4. {flash_context-0.1.0 → flash_context-0.2.0}/config.yaml.example +5 -1
  5. {flash_context-0.1.0 → flash_context-0.2.0}/pyproject.toml +3 -1
  6. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/agent.py +142 -50
  7. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/config.py +93 -49
  8. flash_context-0.2.0/src/flash_context/logging.py +87 -0
  9. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/mcp_server.py +2 -16
  10. flash_context-0.2.0/src/flash_context/tools/glob.py +39 -0
  11. flash_context-0.2.0/src/flash_context/tools/grep.py +102 -0
  12. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/tools/read.py +44 -16
  13. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/tools/safety.py +83 -19
  14. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/types.py +5 -0
  15. flash_context-0.1.0/src/flash_context/logging.py +0 -84
  16. flash_context-0.1.0/src/flash_context/tools/glob.py +0 -29
  17. flash_context-0.1.0/src/flash_context/tools/grep.py +0 -67
  18. {flash_context-0.1.0 → flash_context-0.2.0}/.gitignore +0 -0
  19. {flash_context-0.1.0 → flash_context-0.2.0}/LICENSE +0 -0
  20. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/__init__.py +0 -0
  21. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/cli.py +0 -0
  22. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/llm.py +0 -0
  23. {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/tools/__init__.py +0 -0
@@ -4,10 +4,14 @@ FLASH_CONTEXT_API_KEY=
4
4
  FLASH_CONTEXT_MAX_TURNS=6
5
5
  FLASH_CONTEXT_MAX_READ_BYTES=12000
6
6
  FLASH_CONTEXT_MAX_GREP_RESULTS=50
7
- FLASH_CONTEXT_TRAJ_DIR=.flash-context
7
+ FLASH_CONTEXT_TRAJ_DIR=
8
8
  FLASH_CONTEXT_TIMEOUT_SECONDS=120
9
9
  FLASH_CONTEXT_MAX_OBSERVATION_CHARS=6000
10
10
  FLASH_CONTEXT_MAX_READ_LINES=120
11
11
  FLASH_CONTEXT_MAX_COMPLETION_TOKENS=512
12
12
  FLASH_CONTEXT_TEMPERATURE=0
13
13
  FLASH_CONTEXT_MAX_PARALLEL_TOOLS=4
14
+ FLASH_CONTEXT_MAX_SCAN_BYTES=16777216
15
+ FLASH_CONTEXT_MAX_SCAN_ENTRIES=10000
16
+ FLASH_CONTEXT_TOOL_TIMEOUT_SECONDS=5
17
+ FLASH_CONTEXT_MAX_TOOL_CALLS_PER_TURN=16
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flash-context
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Read-only context exploration and retrieval for AI agents, starting with codebases and repositories.
5
5
  Project-URL: Repository, https://github.com/zyf0717/flash-context
6
6
  Project-URL: Issues, https://github.com/zyf0717/flash-context/issues
@@ -16,20 +16,33 @@ Classifier: Topic :: Software Development
16
16
  Requires-Python: >=3.13
17
17
  Requires-Dist: httpx>=0.28.0
18
18
  Requires-Dist: pyyaml>=6.0.0
19
+ Requires-Dist: regex>=2026.9.29
19
20
  Provides-Extra: mcp
20
21
  Requires-Dist: mcp<2,>=1.13.0; extra == 'mcp'
21
22
  Description-Content-Type: text/markdown
22
23
 
23
24
  # flash-context
24
25
 
26
+ [![CI](https://github.com/zyf0717/flash-context/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/zyf0717/flash-context/actions/workflows/ci.yml)
27
+ [![PyPI version](https://img.shields.io/pypi/v/flash-context)](https://pypi.org/project/flash-context/)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/flash-context)](https://pypi.org/project/flash-context/)
29
+ [![License](https://img.shields.io/pypi/l/flash-context)](LICENSE)
30
+
25
31
  Read-only context exploration and retrieval for AI agents,
26
32
  available as a Python CLI and Model Context Protocol (MCP) server.
27
33
 
28
- Ask natural-language questions across source code, documentation, configuration,
29
- and other local text files, and get validated file-line citations and snippets.
30
- The current focus is codebases and repositories. Both interfaces share one
31
- exploration core, backed by an OpenAI-compatible FastContext-style endpoint.
32
- Local tools enforce read-only access, path safety, and resource limits.
34
+ Delegate recursive, repetitive file search and reading to a utility model
35
+ behind an OpenAI-compatible endpoint. The calling agent receives validated
36
+ file-line citations and source snippets to use in its main task.
37
+
38
+ Codebases and repositories are the current focus; the same tools explore
39
+ documentation, configuration, and other local text files. CLI and MCP share one
40
+ exploration core with read-only tools, path safety, and resource limits.
41
+ Scans and regex execution are bounded; partial results report truncation.
42
+ Optional metadata-only trajectory logs must stay outside the inspected repository.
43
+
44
+ Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
45
+ on delegating repository exploration to a dedicated model.
33
46
 
34
47
  ## Quickstart
35
48
 
@@ -1,13 +1,25 @@
1
1
  # flash-context
2
2
 
3
+ [![CI](https://github.com/zyf0717/flash-context/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/zyf0717/flash-context/actions/workflows/ci.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/flash-context)](https://pypi.org/project/flash-context/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/flash-context)](https://pypi.org/project/flash-context/)
6
+ [![License](https://img.shields.io/pypi/l/flash-context)](LICENSE)
7
+
3
8
  Read-only context exploration and retrieval for AI agents,
4
9
  available as a Python CLI and Model Context Protocol (MCP) server.
5
10
 
6
- Ask natural-language questions across source code, documentation, configuration,
7
- and other local text files, and get validated file-line citations and snippets.
8
- The current focus is codebases and repositories. Both interfaces share one
9
- exploration core, backed by an OpenAI-compatible FastContext-style endpoint.
10
- Local tools enforce read-only access, path safety, and resource limits.
11
+ Delegate recursive, repetitive file search and reading to a utility model
12
+ behind an OpenAI-compatible endpoint. The calling agent receives validated
13
+ file-line citations and source snippets to use in its main task.
14
+
15
+ Codebases and repositories are the current focus; the same tools explore
16
+ documentation, configuration, and other local text files. CLI and MCP share one
17
+ exploration core with read-only tools, path safety, and resource limits.
18
+ Scans and regex execution are bounded; partial results report truncation.
19
+ Optional metadata-only trajectory logs must stay outside the inspected repository.
20
+
21
+ Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
22
+ on delegating repository exploration to a dedicated model.
11
23
 
12
24
  ## Quickstart
13
25
 
@@ -8,7 +8,7 @@ explorer:
8
8
  - "dist"
9
9
  - "build"
10
10
  # Relative paths resolve from the flash-context project root.
11
- traj_dir: ".flash-context"
11
+ traj_dir: null
12
12
 
13
13
  model:
14
14
  base_url: "http://100.105.153.85:1234/v1"
@@ -24,3 +24,7 @@ tools:
24
24
  max_observation_chars: 6000
25
25
  max_read_lines: 120
26
26
  max_parallel_tools: 4
27
+ max_scan_bytes: 16777216
28
+ max_scan_entries: 10000
29
+ tool_timeout_seconds: 5
30
+ max_tool_calls_per_turn: 16
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flash-context"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Read-only context exploration and retrieval for AI agents, starting with codebases and repositories."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -18,6 +18,7 @@ classifiers = [
18
18
  dependencies = [
19
19
  "httpx>=0.28.0",
20
20
  "PyYAML>=6.0.0",
21
+ "regex>=2026.9.29",
21
22
  ]
22
23
 
23
24
  [project.urls]
@@ -36,6 +37,7 @@ dev = [
36
37
  "pytest>=8.3.0",
37
38
  "ruff>=0.9.0",
38
39
  "types-PyYAML>=6.0.12",
40
+ "types-regex>=2026.9.29.20261030",
39
41
  ]
40
42
 
41
43
  [build-system]
@@ -9,7 +9,7 @@ from typing import Any, Literal, cast
9
9
 
10
10
  from flash_context.config import Settings
11
11
  from flash_context.llm import ChatClient
12
- from flash_context.logging import TrajectoryRecorder
12
+ from flash_context.logging import TrajectoryRecorder, validate_trajectory_dir
13
13
  from flash_context.tools import read_file, repo_glob, repo_grep
14
14
  from flash_context.types import (
15
15
  Citation,
@@ -57,16 +57,9 @@ MARKDOWN_FILE_LINES_RE = re.compile(
57
57
  EXPLICIT_PATH_RE = re.compile(
58
58
  r"`?(?P<path>(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)`?"
59
59
  )
60
- BACKTICK_OR_QUOTE_RE = re.compile(
61
- r"[`'\"](?P<token>[A-Za-z_][A-Za-z0-9_]*)[`'\"]"
62
- )
60
+ BACKTICK_OR_QUOTE_RE = re.compile(r"[`'\"](?P<token>[A-Za-z_][A-Za-z0-9_]*)[`'\"]")
63
61
  EXACT_TEXT_RE = re.compile(r"[`'\"](?P<target>[^`'\"]{1,120})[`'\"]")
64
62
  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
63
 
71
64
 
72
65
  @dataclass(slots=True)
@@ -74,7 +67,6 @@ class EvidenceState:
74
67
  reads: list[ToolObservation] = field(default_factory=list)
75
68
  citations: list[Citation] = field(default_factory=list)
76
69
  seen_tool_calls: set[str] = field(default_factory=set)
77
- repeated_tool_calls: int = 0
78
70
 
79
71
 
80
72
  def explore(
@@ -85,6 +77,7 @@ def explore(
85
77
  ) -> ExploreResult:
86
78
  request.validate()
87
79
  repo_root = request.repo_root.resolve()
80
+ validate_trajectory_dir(settings.traj_dir, repo_root)
88
81
  fast_result = _try_exact_fast_path(
89
82
  request,
90
83
  repo_root=repo_root,
@@ -102,6 +95,7 @@ def explore(
102
95
  pattern="**/*",
103
96
  max_results=settings.max_grep_results,
104
97
  ignore=settings.ignore,
98
+ limits=settings.limits,
105
99
  )
106
100
  messages = _initial_messages(request, file_sample, settings)
107
101
  tools = tool_schemas()
@@ -114,6 +108,41 @@ def explore(
114
108
  for turn in range(1, effective_max_turns + 1):
115
109
  response = chat_client.chat_completion(messages=messages, tools=tools)
116
110
  message = _extract_message(response)
111
+ raw_calls = message.get("tool_calls")
112
+ if (
113
+ isinstance(raw_calls, list)
114
+ and len(raw_calls) > settings.max_tool_calls_per_turn
115
+ ):
116
+ raise ExplorerError(
117
+ "ENDPOINT_BAD_RESPONSE", "Too many tool calls in one turn"
118
+ )
119
+ if response["choices"][0].get("finish_reason") == "length":
120
+ truncated = True
121
+ warnings.append("endpoint completion token limit reached")
122
+ citations = _validated_citations_from_content(
123
+ str(message.get("content") or ""),
124
+ repo_root=repo_root,
125
+ settings=settings,
126
+ evidence=evidence,
127
+ warnings=warnings,
128
+ ) or _best_current_citations(evidence)
129
+ if not citations:
130
+ raise ExplorerError(
131
+ "ENDPOINT_BAD_RESPONSE",
132
+ "Completion token limit reached without evidence",
133
+ )
134
+ result = _finalize_result(
135
+ request=request,
136
+ repo_root=repo_root,
137
+ settings=settings,
138
+ answer="",
139
+ citations=citations,
140
+ turns_used=turn,
141
+ truncated=True,
142
+ warnings=warnings,
143
+ )
144
+ recorder.finish(result=result)
145
+ return result
117
146
  recorder.record_model_turn(turn, message)
118
147
  content_text = str(message.get("content") or "").strip()
119
148
  if content_text:
@@ -191,7 +220,6 @@ def explore(
191
220
 
192
221
  repeated_call = _first_repeated_tool_call(evidence, tool_calls)
193
222
  if repeated_call is not None:
194
- evidence.repeated_tool_calls += 1
195
223
  _append_warning_once(
196
224
  warnings,
197
225
  "repeated tool call stopped exploration",
@@ -220,7 +248,13 @@ def explore(
220
248
  )
221
249
  for tool_call, observation in zip(tool_calls, observations, strict=True):
222
250
  observation.tool_call_id = tool_call.id
223
- truncated = truncated or observation.truncated
251
+ payload = _model_observation_payload(
252
+ observation,
253
+ max_chars=settings.max_observation_chars,
254
+ )
255
+ truncated = truncated or bool(payload["truncated"])
256
+ for warning in observation.warnings:
257
+ _append_warning_once(warnings, warning)
224
258
  if (
225
259
  tool_call.name == "read_file"
226
260
  and observation.ok
@@ -235,10 +269,7 @@ def explore(
235
269
  "tool_call_id": tool_call.id,
236
270
  "name": tool_call.name,
237
271
  "content": json.dumps(
238
- _model_observation_payload(
239
- observation,
240
- max_chars=settings.max_observation_chars,
241
- ),
272
+ payload,
242
273
  sort_keys=True,
243
274
  ),
244
275
  }
@@ -401,7 +432,11 @@ def _finalize_result(
401
432
  citations=citations,
402
433
  raw_locations=raw_locations,
403
434
  turns_used=turns_used,
404
- truncated=truncated,
435
+ truncated=(
436
+ truncated
437
+ or any(x.truncated for x in raw_locations)
438
+ or "citation verification limit reached" in warnings
439
+ ),
405
440
  warnings=warnings,
406
441
  )
407
442
 
@@ -460,9 +495,7 @@ def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
460
495
  current_reason_index = index
461
496
  continue
462
497
 
463
- merged.append(
464
- Citation(path, current_start, current_end, current_reason)
465
- )
498
+ merged.append(Citation(path, current_start, current_end, current_reason))
466
499
  current_start = start
467
500
  current_end = end
468
501
  current_reason = citation.reason
@@ -495,6 +528,7 @@ def _extract_raw_locations(
495
528
  end_line=citation.end_line,
496
529
  max_bytes=settings.max_read_bytes,
497
530
  ignore=settings.ignore,
531
+ limits=settings.limits,
498
532
  )
499
533
  except ExplorerError:
500
534
  _append_warning_once(warnings, "raw location could not be read")
@@ -528,7 +562,7 @@ def _try_exact_fast_path(
528
562
  repo_root: Path,
529
563
  settings: Settings,
530
564
  ) -> ExploreResult | None:
531
- if _is_semantic_query(request.query):
565
+ if not _is_exact_lookup(request.query):
532
566
  return None
533
567
  evidence = EvidenceState()
534
568
  warnings: list[str] = []
@@ -598,8 +632,42 @@ def _try_exact_fast_path(
598
632
  )
599
633
 
600
634
 
601
- def _is_semantic_query(query: str) -> bool:
602
- return bool(SEMANTIC_QUERY_RE.search(query))
635
+ def _is_exact_lookup(query: str) -> bool:
636
+ # Admit a small lookup vocabulary; any unexplained intent goes to the model.
637
+ paths = list(EXPLICIT_PATH_RE.finditer(query))
638
+ targets = list(EXACT_TEXT_RE.finditer(query))
639
+ if len(paths) > 1 or len(targets) > 1:
640
+ return False
641
+ remainder = CITATION_RE.sub(" ", query)
642
+ remainder = EXPLICIT_PATH_RE.sub(" ", remainder)
643
+ remainder = EXACT_TEXT_RE.sub(" ", remainder)
644
+ words = [m.group().lower() for m in TOKEN_RE.finditer(remainder)]
645
+ allowed = {
646
+ "find",
647
+ "return",
648
+ "show",
649
+ "use",
650
+ "in",
651
+ "the",
652
+ "definition",
653
+ "of",
654
+ "function",
655
+ "class",
656
+ "symbol",
657
+ "file",
658
+ "line",
659
+ "lines",
660
+ "citations",
661
+ "citation",
662
+ "only",
663
+ "where",
664
+ }
665
+ if not paths and not targets and not extract_citations(query):
666
+ unknown = [word for word in words if word not in allowed]
667
+ return len(unknown) == 1 and all(
668
+ word in allowed or word == unknown[0] for word in words
669
+ )
670
+ return all(word in allowed for word in words)
603
671
 
604
672
 
605
673
  def _extract_explicit_path(query: str) -> str | None:
@@ -735,6 +803,7 @@ def _grep_exact_target(
735
803
  glob=glob,
736
804
  max_results=MAX_FINAL_CITATIONS + 1,
737
805
  ignore=settings.ignore,
806
+ limits=settings.limits,
738
807
  )
739
808
 
740
809
 
@@ -765,9 +834,7 @@ def _line_matches_target(
765
834
  definitions_only: bool,
766
835
  ) -> bool:
767
836
  escaped = re.escape(target)
768
- definition_pattern = (
769
- rf"^\s*(?:async\s+def|def|class)\s+{escaped}\b"
770
- )
837
+ definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{escaped}\b"
771
838
  if re.search(definition_pattern, line):
772
839
  return True
773
840
  if definitions_only:
@@ -788,6 +855,8 @@ def _validated_citations_from_content(
788
855
  parsed = extract_citations(content)
789
856
  if not parsed:
790
857
  return []
858
+ if len(parsed) > 16:
859
+ _append_warning_once(warnings, "citation verification limit reached")
791
860
  validated = _validate_citations(
792
861
  parsed,
793
862
  repo_root=repo_root,
@@ -807,7 +876,7 @@ def _validate_citations(
807
876
  evidence: EvidenceState,
808
877
  ) -> list[Citation]:
809
878
  validated: list[Citation] = []
810
- for citation in citations:
879
+ for citation in citations[:16]:
811
880
  normalized = _normalize_citation(citation)
812
881
  if normalized is None:
813
882
  continue
@@ -822,9 +891,7 @@ def _validate_citations(
822
891
  )
823
892
  if verified is not None:
824
893
  validated.append(verified)
825
- return _merge_adjacent_citations(_dedupe_citations(validated))[
826
- :MAX_FINAL_CITATIONS
827
- ]
894
+ return _merge_adjacent_citations(_dedupe_citations(validated))[:MAX_FINAL_CITATIONS]
828
895
 
829
896
 
830
897
  def _normalize_citation(citation: Citation) -> Citation | None:
@@ -905,6 +972,7 @@ def _verify_citation_by_read(
905
972
  end_line=citation.end_line,
906
973
  max_bytes=settings.max_read_bytes,
907
974
  ignore=settings.ignore,
975
+ limits=settings.limits,
908
976
  )
909
977
  except ExplorerError:
910
978
  return None
@@ -973,9 +1041,13 @@ def _should_finalize_before_tool_calls(
973
1041
  tool_calls: list[ToolCall],
974
1042
  settings: Settings,
975
1043
  ) -> bool:
976
- if not request.citation or _best_narrow_read_citation(evidence, settings) is None:
1044
+ if (
1045
+ not request.citation
1046
+ or not _is_exact_lookup(request.query)
1047
+ or _best_narrow_read_citation(evidence, settings) is None
1048
+ ):
977
1049
  return False
978
- return any(
1050
+ return bool(tool_calls) and all(
979
1051
  _is_broad_read_on_seen_path(tool_call, evidence, settings)
980
1052
  for tool_call in tool_calls
981
1053
  )
@@ -1042,10 +1114,7 @@ def _first_repeated_tool_call(
1042
1114
  batch_fingerprints: set[str] = set()
1043
1115
  for tool_call in tool_calls:
1044
1116
  fingerprint = _tool_call_fingerprint(tool_call)
1045
- if (
1046
- fingerprint in evidence.seen_tool_calls
1047
- or fingerprint in batch_fingerprints
1048
- ):
1117
+ if fingerprint in evidence.seen_tool_calls or fingerprint in batch_fingerprints:
1049
1118
  return tool_call
1050
1119
  batch_fingerprints.add(fingerprint)
1051
1120
  return None
@@ -1096,10 +1165,7 @@ def _citations_from_observations(
1096
1165
  for observation in observations:
1097
1166
  if observation.path is None or observation.line_range is None:
1098
1167
  continue
1099
- if (
1100
- observation.path not in answer
1101
- and Path(observation.path).name not in answer
1102
- ):
1168
+ if observation.path not in answer and Path(observation.path).name not in answer:
1103
1169
  continue
1104
1170
  start, end = _parse_line_range(observation.line_range)
1105
1171
  key = (observation.path, start, end)
@@ -1198,6 +1264,17 @@ def _extract_message(response: dict[str, Any]) -> dict[str, Any]:
1198
1264
  raise ExplorerError(
1199
1265
  "ENDPOINT_BAD_RESPONSE", "Endpoint message must be an object"
1200
1266
  )
1267
+ content = message.get("content")
1268
+ if content is not None and not isinstance(content, str):
1269
+ raise ExplorerError("ENDPOINT_BAD_RESPONSE", "Message content must be text")
1270
+ if (
1271
+ (not content or not content.strip())
1272
+ and not message.get("tool_calls")
1273
+ and first.get("finish_reason") != "length"
1274
+ ):
1275
+ raise ExplorerError(
1276
+ "ENDPOINT_BAD_RESPONSE", "Endpoint returned an empty message"
1277
+ )
1201
1278
  return cast(dict[str, Any], message)
1202
1279
 
1203
1280
 
@@ -1284,6 +1361,18 @@ def _model_observation_payload(
1284
1361
 
1285
1362
 
1286
1363
  def _cap_payload_content(payload: dict[str, object], max_chars: int) -> None:
1364
+ for key in ("hits", "matches"):
1365
+ rows = payload.get(key)
1366
+ if isinstance(rows, list):
1367
+ kept = []
1368
+ size = 0
1369
+ for row in rows:
1370
+ size += len(json.dumps(row))
1371
+ if size > max_chars:
1372
+ payload["truncated"] = True
1373
+ break
1374
+ kept.append(row)
1375
+ payload[key] = kept
1287
1376
  content = payload.get("content")
1288
1377
  if not isinstance(content, str) or len(content) <= max_chars:
1289
1378
  return
@@ -1351,9 +1440,9 @@ def _execute_tool_call(
1351
1440
  end_line=end_line,
1352
1441
  max_bytes=settings.max_read_bytes,
1353
1442
  ignore=settings.ignore,
1443
+ limits=settings.limits,
1444
+ preview=clamped,
1354
1445
  )
1355
- if clamped:
1356
- observation.truncated = True
1357
1446
  return observation
1358
1447
  if tool_call.name == "repo_glob":
1359
1448
  return repo_glob(
@@ -1361,6 +1450,7 @@ def _execute_tool_call(
1361
1450
  pattern=_required_str(tool_call.arguments, "pattern"),
1362
1451
  max_results=settings.max_grep_results,
1363
1452
  ignore=settings.ignore,
1453
+ limits=settings.limits,
1364
1454
  )
1365
1455
  if tool_call.name == "repo_grep":
1366
1456
  requested_max = _optional_int(tool_call.arguments, "max_results")
@@ -1371,6 +1461,7 @@ def _execute_tool_call(
1371
1461
  max_results=requested_max or settings.max_grep_results,
1372
1462
  configured_max_results=settings.max_grep_results,
1373
1463
  ignore=settings.ignore,
1464
+ limits=settings.limits,
1374
1465
  )
1375
1466
  except ExplorerError as exc:
1376
1467
  return ToolObservation.from_error(exc, tool_call_id=tool_call.id)
@@ -1415,11 +1506,12 @@ def _bounded_read_lines(
1415
1506
  ) -> tuple[int | None, int | None, bool]:
1416
1507
  start_line = _optional_int(arguments, "start_line")
1417
1508
  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
1509
+ start_line = 1 if start_line is None else start_line
1510
+ if start_line <= 0 or (end_line is not None and end_line < start_line):
1511
+ raise ExplorerError("INVALID_TOOL_ARGUMENTS", "Invalid read line range")
1512
+ max_end = start_line + settings.max_read_lines - 1
1513
+ return (
1514
+ start_line,
1515
+ min(end_line, max_end) if end_line is not None else max_end,
1516
+ (end_line is None or end_line > max_end),
1517
+ )