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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from flash_context.agent import explore
|
|
9
|
+
from flash_context.config import Settings, SettingsOverrides, load_settings
|
|
10
|
+
from flash_context.types import ExploreRequest, ExplorerError, ExploreResult
|
|
11
|
+
|
|
12
|
+
CoreFn = Callable[[ExploreRequest, Settings], ExploreResult]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def explore_repository_handler(
|
|
16
|
+
*,
|
|
17
|
+
query: str,
|
|
18
|
+
repo_root: str | None = None,
|
|
19
|
+
max_turns: int | None = None,
|
|
20
|
+
citation: bool | None = None,
|
|
21
|
+
settings: Settings | None = None,
|
|
22
|
+
core: CoreFn = explore,
|
|
23
|
+
) -> dict[str, object]:
|
|
24
|
+
root = Path(repo_root) if repo_root is not None else Path.cwd()
|
|
25
|
+
try:
|
|
26
|
+
effective_settings = settings or load_settings(
|
|
27
|
+
repo_root=root,
|
|
28
|
+
overrides=SettingsOverrides(max_turns=max_turns),
|
|
29
|
+
)
|
|
30
|
+
if max_turns is not None and settings is not None:
|
|
31
|
+
effective_settings = Settings(
|
|
32
|
+
base_url=settings.base_url,
|
|
33
|
+
model=settings.model,
|
|
34
|
+
api_key=settings.api_key,
|
|
35
|
+
max_turns=max_turns,
|
|
36
|
+
max_read_bytes=settings.max_read_bytes,
|
|
37
|
+
max_grep_results=settings.max_grep_results,
|
|
38
|
+
traj_dir=settings.traj_dir,
|
|
39
|
+
ignore=settings.ignore,
|
|
40
|
+
timeout_seconds=settings.timeout_seconds,
|
|
41
|
+
max_observation_chars=settings.max_observation_chars,
|
|
42
|
+
max_read_lines=settings.max_read_lines,
|
|
43
|
+
max_completion_tokens=settings.max_completion_tokens,
|
|
44
|
+
temperature=settings.temperature,
|
|
45
|
+
max_parallel_tools=settings.max_parallel_tools,
|
|
46
|
+
)
|
|
47
|
+
request = ExploreRequest(
|
|
48
|
+
query=query,
|
|
49
|
+
repo_root=root,
|
|
50
|
+
max_turns=effective_settings.max_turns,
|
|
51
|
+
citation=True if citation is None else citation,
|
|
52
|
+
format="json",
|
|
53
|
+
)
|
|
54
|
+
return core(request, effective_settings).to_dict()
|
|
55
|
+
except ExplorerError as exc:
|
|
56
|
+
return {"error": exc.to_dict()}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_server() -> Any:
|
|
60
|
+
try:
|
|
61
|
+
fastmcp_module = import_module("mcp.server.fastmcp")
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
raise ExplorerError(
|
|
64
|
+
"CONFIG_INVALID",
|
|
65
|
+
"MCP SDK is not installed; install flash-context[mcp]",
|
|
66
|
+
) from exc
|
|
67
|
+
fast_mcp = fastmcp_module.FastMCP
|
|
68
|
+
server = fast_mcp("flash-context")
|
|
69
|
+
|
|
70
|
+
@server.tool() # type: ignore[untyped-decorator]
|
|
71
|
+
def explore_repository(
|
|
72
|
+
query: str,
|
|
73
|
+
repo_root: str | None = None,
|
|
74
|
+
max_turns: int | None = None,
|
|
75
|
+
citation: bool | None = None,
|
|
76
|
+
) -> dict[str, object]:
|
|
77
|
+
return explore_repository_handler(
|
|
78
|
+
query=query,
|
|
79
|
+
repo_root=repo_root,
|
|
80
|
+
max_turns=max_turns,
|
|
81
|
+
citation=citation,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return server
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_mcp(*, transport: str = "stdio") -> None:
|
|
88
|
+
server = create_server()
|
|
89
|
+
server.run(transport=transport)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from flash_context.tools.safety import PathSafety
|
|
6
|
+
from flash_context.types import ExplorerError, ToolObservation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def repo_glob(
|
|
10
|
+
*,
|
|
11
|
+
repo_root: Path,
|
|
12
|
+
pattern: str,
|
|
13
|
+
max_results: int,
|
|
14
|
+
ignore: list[str] | None = None,
|
|
15
|
+
) -> ToolObservation:
|
|
16
|
+
if not pattern:
|
|
17
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", "pattern is required")
|
|
18
|
+
if max_results <= 0:
|
|
19
|
+
raise ExplorerError("CONFIG_INVALID", "max_results must be positive")
|
|
20
|
+
safety = PathSafety(repo_root, tuple(ignore or ()))
|
|
21
|
+
matches: list[str] = []
|
|
22
|
+
truncated = False
|
|
23
|
+
for path in safety.iter_safe_files(pattern):
|
|
24
|
+
if len(matches) >= max_results:
|
|
25
|
+
truncated = True
|
|
26
|
+
break
|
|
27
|
+
matches.append(safety.relative_or_raw(path))
|
|
28
|
+
return ToolObservation(matches=matches, truncated=truncated)
|
|
29
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from flash_context.tools.safety import PathSafety
|
|
8
|
+
from flash_context.types import ExplorerError, SearchHit, ToolObservation
|
|
9
|
+
|
|
10
|
+
MAX_HIT_TEXT = 240
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def repo_grep(
|
|
14
|
+
*,
|
|
15
|
+
repo_root: Path,
|
|
16
|
+
pattern: str,
|
|
17
|
+
max_results: int,
|
|
18
|
+
glob: str = "**/*",
|
|
19
|
+
configured_max_results: int | None = None,
|
|
20
|
+
ignore: list[str] | None = None,
|
|
21
|
+
) -> ToolObservation:
|
|
22
|
+
if not pattern:
|
|
23
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", "pattern is required")
|
|
24
|
+
if max_results <= 0:
|
|
25
|
+
raise ExplorerError("CONFIG_INVALID", "max_results must be positive")
|
|
26
|
+
limit = min(max_results, configured_max_results or max_results)
|
|
27
|
+
try:
|
|
28
|
+
regex = re.compile(pattern)
|
|
29
|
+
except re.error as exc:
|
|
30
|
+
raise ExplorerError(
|
|
31
|
+
"INVALID_GREP_PATTERN",
|
|
32
|
+
"Grep pattern is not a valid regular expression",
|
|
33
|
+
details={"error": str(exc)},
|
|
34
|
+
) from exc
|
|
35
|
+
|
|
36
|
+
safety = PathSafety(repo_root, tuple(ignore or ()))
|
|
37
|
+
hits: list[SearchHit] = []
|
|
38
|
+
truncated = False
|
|
39
|
+
for path in safety.iter_safe_files(glob or "**/*"):
|
|
40
|
+
for line_number, line in _iter_text_lines(path):
|
|
41
|
+
if not regex.search(line):
|
|
42
|
+
continue
|
|
43
|
+
if len(hits) >= limit:
|
|
44
|
+
truncated = True
|
|
45
|
+
return ToolObservation(hits=hits, truncated=truncated)
|
|
46
|
+
hits.append(
|
|
47
|
+
SearchHit(
|
|
48
|
+
path=safety.relative_or_raw(path),
|
|
49
|
+
line=line_number,
|
|
50
|
+
text=_cap_text(line.rstrip("\n")),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return ToolObservation(hits=hits, truncated=truncated)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _iter_text_lines(path: Path) -> Iterator[tuple[int, str]]:
|
|
57
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
58
|
+
for line_number, line in enumerate(handle, start=1):
|
|
59
|
+
if "\x00" in line:
|
|
60
|
+
continue
|
|
61
|
+
yield line_number, line
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cap_text(text: str) -> str:
|
|
65
|
+
if len(text) <= MAX_HIT_TEXT:
|
|
66
|
+
return text
|
|
67
|
+
return f"{text[: MAX_HIT_TEXT - 1]}..."
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from flash_context.tools.safety import PathSafety
|
|
6
|
+
from flash_context.types import ExplorerError, ToolObservation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_file(
|
|
10
|
+
*,
|
|
11
|
+
repo_root: Path,
|
|
12
|
+
path: str,
|
|
13
|
+
max_bytes: int,
|
|
14
|
+
start_line: int | None = None,
|
|
15
|
+
end_line: int | None = None,
|
|
16
|
+
ignore: list[str] | None = None,
|
|
17
|
+
) -> ToolObservation:
|
|
18
|
+
if not path:
|
|
19
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", "path is required")
|
|
20
|
+
if start_line is not None and start_line <= 0:
|
|
21
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", "start_line must be positive")
|
|
22
|
+
if end_line is not None and end_line <= 0:
|
|
23
|
+
raise ExplorerError("INVALID_TOOL_ARGUMENTS", "end_line must be positive")
|
|
24
|
+
if start_line is not None and end_line is not None and end_line < start_line:
|
|
25
|
+
raise ExplorerError(
|
|
26
|
+
"INVALID_TOOL_ARGUMENTS",
|
|
27
|
+
"end_line must be greater than or equal to start_line",
|
|
28
|
+
)
|
|
29
|
+
if max_bytes <= 0:
|
|
30
|
+
raise ExplorerError("CONFIG_INVALID", "max_bytes must be positive")
|
|
31
|
+
|
|
32
|
+
safety = PathSafety(repo_root, tuple(ignore or ()))
|
|
33
|
+
resolved = safety.resolve_existing_file(path)
|
|
34
|
+
rel_path = safety.relative_or_raw(resolved)
|
|
35
|
+
first_line = start_line or 1
|
|
36
|
+
last_line = end_line
|
|
37
|
+
content_bytes = bytearray()
|
|
38
|
+
actual_last: int | None = None
|
|
39
|
+
total_lines = 0
|
|
40
|
+
truncated = False
|
|
41
|
+
|
|
42
|
+
with resolved.open("rb") as handle:
|
|
43
|
+
for line_number, raw_line in enumerate(handle, start=1):
|
|
44
|
+
total_lines = line_number
|
|
45
|
+
if line_number < first_line:
|
|
46
|
+
continue
|
|
47
|
+
if last_line is not None and line_number > last_line:
|
|
48
|
+
break
|
|
49
|
+
if len(content_bytes) + len(raw_line) > max_bytes:
|
|
50
|
+
remaining = max_bytes - len(content_bytes)
|
|
51
|
+
if remaining > 0:
|
|
52
|
+
content_bytes.extend(raw_line[:remaining])
|
|
53
|
+
actual_last = line_number
|
|
54
|
+
truncated = True
|
|
55
|
+
break
|
|
56
|
+
content_bytes.extend(raw_line)
|
|
57
|
+
actual_last = line_number
|
|
58
|
+
|
|
59
|
+
if actual_last is None:
|
|
60
|
+
raise ExplorerError(
|
|
61
|
+
"INVALID_TOOL_ARGUMENTS",
|
|
62
|
+
"Requested start_line is beyond the end of file",
|
|
63
|
+
details={
|
|
64
|
+
"path": rel_path,
|
|
65
|
+
"start_line": first_line,
|
|
66
|
+
"total_lines": total_lines,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
content = content_bytes.decode("utf-8", errors="replace")
|
|
71
|
+
line_range = f"{first_line}-{actual_last}"
|
|
72
|
+
return ToolObservation(
|
|
73
|
+
path=rel_path,
|
|
74
|
+
line_range=line_range,
|
|
75
|
+
content=content,
|
|
76
|
+
truncated=truncated,
|
|
77
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from flash_context.types import ExplorerError
|
|
8
|
+
|
|
9
|
+
DEFAULT_DENY_PATTERNS = (
|
|
10
|
+
".git/**",
|
|
11
|
+
".env",
|
|
12
|
+
".env.*",
|
|
13
|
+
"*.pem",
|
|
14
|
+
"*.key",
|
|
15
|
+
"id_rsa",
|
|
16
|
+
"id_ed25519",
|
|
17
|
+
"**/secrets/**",
|
|
18
|
+
"**/.aws/**",
|
|
19
|
+
"**/.ssh/**",
|
|
20
|
+
".venv/**",
|
|
21
|
+
"venv/**",
|
|
22
|
+
"node_modules/**",
|
|
23
|
+
"dist/**",
|
|
24
|
+
"build/**",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DENY_DIR_NAMES = {
|
|
28
|
+
".git",
|
|
29
|
+
".aws",
|
|
30
|
+
".ssh",
|
|
31
|
+
".venv",
|
|
32
|
+
"venv",
|
|
33
|
+
"node_modules",
|
|
34
|
+
"dist",
|
|
35
|
+
"build",
|
|
36
|
+
"secrets",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
DENY_FILE_NAMES = {"id_rsa", "id_ed25519", ".env"}
|
|
40
|
+
DENY_FILE_SUFFIXES = (".pem", ".key")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class PathSafety:
|
|
45
|
+
repo_root: Path
|
|
46
|
+
ignore: tuple[str, ...] = field(default_factory=tuple)
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
root = self.repo_root.resolve()
|
|
50
|
+
object.__setattr__(self, "repo_root", root)
|
|
51
|
+
if not root.exists() or not root.is_dir():
|
|
52
|
+
raise ExplorerError(
|
|
53
|
+
"REPO_NOT_FOUND",
|
|
54
|
+
"Repository root must exist and be a directory",
|
|
55
|
+
details={"repo_root": str(root)},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def resolve_existing_file(self, requested: str | Path) -> Path:
|
|
59
|
+
raw = Path(requested)
|
|
60
|
+
if ".." in raw.parts:
|
|
61
|
+
raise ExplorerError(
|
|
62
|
+
"PATH_OUTSIDE_ROOT",
|
|
63
|
+
"Requested path is outside the repository root",
|
|
64
|
+
details={"path": str(requested)},
|
|
65
|
+
)
|
|
66
|
+
candidate = raw if raw.is_absolute() else self.repo_root / raw
|
|
67
|
+
resolved = candidate.resolve(strict=False)
|
|
68
|
+
if not _is_relative_to(resolved, self.repo_root):
|
|
69
|
+
raise ExplorerError(
|
|
70
|
+
"PATH_OUTSIDE_ROOT",
|
|
71
|
+
"Requested path is outside the repository root",
|
|
72
|
+
details={"path": str(requested)},
|
|
73
|
+
)
|
|
74
|
+
if not resolved.exists():
|
|
75
|
+
raise ExplorerError(
|
|
76
|
+
"PATH_NOT_FOUND",
|
|
77
|
+
"Requested path does not exist",
|
|
78
|
+
details={"path": self.relative_or_raw(resolved)},
|
|
79
|
+
)
|
|
80
|
+
if not resolved.is_file():
|
|
81
|
+
raise ExplorerError(
|
|
82
|
+
"PATH_DENIED",
|
|
83
|
+
"Requested path is not a regular file",
|
|
84
|
+
details={"path": self.relative_or_raw(resolved)},
|
|
85
|
+
)
|
|
86
|
+
self.ensure_allowed(resolved)
|
|
87
|
+
return resolved
|
|
88
|
+
|
|
89
|
+
def ensure_allowed(self, path: Path) -> None:
|
|
90
|
+
rel = self.relative_or_raw(path)
|
|
91
|
+
parts = rel.split("/")
|
|
92
|
+
if any(part in DENY_DIR_NAMES for part in parts[:-1]):
|
|
93
|
+
raise ExplorerError(
|
|
94
|
+
"PATH_DENIED",
|
|
95
|
+
"Requested path is denied by repository safety policy",
|
|
96
|
+
details={"path": rel},
|
|
97
|
+
)
|
|
98
|
+
name = parts[-1] if parts else rel
|
|
99
|
+
if (
|
|
100
|
+
name in DENY_FILE_NAMES
|
|
101
|
+
or name.startswith(".env.")
|
|
102
|
+
or name.endswith(DENY_FILE_SUFFIXES)
|
|
103
|
+
or _matches_patterns(rel, DEFAULT_DENY_PATTERNS)
|
|
104
|
+
or _matches_patterns(rel, self.ignore)
|
|
105
|
+
):
|
|
106
|
+
raise ExplorerError(
|
|
107
|
+
"PATH_DENIED",
|
|
108
|
+
"Requested path is denied by repository safety policy",
|
|
109
|
+
details={"path": rel},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def relative_or_raw(self, path: Path) -> str:
|
|
113
|
+
try:
|
|
114
|
+
return path.resolve(strict=False).relative_to(self.repo_root).as_posix()
|
|
115
|
+
except ValueError:
|
|
116
|
+
return path.as_posix()
|
|
117
|
+
|
|
118
|
+
def validate_glob_pattern(self, pattern: str) -> None:
|
|
119
|
+
raw = Path(pattern)
|
|
120
|
+
if raw.is_absolute() or ".." in raw.parts:
|
|
121
|
+
raise ExplorerError(
|
|
122
|
+
"PATH_OUTSIDE_ROOT",
|
|
123
|
+
"Glob pattern must stay inside the repository root",
|
|
124
|
+
details={"pattern": pattern},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def iter_safe_files(self, pattern: str = "**/*") -> list[Path]:
|
|
128
|
+
self.validate_glob_pattern(pattern)
|
|
129
|
+
files: list[Path] = []
|
|
130
|
+
for candidate in sorted(self.repo_root.glob(pattern)):
|
|
131
|
+
try:
|
|
132
|
+
resolved = candidate.resolve(strict=False)
|
|
133
|
+
if not _is_relative_to(resolved, self.repo_root):
|
|
134
|
+
continue
|
|
135
|
+
if not resolved.is_file():
|
|
136
|
+
continue
|
|
137
|
+
self.ensure_allowed(resolved)
|
|
138
|
+
except ExplorerError:
|
|
139
|
+
continue
|
|
140
|
+
files.append(resolved)
|
|
141
|
+
return files
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _matches_patterns(rel_path: str, patterns: tuple[str, ...]) -> bool:
|
|
145
|
+
return any(
|
|
146
|
+
_matches_pattern(rel_path, pattern.strip())
|
|
147
|
+
for pattern in patterns
|
|
148
|
+
if pattern.strip()
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _matches_pattern(rel_path: str, pattern: str) -> bool:
|
|
153
|
+
normalized = pattern.removeprefix("./")
|
|
154
|
+
if normalized.endswith("/**"):
|
|
155
|
+
prefix = normalized[:-3].rstrip("/")
|
|
156
|
+
return rel_path == prefix or rel_path.startswith(f"{prefix}/")
|
|
157
|
+
if "/" not in normalized:
|
|
158
|
+
parts = rel_path.split("/")
|
|
159
|
+
return any(fnmatch.fnmatchcase(part, normalized) for part in parts)
|
|
160
|
+
return fnmatch.fnmatchcase(rel_path, normalized)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_relative_to(path: Path, root: Path) -> bool:
|
|
164
|
+
try:
|
|
165
|
+
path.relative_to(root)
|
|
166
|
+
except ValueError:
|
|
167
|
+
return False
|
|
168
|
+
return True
|
flash_context/types.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
ErrorCode = Literal[
|
|
8
|
+
"CONFIG_MISSING_ENDPOINT",
|
|
9
|
+
"CONFIG_INVALID",
|
|
10
|
+
"REPO_NOT_FOUND",
|
|
11
|
+
"QUERY_EMPTY",
|
|
12
|
+
"PATH_OUTSIDE_ROOT",
|
|
13
|
+
"PATH_DENIED",
|
|
14
|
+
"PATH_NOT_FOUND",
|
|
15
|
+
"TOOL_OUTPUT_TRUNCATED",
|
|
16
|
+
"ENDPOINT_UNREACHABLE",
|
|
17
|
+
"ENDPOINT_TIMEOUT",
|
|
18
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
19
|
+
"UNSUPPORTED_TOOL_CALL",
|
|
20
|
+
"MAX_TURNS_EXCEEDED",
|
|
21
|
+
"INVALID_TOOL_ARGUMENTS",
|
|
22
|
+
"INVALID_GREP_PATTERN",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class ExplorerError(Exception):
|
|
28
|
+
code: ErrorCode
|
|
29
|
+
message: str
|
|
30
|
+
retryable: bool = False
|
|
31
|
+
details: dict[str, object] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
super().__init__(f"{self.code}: {self.message}")
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, object]:
|
|
37
|
+
return {
|
|
38
|
+
"code": self.code,
|
|
39
|
+
"message": self.message,
|
|
40
|
+
"retryable": self.retryable,
|
|
41
|
+
"details": self.details,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Citation:
|
|
47
|
+
path: str
|
|
48
|
+
start_line: int | None = None
|
|
49
|
+
end_line: int | None = None
|
|
50
|
+
reason: str | None = None
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"path": self.path,
|
|
55
|
+
"start_line": self.start_line,
|
|
56
|
+
"end_line": self.end_line,
|
|
57
|
+
"reason": self.reason,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def label(self) -> str:
|
|
61
|
+
if self.start_line is None:
|
|
62
|
+
return self.path
|
|
63
|
+
if self.end_line is None or self.end_line == self.start_line:
|
|
64
|
+
return f"{self.path}:{self.start_line}"
|
|
65
|
+
return f"{self.path}:{self.start_line}-{self.end_line}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class RawLocation:
|
|
70
|
+
path: str
|
|
71
|
+
start_line: int
|
|
72
|
+
end_line: int
|
|
73
|
+
text: str
|
|
74
|
+
truncated: bool = False
|
|
75
|
+
|
|
76
|
+
def to_dict(self, *, include_text: bool = True) -> dict[str, object]:
|
|
77
|
+
data: dict[str, object] = {
|
|
78
|
+
"path": self.path,
|
|
79
|
+
"start_line": self.start_line,
|
|
80
|
+
"end_line": self.end_line,
|
|
81
|
+
"truncated": self.truncated,
|
|
82
|
+
}
|
|
83
|
+
if include_text:
|
|
84
|
+
data["text"] = self.text
|
|
85
|
+
else:
|
|
86
|
+
data["text_length"] = len(self.text)
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class SearchHit:
|
|
92
|
+
path: str
|
|
93
|
+
line: int
|
|
94
|
+
text: str
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, object]:
|
|
97
|
+
return {"path": self.path, "line": self.line, "text": self.text}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class ToolCall:
|
|
102
|
+
id: str
|
|
103
|
+
name: Literal["read_file", "repo_glob", "repo_grep"]
|
|
104
|
+
arguments: dict[str, object]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class ToolObservation:
|
|
109
|
+
tool_call_id: str | None = None
|
|
110
|
+
ok: bool = True
|
|
111
|
+
path: str | None = None
|
|
112
|
+
line_range: str | None = None
|
|
113
|
+
content: str | None = None
|
|
114
|
+
matches: list[str] = field(default_factory=list)
|
|
115
|
+
hits: list[SearchHit] = field(default_factory=list)
|
|
116
|
+
truncated: bool = False
|
|
117
|
+
error: ExplorerError | None = None
|
|
118
|
+
|
|
119
|
+
def to_dict(self, *, include_content: bool = True) -> dict[str, object]:
|
|
120
|
+
data: dict[str, object] = {
|
|
121
|
+
"ok": self.ok,
|
|
122
|
+
"truncated": self.truncated,
|
|
123
|
+
}
|
|
124
|
+
if self.tool_call_id is not None:
|
|
125
|
+
data["tool_call_id"] = self.tool_call_id
|
|
126
|
+
if self.path is not None:
|
|
127
|
+
data["path"] = self.path
|
|
128
|
+
if self.line_range is not None:
|
|
129
|
+
data["line_range"] = self.line_range
|
|
130
|
+
if include_content and self.content is not None:
|
|
131
|
+
data["content"] = self.content
|
|
132
|
+
elif self.content is not None:
|
|
133
|
+
data["content_length"] = len(self.content)
|
|
134
|
+
if self.matches:
|
|
135
|
+
data["matches"] = self.matches
|
|
136
|
+
if self.hits:
|
|
137
|
+
data["hits"] = [hit.to_dict() for hit in self.hits]
|
|
138
|
+
if self.error is not None:
|
|
139
|
+
data["error"] = self.error.to_dict()
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_error(
|
|
144
|
+
cls, error: ExplorerError, *, tool_call_id: str | None = None
|
|
145
|
+
) -> ToolObservation:
|
|
146
|
+
return cls(tool_call_id=tool_call_id, ok=False, error=error)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class ExploreRequest:
|
|
151
|
+
query: str
|
|
152
|
+
repo_root: Path
|
|
153
|
+
max_turns: int = 6
|
|
154
|
+
citation: bool = True
|
|
155
|
+
format: Literal["text", "json"] = "text"
|
|
156
|
+
|
|
157
|
+
def validate(self) -> None:
|
|
158
|
+
if not self.query.strip():
|
|
159
|
+
raise ExplorerError("QUERY_EMPTY", "Query must not be blank")
|
|
160
|
+
if self.max_turns <= 0:
|
|
161
|
+
raise ExplorerError(
|
|
162
|
+
"CONFIG_INVALID",
|
|
163
|
+
"max_turns must be positive",
|
|
164
|
+
details={"max_turns": self.max_turns},
|
|
165
|
+
)
|
|
166
|
+
if not self.repo_root.exists() or not self.repo_root.is_dir():
|
|
167
|
+
raise ExplorerError(
|
|
168
|
+
"REPO_NOT_FOUND",
|
|
169
|
+
"Repository root must exist and be a directory",
|
|
170
|
+
details={"repo_root": str(self.repo_root)},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def to_dict(self) -> dict[str, object]:
|
|
174
|
+
return {
|
|
175
|
+
"query": self.query,
|
|
176
|
+
"repo_root": str(self.repo_root),
|
|
177
|
+
"max_turns": self.max_turns,
|
|
178
|
+
"citation": self.citation,
|
|
179
|
+
"format": self.format,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(slots=True)
|
|
184
|
+
class ExploreResult:
|
|
185
|
+
query: str
|
|
186
|
+
repo_root: str
|
|
187
|
+
answer: str
|
|
188
|
+
citations: list[Citation]
|
|
189
|
+
turns_used: int
|
|
190
|
+
raw_locations: list[RawLocation] = field(default_factory=list)
|
|
191
|
+
truncated: bool = False
|
|
192
|
+
warnings: list[str] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
def to_dict(self, *, include_raw_location_text: bool = True) -> dict[str, object]:
|
|
195
|
+
return {
|
|
196
|
+
"query": self.query,
|
|
197
|
+
"repo_root": self.repo_root,
|
|
198
|
+
"answer": self.answer,
|
|
199
|
+
"citations": [citation.to_dict() for citation in self.citations],
|
|
200
|
+
"raw_locations": [
|
|
201
|
+
raw_location.to_dict(include_text=include_raw_location_text)
|
|
202
|
+
for raw_location in self.raw_locations
|
|
203
|
+
],
|
|
204
|
+
"turns_used": self.turns_used,
|
|
205
|
+
"truncated": self.truncated,
|
|
206
|
+
"warnings": self.warnings,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
JsonObject = dict[str, Any]
|