flash-context 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flash_context/__init__.py +7 -0
- flash_context/agent.py +1425 -0
- flash_context/cli.py +86 -0
- flash_context/config.py +391 -0
- flash_context/llm.py +87 -0
- flash_context/logging.py +84 -0
- flash_context/mcp_server.py +89 -0
- flash_context/tools/__init__.py +5 -0
- flash_context/tools/glob.py +29 -0
- flash_context/tools/grep.py +67 -0
- flash_context/tools/read.py +77 -0
- flash_context/tools/safety.py +168 -0
- flash_context/types.py +210 -0
- flash_context-0.1.0.dist-info/METADATA +81 -0
- flash_context-0.1.0.dist-info/RECORD +18 -0
- flash_context-0.1.0.dist-info/WHEEL +4 -0
- flash_context-0.1.0.dist-info/entry_points.txt +2 -0
- flash_context-0.1.0.dist-info/licenses/LICENSE +202 -0
flash_context/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flash_context.agent import explore, format_text_result
|
|
10
|
+
from flash_context.config import SettingsOverrides, load_settings
|
|
11
|
+
from flash_context.mcp_server import run_mcp
|
|
12
|
+
from flash_context.types import ExploreRequest, ExplorerError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
16
|
+
parser = _build_parser()
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
if args.command == "explore":
|
|
19
|
+
return _run_explore(args)
|
|
20
|
+
if args.command == "mcp":
|
|
21
|
+
return _run_mcp(args)
|
|
22
|
+
parser.print_help()
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(prog="flash-context")
|
|
28
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
29
|
+
|
|
30
|
+
explore_parser = subparsers.add_parser("explore", help="explore a repository")
|
|
31
|
+
explore_parser.add_argument("--query", required=True)
|
|
32
|
+
explore_parser.add_argument("--repo", required=True, type=Path)
|
|
33
|
+
explore_parser.add_argument("--max-turns", type=int)
|
|
34
|
+
explore_parser.add_argument("--citation", action="store_true", default=True)
|
|
35
|
+
explore_parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
36
|
+
|
|
37
|
+
mcp_parser = subparsers.add_parser("mcp", help="run the MCP adapter")
|
|
38
|
+
mcp_parser.add_argument("--transport", default="stdio", choices=("stdio",))
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_explore(args: argparse.Namespace) -> int:
|
|
43
|
+
try:
|
|
44
|
+
repo_root = args.repo.resolve()
|
|
45
|
+
overrides = SettingsOverrides(max_turns=args.max_turns)
|
|
46
|
+
settings = load_settings(repo_root=repo_root, overrides=overrides)
|
|
47
|
+
request = ExploreRequest(
|
|
48
|
+
query=args.query,
|
|
49
|
+
repo_root=repo_root,
|
|
50
|
+
max_turns=settings.max_turns,
|
|
51
|
+
citation=args.citation,
|
|
52
|
+
format=args.format,
|
|
53
|
+
)
|
|
54
|
+
result = explore(request, settings)
|
|
55
|
+
except ExplorerError as exc:
|
|
56
|
+
print(f"{exc.code}: {exc.message}", file=sys.stderr)
|
|
57
|
+
return _exit_code(exc)
|
|
58
|
+
|
|
59
|
+
if args.format == "json":
|
|
60
|
+
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
|
|
61
|
+
else:
|
|
62
|
+
print(format_text_result(result, citation=args.citation))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_mcp(args: argparse.Namespace) -> int:
|
|
67
|
+
try:
|
|
68
|
+
run_mcp(transport=args.transport)
|
|
69
|
+
except ExplorerError as exc:
|
|
70
|
+
print(f"{exc.code}: {exc.message}", file=sys.stderr)
|
|
71
|
+
return _exit_code(exc)
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _exit_code(error: ExplorerError) -> int:
|
|
76
|
+
if error.code.startswith("CONFIG_") or error.code == "QUERY_EMPTY":
|
|
77
|
+
return 3 if error.code.startswith("CONFIG_") else 2
|
|
78
|
+
if error.code.startswith("PATH_") or error.code == "REPO_NOT_FOUND":
|
|
79
|
+
return 4
|
|
80
|
+
if error.code.startswith("ENDPOINT_"):
|
|
81
|
+
return 5
|
|
82
|
+
return 6
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
flash_context/config.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from flash_context.types import ExplorerError
|
|
12
|
+
|
|
13
|
+
PROJECT_CONFIG_NAME = "config.yaml"
|
|
14
|
+
PROJECT_ENV_NAME = ".env"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Settings:
|
|
19
|
+
base_url: str = ""
|
|
20
|
+
model: str = ""
|
|
21
|
+
api_key: str | None = None
|
|
22
|
+
max_turns: int = 6
|
|
23
|
+
max_read_bytes: int = 12_000
|
|
24
|
+
max_grep_results: int = 50
|
|
25
|
+
traj_dir: Path | None = None
|
|
26
|
+
ignore: list[str] = field(default_factory=list)
|
|
27
|
+
timeout_seconds: float = 120.0
|
|
28
|
+
max_observation_chars: int = 6_000
|
|
29
|
+
max_read_lines: int = 120
|
|
30
|
+
max_completion_tokens: int = 512
|
|
31
|
+
temperature: float = 0.0
|
|
32
|
+
max_parallel_tools: int = 4
|
|
33
|
+
|
|
34
|
+
def require_endpoint(self) -> None:
|
|
35
|
+
if not self.base_url:
|
|
36
|
+
raise ExplorerError(
|
|
37
|
+
"CONFIG_MISSING_ENDPOINT",
|
|
38
|
+
"FLASH_CONTEXT_BASE_URL is required",
|
|
39
|
+
)
|
|
40
|
+
if not self.model:
|
|
41
|
+
raise ExplorerError(
|
|
42
|
+
"CONFIG_INVALID",
|
|
43
|
+
"FLASH_CONTEXT_MODEL is required",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class SettingsOverrides:
|
|
49
|
+
base_url: str | None = None
|
|
50
|
+
model: str | None = None
|
|
51
|
+
api_key: str | None = None
|
|
52
|
+
max_turns: int | None = None
|
|
53
|
+
max_read_bytes: int | None = None
|
|
54
|
+
max_grep_results: int | None = None
|
|
55
|
+
traj_dir: Path | None = None
|
|
56
|
+
ignore: list[str] | None = None
|
|
57
|
+
timeout_seconds: float | None = None
|
|
58
|
+
max_observation_chars: int | None = None
|
|
59
|
+
max_read_lines: int | None = None
|
|
60
|
+
max_completion_tokens: int | None = None
|
|
61
|
+
temperature: float | None = None
|
|
62
|
+
max_parallel_tools: int | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_settings(
|
|
66
|
+
*,
|
|
67
|
+
repo_root: Path | None = None,
|
|
68
|
+
overrides: SettingsOverrides | None = None,
|
|
69
|
+
) -> Settings:
|
|
70
|
+
_ = repo_root
|
|
71
|
+
settings = Settings()
|
|
72
|
+
project_root = _project_root()
|
|
73
|
+
if project_root is not None:
|
|
74
|
+
yaml_path = project_root / PROJECT_CONFIG_NAME
|
|
75
|
+
if yaml_path.exists():
|
|
76
|
+
settings = _apply_yaml(settings, yaml_path, project_root=project_root)
|
|
77
|
+
settings = _apply_env(settings, _load_dotenv(project_root / PROJECT_ENV_NAME))
|
|
78
|
+
settings = _apply_env(settings, os.environ)
|
|
79
|
+
if overrides is not None:
|
|
80
|
+
settings = _apply_overrides(settings, overrides)
|
|
81
|
+
_validate_settings(settings)
|
|
82
|
+
return settings
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _project_root() -> Path | None:
|
|
86
|
+
source_dir = Path(__file__).resolve().parent.parent
|
|
87
|
+
if source_dir.name == "src" and (source_dir.parent / "pyproject.toml").is_file():
|
|
88
|
+
return source_dir.parent
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load_dotenv(path: Path) -> dict[str, str]:
|
|
93
|
+
if not path.exists():
|
|
94
|
+
return {}
|
|
95
|
+
if not path.is_file():
|
|
96
|
+
raise ExplorerError(
|
|
97
|
+
"CONFIG_INVALID",
|
|
98
|
+
f"Env path is not a file: {path}",
|
|
99
|
+
)
|
|
100
|
+
env: dict[str, str] = {}
|
|
101
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
102
|
+
for line_number, raw_line in enumerate(lines, 1):
|
|
103
|
+
line = raw_line.strip()
|
|
104
|
+
if not line or line.startswith("#"):
|
|
105
|
+
continue
|
|
106
|
+
if line.startswith("export "):
|
|
107
|
+
line = line.removeprefix("export ").lstrip()
|
|
108
|
+
if "=" not in line:
|
|
109
|
+
raise ExplorerError(
|
|
110
|
+
"CONFIG_INVALID",
|
|
111
|
+
f"Invalid .env line: {line_number}",
|
|
112
|
+
)
|
|
113
|
+
key, value = line.split("=", 1)
|
|
114
|
+
key = key.strip()
|
|
115
|
+
if not key:
|
|
116
|
+
raise ExplorerError(
|
|
117
|
+
"CONFIG_INVALID",
|
|
118
|
+
f"Invalid .env line: {line_number}",
|
|
119
|
+
)
|
|
120
|
+
env[key] = _parse_env_value(value.strip())
|
|
121
|
+
return env
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_env_value(value: str) -> str:
|
|
125
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
126
|
+
return value[1:-1]
|
|
127
|
+
if " #" in value:
|
|
128
|
+
return value.split(" #", 1)[0].rstrip()
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _apply_yaml(settings: Settings, path: Path, *, project_root: Path) -> Settings:
|
|
133
|
+
try:
|
|
134
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
135
|
+
except yaml.YAMLError as exc:
|
|
136
|
+
raise ExplorerError(
|
|
137
|
+
"CONFIG_INVALID",
|
|
138
|
+
f"Invalid YAML config: {path}",
|
|
139
|
+
details={"error": str(exc)},
|
|
140
|
+
) from exc
|
|
141
|
+
if loaded is None:
|
|
142
|
+
data: dict[str, Any] = {}
|
|
143
|
+
elif isinstance(loaded, dict):
|
|
144
|
+
data = {str(key): value for key, value in loaded.items()}
|
|
145
|
+
else:
|
|
146
|
+
raise ExplorerError("CONFIG_INVALID", "YAML config must be a mapping")
|
|
147
|
+
|
|
148
|
+
model = _section(data, "model")
|
|
149
|
+
explorer = _section(data, "explorer")
|
|
150
|
+
tools = _section(data, "tools")
|
|
151
|
+
traj_dir = explorer.get("traj_dir", settings.traj_dir) or data.get("traj_dir")
|
|
152
|
+
|
|
153
|
+
return replace(
|
|
154
|
+
settings,
|
|
155
|
+
base_url=str(model.get("base_url", settings.base_url) or ""),
|
|
156
|
+
model=str(model.get("model", settings.model) or ""),
|
|
157
|
+
api_key=_optional_str(model.get("api_key", settings.api_key)),
|
|
158
|
+
max_turns=_int_value(
|
|
159
|
+
explorer.get("max_turns", settings.max_turns), "max_turns"
|
|
160
|
+
),
|
|
161
|
+
max_read_bytes=_int_value(
|
|
162
|
+
tools.get("max_read_bytes", settings.max_read_bytes), "max_read_bytes"
|
|
163
|
+
),
|
|
164
|
+
max_grep_results=_int_value(
|
|
165
|
+
tools.get("max_grep_results", settings.max_grep_results),
|
|
166
|
+
"max_grep_results",
|
|
167
|
+
),
|
|
168
|
+
timeout_seconds=_float_value(
|
|
169
|
+
model.get("timeout_seconds", settings.timeout_seconds),
|
|
170
|
+
"timeout_seconds",
|
|
171
|
+
),
|
|
172
|
+
max_observation_chars=_int_value(
|
|
173
|
+
tools.get("max_observation_chars", settings.max_observation_chars),
|
|
174
|
+
"max_observation_chars",
|
|
175
|
+
),
|
|
176
|
+
max_read_lines=_int_value(
|
|
177
|
+
tools.get("max_read_lines", settings.max_read_lines),
|
|
178
|
+
"max_read_lines",
|
|
179
|
+
),
|
|
180
|
+
max_parallel_tools=_int_value(
|
|
181
|
+
tools.get("max_parallel_tools", settings.max_parallel_tools),
|
|
182
|
+
"max_parallel_tools",
|
|
183
|
+
),
|
|
184
|
+
max_completion_tokens=_int_value(
|
|
185
|
+
model.get("max_completion_tokens", settings.max_completion_tokens),
|
|
186
|
+
"max_completion_tokens",
|
|
187
|
+
),
|
|
188
|
+
temperature=_float_value(
|
|
189
|
+
model.get("temperature", settings.temperature),
|
|
190
|
+
"temperature",
|
|
191
|
+
),
|
|
192
|
+
traj_dir=_optional_project_path(traj_dir, project_root=project_root)
|
|
193
|
+
or settings.traj_dir,
|
|
194
|
+
ignore=_str_list(explorer.get("ignore", settings.ignore), "ignore"),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_env(settings: Settings, env: Mapping[str, str | None]) -> Settings:
|
|
199
|
+
return replace(
|
|
200
|
+
settings,
|
|
201
|
+
base_url=env.get("FLASH_CONTEXT_BASE_URL") or settings.base_url,
|
|
202
|
+
model=env.get("FLASH_CONTEXT_MODEL") or settings.model,
|
|
203
|
+
api_key=env.get("FLASH_CONTEXT_API_KEY", settings.api_key),
|
|
204
|
+
max_turns=_env_int(env, "FLASH_CONTEXT_MAX_TURNS", settings.max_turns),
|
|
205
|
+
max_read_bytes=_env_int(
|
|
206
|
+
env, "FLASH_CONTEXT_MAX_READ_BYTES", settings.max_read_bytes
|
|
207
|
+
),
|
|
208
|
+
max_grep_results=_env_int(
|
|
209
|
+
env, "FLASH_CONTEXT_MAX_GREP_RESULTS", settings.max_grep_results
|
|
210
|
+
),
|
|
211
|
+
traj_dir=_optional_path(env.get("FLASH_CONTEXT_TRAJ_DIR")) or settings.traj_dir,
|
|
212
|
+
timeout_seconds=_env_float(
|
|
213
|
+
env,
|
|
214
|
+
"FLASH_CONTEXT_TIMEOUT_SECONDS",
|
|
215
|
+
settings.timeout_seconds,
|
|
216
|
+
),
|
|
217
|
+
max_observation_chars=_env_int(
|
|
218
|
+
env,
|
|
219
|
+
"FLASH_CONTEXT_MAX_OBSERVATION_CHARS",
|
|
220
|
+
settings.max_observation_chars,
|
|
221
|
+
),
|
|
222
|
+
max_read_lines=_env_int(
|
|
223
|
+
env,
|
|
224
|
+
"FLASH_CONTEXT_MAX_READ_LINES",
|
|
225
|
+
settings.max_read_lines,
|
|
226
|
+
),
|
|
227
|
+
max_completion_tokens=_env_int(
|
|
228
|
+
env,
|
|
229
|
+
"FLASH_CONTEXT_MAX_COMPLETION_TOKENS",
|
|
230
|
+
settings.max_completion_tokens,
|
|
231
|
+
),
|
|
232
|
+
max_parallel_tools=_env_int(
|
|
233
|
+
env,
|
|
234
|
+
"FLASH_CONTEXT_MAX_PARALLEL_TOOLS",
|
|
235
|
+
settings.max_parallel_tools,
|
|
236
|
+
),
|
|
237
|
+
temperature=_env_float(
|
|
238
|
+
env,
|
|
239
|
+
"FLASH_CONTEXT_TEMPERATURE",
|
|
240
|
+
settings.temperature,
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _apply_overrides(settings: Settings, overrides: SettingsOverrides) -> Settings:
|
|
246
|
+
return replace(
|
|
247
|
+
settings,
|
|
248
|
+
base_url=overrides.base_url
|
|
249
|
+
if overrides.base_url is not None
|
|
250
|
+
else settings.base_url,
|
|
251
|
+
model=overrides.model if overrides.model is not None else settings.model,
|
|
252
|
+
api_key=overrides.api_key
|
|
253
|
+
if overrides.api_key is not None
|
|
254
|
+
else settings.api_key,
|
|
255
|
+
max_turns=overrides.max_turns
|
|
256
|
+
if overrides.max_turns is not None
|
|
257
|
+
else settings.max_turns,
|
|
258
|
+
max_read_bytes=overrides.max_read_bytes
|
|
259
|
+
if overrides.max_read_bytes is not None
|
|
260
|
+
else settings.max_read_bytes,
|
|
261
|
+
max_grep_results=overrides.max_grep_results
|
|
262
|
+
if overrides.max_grep_results is not None
|
|
263
|
+
else settings.max_grep_results,
|
|
264
|
+
traj_dir=overrides.traj_dir
|
|
265
|
+
if overrides.traj_dir is not None
|
|
266
|
+
else settings.traj_dir,
|
|
267
|
+
ignore=overrides.ignore if overrides.ignore is not None else settings.ignore,
|
|
268
|
+
timeout_seconds=overrides.timeout_seconds
|
|
269
|
+
if overrides.timeout_seconds is not None
|
|
270
|
+
else settings.timeout_seconds,
|
|
271
|
+
max_observation_chars=overrides.max_observation_chars
|
|
272
|
+
if overrides.max_observation_chars is not None
|
|
273
|
+
else settings.max_observation_chars,
|
|
274
|
+
max_read_lines=overrides.max_read_lines
|
|
275
|
+
if overrides.max_read_lines is not None
|
|
276
|
+
else settings.max_read_lines,
|
|
277
|
+
max_completion_tokens=overrides.max_completion_tokens
|
|
278
|
+
if overrides.max_completion_tokens is not None
|
|
279
|
+
else settings.max_completion_tokens,
|
|
280
|
+
temperature=overrides.temperature
|
|
281
|
+
if overrides.temperature is not None
|
|
282
|
+
else settings.temperature,
|
|
283
|
+
max_parallel_tools=overrides.max_parallel_tools
|
|
284
|
+
if overrides.max_parallel_tools is not None
|
|
285
|
+
else settings.max_parallel_tools,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _validate_settings(settings: Settings) -> None:
|
|
290
|
+
int_fields = {
|
|
291
|
+
"max_turns": settings.max_turns,
|
|
292
|
+
"max_read_bytes": settings.max_read_bytes,
|
|
293
|
+
"max_grep_results": settings.max_grep_results,
|
|
294
|
+
"max_observation_chars": settings.max_observation_chars,
|
|
295
|
+
"max_read_lines": settings.max_read_lines,
|
|
296
|
+
"max_completion_tokens": settings.max_completion_tokens,
|
|
297
|
+
"max_parallel_tools": settings.max_parallel_tools,
|
|
298
|
+
}
|
|
299
|
+
for name, value in int_fields.items():
|
|
300
|
+
if value <= 0:
|
|
301
|
+
raise ExplorerError(
|
|
302
|
+
"CONFIG_INVALID",
|
|
303
|
+
f"{name} must be positive",
|
|
304
|
+
details={name: value},
|
|
305
|
+
)
|
|
306
|
+
if settings.timeout_seconds <= 0:
|
|
307
|
+
raise ExplorerError(
|
|
308
|
+
"CONFIG_INVALID",
|
|
309
|
+
"timeout_seconds must be positive",
|
|
310
|
+
details={"timeout_seconds": settings.timeout_seconds},
|
|
311
|
+
)
|
|
312
|
+
if settings.temperature < 0:
|
|
313
|
+
raise ExplorerError(
|
|
314
|
+
"CONFIG_INVALID",
|
|
315
|
+
"temperature must be non-negative",
|
|
316
|
+
details={"temperature": settings.temperature},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
|
|
321
|
+
value = data.get(name, {})
|
|
322
|
+
if not isinstance(value, dict):
|
|
323
|
+
raise ExplorerError("CONFIG_INVALID", f"[{name}] must be a table")
|
|
324
|
+
return value
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _env_int(env: Mapping[str, str | None], name: str, default: int) -> int:
|
|
328
|
+
value = env.get(name)
|
|
329
|
+
return default if value is None else _int_value(value, name)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _env_float(env: Mapping[str, str | None], name: str, default: float) -> float:
|
|
333
|
+
value = env.get(name)
|
|
334
|
+
return default if value is None else _float_value(value, name)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _int_value(value: object, name: str) -> int:
|
|
338
|
+
if isinstance(value, bool):
|
|
339
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be an integer")
|
|
340
|
+
if isinstance(value, int):
|
|
341
|
+
return value
|
|
342
|
+
if not isinstance(value, str):
|
|
343
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be an integer")
|
|
344
|
+
try:
|
|
345
|
+
parsed = int(value)
|
|
346
|
+
except ValueError as exc:
|
|
347
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be an integer") from exc
|
|
348
|
+
return parsed
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _float_value(value: object, name: str) -> float:
|
|
352
|
+
if isinstance(value, bool):
|
|
353
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be a number")
|
|
354
|
+
if isinstance(value, int | float):
|
|
355
|
+
return float(value)
|
|
356
|
+
if not isinstance(value, str):
|
|
357
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be a number")
|
|
358
|
+
try:
|
|
359
|
+
parsed = float(value)
|
|
360
|
+
except ValueError as exc:
|
|
361
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be a number") from exc
|
|
362
|
+
return parsed
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _optional_str(value: object) -> str | None:
|
|
366
|
+
if value is None:
|
|
367
|
+
return None
|
|
368
|
+
return str(value)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _optional_path(value: object) -> Path | None:
|
|
372
|
+
if value is None or value == "":
|
|
373
|
+
return None
|
|
374
|
+
return Path(str(value))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _optional_project_path(value: object, *, project_root: Path) -> Path | None:
|
|
378
|
+
path = _optional_path(value)
|
|
379
|
+
if path is None or path.is_absolute():
|
|
380
|
+
return path
|
|
381
|
+
return project_root / path
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _str_list(value: object, name: str) -> list[str]:
|
|
385
|
+
if value is None:
|
|
386
|
+
return []
|
|
387
|
+
if not isinstance(value, list):
|
|
388
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} must be a list")
|
|
389
|
+
if not all(isinstance(item, str) for item in value):
|
|
390
|
+
raise ExplorerError("CONFIG_INVALID", f"{name} entries must be strings")
|
|
391
|
+
return list(value)
|
flash_context/llm.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from flash_context.config import Settings
|
|
8
|
+
from flash_context.types import ExplorerError, JsonObject
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChatClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
settings: Settings,
|
|
15
|
+
*,
|
|
16
|
+
transport: httpx.BaseTransport | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._settings = settings
|
|
19
|
+
self._client = httpx.Client(
|
|
20
|
+
timeout=settings.timeout_seconds,
|
|
21
|
+
transport=transport,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
self._client.close()
|
|
26
|
+
|
|
27
|
+
def chat_completion(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
messages: list[dict[str, Any]],
|
|
31
|
+
tools: list[dict[str, Any]],
|
|
32
|
+
) -> JsonObject:
|
|
33
|
+
url = _chat_completions_url(self._settings.base_url)
|
|
34
|
+
headers = {"Content-Type": "application/json"}
|
|
35
|
+
if self._settings.api_key:
|
|
36
|
+
headers["Authorization"] = f"Bearer {self._settings.api_key}"
|
|
37
|
+
payload: JsonObject = {
|
|
38
|
+
"model": self._settings.model,
|
|
39
|
+
"messages": messages,
|
|
40
|
+
"tools": tools,
|
|
41
|
+
"tool_choice": "auto",
|
|
42
|
+
"max_tokens": self._settings.max_completion_tokens,
|
|
43
|
+
"temperature": self._settings.temperature,
|
|
44
|
+
}
|
|
45
|
+
try:
|
|
46
|
+
response = self._client.post(url, headers=headers, json=payload)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
except httpx.TimeoutException as exc:
|
|
49
|
+
raise ExplorerError(
|
|
50
|
+
"ENDPOINT_TIMEOUT",
|
|
51
|
+
"Endpoint request timed out",
|
|
52
|
+
retryable=True,
|
|
53
|
+
) from exc
|
|
54
|
+
except httpx.HTTPStatusError as exc:
|
|
55
|
+
raise ExplorerError(
|
|
56
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
57
|
+
"Endpoint returned an unsuccessful status",
|
|
58
|
+
retryable=500 <= exc.response.status_code < 600,
|
|
59
|
+
details={"status_code": exc.response.status_code},
|
|
60
|
+
) from exc
|
|
61
|
+
except httpx.HTTPError as exc:
|
|
62
|
+
raise ExplorerError(
|
|
63
|
+
"ENDPOINT_UNREACHABLE",
|
|
64
|
+
"Endpoint request failed",
|
|
65
|
+
retryable=True,
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
data = response.json()
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise ExplorerError(
|
|
72
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
73
|
+
"Endpoint returned invalid JSON",
|
|
74
|
+
) from exc
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
raise ExplorerError(
|
|
77
|
+
"ENDPOINT_BAD_RESPONSE",
|
|
78
|
+
"Endpoint response must be a JSON object",
|
|
79
|
+
)
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _chat_completions_url(base_url: str) -> str:
|
|
84
|
+
trimmed = base_url.rstrip("/")
|
|
85
|
+
if trimmed.endswith("/chat/completions"):
|
|
86
|
+
return trimmed
|
|
87
|
+
return f"{trimmed}/chat/completions"
|
flash_context/logging.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from flash_context.types import (
|
|
9
|
+
ExploreRequest,
|
|
10
|
+
ExplorerError,
|
|
11
|
+
ExploreResult,
|
|
12
|
+
ToolObservation,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TrajectoryRecorder:
|
|
17
|
+
def __init__(self, traj_dir: Path | None, request: ExploreRequest) -> None:
|
|
18
|
+
self._traj_dir = traj_dir
|
|
19
|
+
self._data: dict[str, object] = {
|
|
20
|
+
"request": request.to_dict(),
|
|
21
|
+
"turns": [],
|
|
22
|
+
"result": None,
|
|
23
|
+
"error": None,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def record_model_turn(self, turn: int, message: dict[str, object]) -> None:
|
|
27
|
+
if self._traj_dir is None:
|
|
28
|
+
return
|
|
29
|
+
self._turns().append(
|
|
30
|
+
{
|
|
31
|
+
"turn": turn,
|
|
32
|
+
"message": _sanitize_model_message(message),
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def record_observation(
|
|
37
|
+
self,
|
|
38
|
+
turn: int,
|
|
39
|
+
tool_name: str,
|
|
40
|
+
observation: ToolObservation,
|
|
41
|
+
) -> None:
|
|
42
|
+
if self._traj_dir is None:
|
|
43
|
+
return
|
|
44
|
+
self._turns().append(
|
|
45
|
+
{
|
|
46
|
+
"turn": turn,
|
|
47
|
+
"tool": tool_name,
|
|
48
|
+
"observation": observation.to_dict(include_content=False),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def finish(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
result: ExploreResult | None = None,
|
|
56
|
+
error: ExplorerError | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
if self._traj_dir is None:
|
|
59
|
+
return
|
|
60
|
+
self._data["result"] = (
|
|
61
|
+
result.to_dict(include_raw_location_text=False)
|
|
62
|
+
if result is not None
|
|
63
|
+
else None
|
|
64
|
+
)
|
|
65
|
+
self._data["error"] = error.to_dict() if error is not None else None
|
|
66
|
+
self._traj_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S")
|
|
68
|
+
path = self._traj_dir / f"{stamp}-{uuid4().hex}.json"
|
|
69
|
+
content = json.dumps(self._data, indent=2, sort_keys=True)
|
|
70
|
+
path.write_text(content, encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
def _turns(self) -> list[dict[str, object]]:
|
|
73
|
+
turns = self._data["turns"]
|
|
74
|
+
if not isinstance(turns, list):
|
|
75
|
+
raise TypeError("trajectory turns storage corrupted")
|
|
76
|
+
return turns
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sanitize_model_message(message: dict[str, object]) -> dict[str, object]:
|
|
80
|
+
sanitized = dict(message)
|
|
81
|
+
content = sanitized.get("content")
|
|
82
|
+
if isinstance(content, str) and len(content) > 500:
|
|
83
|
+
sanitized["content"] = f"{content[:500]}..."
|
|
84
|
+
return sanitized
|