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.
- {flash_context-0.1.0 → flash_context-0.2.0}/.env.example +5 -1
- {flash_context-0.1.0 → flash_context-0.2.0}/PKG-INFO +19 -6
- {flash_context-0.1.0 → flash_context-0.2.0}/README.md +17 -5
- {flash_context-0.1.0 → flash_context-0.2.0}/config.yaml.example +5 -1
- {flash_context-0.1.0 → flash_context-0.2.0}/pyproject.toml +3 -1
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/agent.py +142 -50
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/config.py +93 -49
- flash_context-0.2.0/src/flash_context/logging.py +87 -0
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/mcp_server.py +2 -16
- flash_context-0.2.0/src/flash_context/tools/glob.py +39 -0
- flash_context-0.2.0/src/flash_context/tools/grep.py +102 -0
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/tools/read.py +44 -16
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/tools/safety.py +83 -19
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/types.py +5 -0
- flash_context-0.1.0/src/flash_context/logging.py +0 -84
- flash_context-0.1.0/src/flash_context/tools/glob.py +0 -29
- flash_context-0.1.0/src/flash_context/tools/grep.py +0 -67
- {flash_context-0.1.0 → flash_context-0.2.0}/.gitignore +0 -0
- {flash_context-0.1.0 → flash_context-0.2.0}/LICENSE +0 -0
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/__init__.py +0 -0
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/cli.py +0 -0
- {flash_context-0.1.0 → flash_context-0.2.0}/src/flash_context/llm.py +0 -0
- {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
|
|
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.
|
|
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
|
+
[](https://github.com/zyf0717/flash-context/actions/workflows/ci.yml)
|
|
27
|
+
[](https://pypi.org/project/flash-context/)
|
|
28
|
+
[](https://pypi.org/project/flash-context/)
|
|
29
|
+
[](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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
[](https://github.com/zyf0717/flash-context/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/flash-context/)
|
|
5
|
+
[](https://pypi.org/project/flash-context/)
|
|
6
|
+
[](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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
|
602
|
-
|
|
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
|
|
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
|
|
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
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
+
)
|