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/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())
@@ -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"
@@ -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