@octocodeai/freellama-mcp-server 0.1.0

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.
@@ -0,0 +1,663 @@
1
+ #!/usr/bin/env python3
2
+ """Context-window management shared by the research/benchmark adapters.
3
+
4
+ Why this exists, concretely. Both adapters run at `num_ctx=8192` and append a full observation
5
+ (up to 3000 characters) after every turn. At the benchmark's `max_turns=10` the conversation
6
+ reaches roughly 9-10k tokens — past the window. Ollama does not error on that: it silently
7
+ truncates from the **front** of the prompt, and the front is the system prompt. The agent loses
8
+ its own tool schema and output contract mid-run, then returns prose instead of JSON, and the run
9
+ is recorded as `model did not return a JSON action` — a parsing failure that reads like a model
10
+ weakness but is really a context-management bug in the harness.
11
+
12
+ Everything here is deterministic and dependency-free, because the adapters are copied verbatim
13
+ into the published MCP package (`packages/mcp/scripts/bundle-adapters.mjs`) and must keep running
14
+ with nothing but the standard library.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import hashlib
21
+ import math
22
+ import os
23
+ import time
24
+ from dataclasses import asdict, dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Mapping
27
+
28
+ # Ollama reports no token counts until after a call, so the first budget uses a configurable
29
+ # approximation. Real prompt_eval_count values then raise the multiplier whenever this estimate
30
+ # was optimistic; it never lowers itself from a sample.
31
+ CHARS_PER_TOKEN = 4
32
+ # Headroom held back from `num_ctx` on top of `num_predict`: chat templating, tool-call scaffolding
33
+ # and the token estimate's own error all land inside the same window.
34
+ SAFETY_MARGIN_TOKENS = 256
35
+ # Base64 bytes are not text tokens. Multimodal templates turn each image into a bounded token
36
+ # sequence; charging a conservative fixed amount avoids treating a 5MB JPEG as 1.25M text tokens.
37
+ IMAGE_TOKEN_ESTIMATE = 1024
38
+ # Observations kept verbatim. The two most recent are what the model is actually reasoning over;
39
+ # older ones have usually already been distilled into the answer it is assembling.
40
+ DEFAULT_KEEP_RECENT = 2
41
+ # How much of a compacted observation survives as a breadcrumb, so the model still knows the call
42
+ # happened and roughly what it returned rather than silently losing the step.
43
+ COMPACT_PREVIEW_CHARS = 180
44
+ # Emergency clipping keeps this fraction of the current payload on each pass. It is deliberately
45
+ # iterative: rounding and per-message metadata make a one-shot subtraction unsafe.
46
+ COMPACT_RETAIN_RATIO = 0.8
47
+ CLIP_HEAD_RATIO = 2 / 3
48
+ # Full observations are kept outside the prompt and served in line-aware pages of this size.
49
+ OBSERVATION_PAGE_CHARS = 3000
50
+
51
+
52
+ def _env_int(env: Mapping[str, str], name: str, default: int, *, minimum: int = 0) -> int:
53
+ raw = env.get(name)
54
+ if raw is None or raw == "":
55
+ return default
56
+ try:
57
+ value = int(raw)
58
+ except ValueError as error:
59
+ raise ValueError(f"{name} must be an integer, got {raw!r}") from error
60
+ if value < minimum:
61
+ raise ValueError(f"{name} must be >= {minimum}, got {value}")
62
+ return value
63
+
64
+
65
+ def _env_float(env: Mapping[str, str], name: str, default: float, *, minimum: float = 0.0) -> float:
66
+ raw = env.get(name)
67
+ if raw is None or raw == "":
68
+ return default
69
+ try:
70
+ value = float(raw)
71
+ except ValueError as error:
72
+ raise ValueError(f"{name} must be a number, got {raw!r}") from error
73
+ if not math.isfinite(value) or value < minimum:
74
+ raise ValueError(f"{name} must be finite and >= {minimum}, got {raw!r}")
75
+ return value
76
+
77
+
78
+ def _env_bool(env: Mapping[str, str], name: str, default: bool) -> bool:
79
+ raw = env.get(name)
80
+ if raw is None or raw == "":
81
+ return default
82
+ normalized = raw.strip().lower()
83
+ if normalized in {"1", "true", "yes", "on"}:
84
+ return True
85
+ if normalized in {"0", "false", "no", "off"}:
86
+ return False
87
+ raise ValueError(f"{name} must be true/false, got {raw!r}")
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ContextPolicy:
92
+ """Validated context-management policy shared by every research adapter."""
93
+
94
+ chars_per_token: float = CHARS_PER_TOKEN
95
+ safety_margin_tokens: int = SAFETY_MARGIN_TOKENS
96
+ image_token_estimate: int = IMAGE_TOKEN_ESTIMATE
97
+ keep_recent: int = DEFAULT_KEEP_RECENT
98
+ compact_preview_chars: int = COMPACT_PREVIEW_CHARS
99
+ compact_retain_ratio: float = COMPACT_RETAIN_RATIO
100
+ clip_head_ratio: float = CLIP_HEAD_RATIO
101
+ observation_page_chars: int = OBSERVATION_PAGE_CHARS
102
+ pinned_overflow: str = "error"
103
+
104
+ def __post_init__(self) -> None:
105
+ if not math.isfinite(self.chars_per_token) or self.chars_per_token <= 0:
106
+ raise ValueError("chars_per_token must be finite and > 0")
107
+ if self.safety_margin_tokens < 0 or self.image_token_estimate < 0 or self.keep_recent < 0:
108
+ raise ValueError("context token estimates and keep_recent must be >= 0")
109
+ if self.compact_preview_chars <= 0 or self.observation_page_chars <= 0:
110
+ raise ValueError("preview and page sizes must be > 0")
111
+ if not 0 < self.compact_retain_ratio < 1:
112
+ raise ValueError("compact_retain_ratio must be between 0 and 1")
113
+ if not 0 < self.clip_head_ratio < 1:
114
+ raise ValueError("clip_head_ratio must be between 0 and 1")
115
+ if self.pinned_overflow not in {"error", "clip"}:
116
+ raise ValueError("pinned_overflow must be 'error' or 'clip'")
117
+
118
+ @classmethod
119
+ def from_env(cls, env: Mapping[str, str] | None = None) -> "ContextPolicy":
120
+ values = os.environ if env is None else env
121
+ pinned_overflow = values.get("FREELLAMA_AGENT_PINNED_OVERFLOW", "error").strip().lower()
122
+ return cls(
123
+ chars_per_token=_env_float(values, "FREELLAMA_AGENT_CHARS_PER_TOKEN", CHARS_PER_TOKEN, minimum=0.000001),
124
+ safety_margin_tokens=_env_int(values, "FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS", SAFETY_MARGIN_TOKENS),
125
+ image_token_estimate=_env_int(values, "FREELLAMA_AGENT_IMAGE_TOKEN_ESTIMATE", IMAGE_TOKEN_ESTIMATE),
126
+ keep_recent=_env_int(values, "FREELLAMA_AGENT_KEEP_RECENT", DEFAULT_KEEP_RECENT),
127
+ compact_preview_chars=_env_int(values, "FREELLAMA_AGENT_COMPACT_PREVIEW_CHARS", COMPACT_PREVIEW_CHARS, minimum=1),
128
+ compact_retain_ratio=_env_float(values, "FREELLAMA_AGENT_COMPACT_RETAIN_RATIO", COMPACT_RETAIN_RATIO, minimum=0.000001),
129
+ clip_head_ratio=_env_float(values, "FREELLAMA_AGENT_CLIP_HEAD_RATIO", CLIP_HEAD_RATIO, minimum=0.000001),
130
+ observation_page_chars=_env_int(values, "FREELLAMA_AGENT_OBSERVATION_PAGE_CHARS", OBSERVATION_PAGE_CHARS, minimum=1),
131
+ pinned_overflow=pinned_overflow,
132
+ )
133
+
134
+ def metadata(self) -> dict[str, Any]:
135
+ return asdict(self)
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class AgentRuntimeConfig:
140
+ """Typed operational settings; safety confinement remains an invariant, not a knob."""
141
+
142
+ max_turns: int
143
+ num_ctx: int
144
+ num_predict: int
145
+ temperature: float
146
+ seed: int
147
+ request_timeout_seconds: float
148
+ retry_attempts: int
149
+ retry_backoff_seconds: float
150
+ tool_timeout_seconds: float
151
+ keep_alive: str
152
+ think: bool
153
+ max_parse_repairs: int
154
+ parse_repair_echo_chars: int
155
+ context: ContextPolicy
156
+
157
+ @classmethod
158
+ def from_env(
159
+ cls,
160
+ *,
161
+ default_tool_timeout_seconds: float,
162
+ env: Mapping[str, str] | None = None,
163
+ ) -> "AgentRuntimeConfig":
164
+ values = os.environ if env is None else env
165
+ context = ContextPolicy.from_env(values)
166
+ config = cls(
167
+ max_turns=_env_int(values, "FREELLAMA_AGENT_MAX_TURNS", 10, minimum=1),
168
+ num_ctx=_env_int(values, "FREELLAMA_AGENT_NUM_CTX", 8192, minimum=1),
169
+ num_predict=_env_int(values, "FREELLAMA_AGENT_NUM_PREDICT", 512, minimum=1),
170
+ temperature=_env_float(values, "FREELLAMA_AGENT_TEMPERATURE", 0.0),
171
+ seed=_env_int(values, "FREELLAMA_AGENT_SEED", 42),
172
+ request_timeout_seconds=_env_float(values, "FREELLAMA_AGENT_REQUEST_TIMEOUT_SECONDS", 600.0, minimum=0.001),
173
+ retry_attempts=_env_int(values, "FREELLAMA_AGENT_RETRY_ATTEMPTS", 2, minimum=1),
174
+ retry_backoff_seconds=_env_float(values, "FREELLAMA_AGENT_RETRY_BACKOFF_SECONDS", 5.0),
175
+ tool_timeout_seconds=_env_float(values, "FREELLAMA_AGENT_TOOL_TIMEOUT_SECONDS", default_tool_timeout_seconds, minimum=0.001),
176
+ keep_alive=values.get("FREELLAMA_AGENT_KEEP_ALIVE", "5m").strip(),
177
+ think=_env_bool(values, "FREELLAMA_AGENT_THINK", False),
178
+ max_parse_repairs=_env_int(values, "FREELLAMA_AGENT_MAX_PARSE_REPAIRS", MAX_PARSE_REPAIRS, minimum=0),
179
+ parse_repair_echo_chars=_env_int(values, "FREELLAMA_AGENT_PARSE_REPAIR_ECHO_CHARS", 500, minimum=1),
180
+ context=context,
181
+ )
182
+ if context_budget(config.num_ctx, config.num_predict, context.safety_margin_tokens) <= 0:
183
+ raise ValueError(
184
+ "FREELLAMA_AGENT_NUM_CTX must exceed FREELLAMA_AGENT_NUM_PREDICT plus "
185
+ "FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS"
186
+ )
187
+ if not config.keep_alive:
188
+ raise ValueError("FREELLAMA_AGENT_KEEP_ALIVE must not be empty")
189
+ return config
190
+
191
+ def metadata(self) -> dict[str, Any]:
192
+ result = asdict(self)
193
+ result.pop("context", None)
194
+ return result
195
+
196
+
197
+ def estimate_tokens(text: str, chars_per_token: float = CHARS_PER_TOKEN) -> int:
198
+ """Approximate a first-call token count; later model counts calibrate the multiplier."""
199
+ return math.floor(len(text) / chars_per_token) + 1
200
+
201
+
202
+ def messages_tokens(messages: list[dict[str, Any]], policy: ContextPolicy | None = None) -> int:
203
+ """Estimate typed Ollama messages without flattening images/tool calls into text."""
204
+ policy = policy or ContextPolicy()
205
+ total = 0
206
+ for message in messages:
207
+ content = message.get("content", "")
208
+ total += estimate_tokens(
209
+ content if isinstance(content, str) else json.dumps(content, ensure_ascii=False),
210
+ policy.chars_per_token,
211
+ )
212
+ images = message.get("images", [])
213
+ if isinstance(images, list):
214
+ total += len(images) * policy.image_token_estimate
215
+ metadata = {key: value for key, value in message.items() if key not in {"content", "images"}}
216
+ total += estimate_tokens(
217
+ json.dumps(metadata, ensure_ascii=False, default=str), policy.chars_per_token
218
+ )
219
+ return total
220
+
221
+
222
+ class ContextBudgeter:
223
+ """Conservative estimate calibrated by Ollama's real post-call prompt count.
224
+
225
+ Ollama currently has no stable preflight tokenizer endpoint. The first call therefore uses the
226
+ configured character estimate. After every successful call, `prompt_eval_count` raises (never
227
+ lowers) the estimate multiplier for this exact model/chat template.
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ policy: ContextPolicy | None = None,
233
+ *,
234
+ initial_scale: float = 1.0,
235
+ calibration_samples: int = 0,
236
+ ) -> None:
237
+ self.policy = policy or ContextPolicy()
238
+ self.scale = max(float(initial_scale), 1.0)
239
+ self.calibration_samples = max(int(calibration_samples), 0)
240
+
241
+ def estimate(self, messages: list[dict[str, Any]]) -> int:
242
+ return math.ceil(messages_tokens(messages, self.policy) * self.scale)
243
+
244
+ def observe(self, messages: list[dict[str, Any]], prompt_eval_count: Any) -> bool:
245
+ try:
246
+ actual = int(prompt_eval_count)
247
+ except (TypeError, ValueError):
248
+ return False
249
+ if actual <= 0:
250
+ return False
251
+ baseline = max(messages_tokens(messages, self.policy), 1)
252
+ previous = self.scale
253
+ self.scale = max(self.scale, actual / baseline)
254
+ self.calibration_samples += 1
255
+ return self.scale > previous
256
+
257
+ def metadata(self) -> dict[str, Any]:
258
+ return {
259
+ "token_counting": "model_calibrated_estimate" if self.calibration_samples else "configured_estimate",
260
+ "estimate_scale": round(self.scale, 6),
261
+ "calibration_samples": self.calibration_samples,
262
+ }
263
+
264
+
265
+ def clip(text: str, limit: int, head_ratio: float = CLIP_HEAD_RATIO) -> str:
266
+ """Truncate to `limit` characters keeping both ends.
267
+
268
+ The adapters previously did `text[-limit:]`, keeping only the tail. That is the wrong half for
269
+ almost every tool used here: a directory listing, a grep result and a file read all put their
270
+ most identifying content first, so tail-only truncation threw away the part the model needed
271
+ and kept the trailing noise. Keeping a majority head plus a minority tail preserves the
272
+ beginning of the result while still showing where it ended.
273
+ """
274
+ if not 0 < head_ratio < 1:
275
+ raise ValueError("head_ratio must be between 0 and 1")
276
+ if limit <= 0 or len(text) <= limit:
277
+ return text
278
+ marker_template = "\n… [{dropped} characters elided] …\n"
279
+ # Reserve room for the marker itself so the result never exceeds `limit`.
280
+ marker_budget = len(marker_template.format(dropped=len(text)))
281
+ usable = max(limit - marker_budget, 0)
282
+ if usable == 0:
283
+ return text[:limit]
284
+ head_chars = int(usable * head_ratio)
285
+ tail_chars = usable - head_chars
286
+ dropped = len(text) - head_chars - tail_chars
287
+ tail = text[-tail_chars:] if tail_chars else ""
288
+ return text[:head_chars] + marker_template.format(dropped=dropped) + tail
289
+
290
+
291
+ def context_budget(
292
+ num_ctx: int,
293
+ num_predict: int,
294
+ safety_margin_tokens: int = SAFETY_MARGIN_TOKENS,
295
+ ) -> int:
296
+ """Tokens available for the conversation itself, once generation and slack are reserved."""
297
+ return max(num_ctx - num_predict - safety_margin_tokens, 0)
298
+
299
+
300
+ def _compact(content: str, policy: ContextPolicy) -> str:
301
+ preview = " ".join(content[: policy.compact_preview_chars].split())
302
+ return f"[earlier observation compacted to save context] {preview}…"
303
+
304
+
305
+ def fit_to_context(
306
+ messages: list[dict[str, Any]],
307
+ *,
308
+ num_ctx: int,
309
+ num_predict: int,
310
+ keep_recent: int | None = None,
311
+ policy: ContextPolicy | None = None,
312
+ token_counter: Callable[[list[dict[str, Any]]], int] | None = None,
313
+ ) -> tuple[list[dict[str, Any]], bool]:
314
+ """Trim a conversation to fit `num_ctx`, protecting the parts that must never be dropped.
315
+
316
+ Returns `(messages, compacted)`. The system prompt (index 0) and the original question
317
+ (index 1) are pinned: they are the two messages Ollama's own front-truncation would delete
318
+ first, and losing either is what turns a working agent into one that emits prose instead of
319
+ JSON. Compaction walks oldest-first through the observation turns, so the model keeps a
320
+ breadcrumb of every step it took even when the full text of the early ones is gone.
321
+ """
322
+ policy = policy or ContextPolicy()
323
+ keep_recent = policy.keep_recent if keep_recent is None else keep_recent
324
+ budget = context_budget(num_ctx, num_predict, policy.safety_margin_tokens)
325
+ count = token_counter or (lambda value: messages_tokens(value, policy))
326
+ if count(messages) <= budget:
327
+ return messages, False
328
+
329
+ pinned = min(2, len(messages))
330
+ trimmed = [dict(message) for message in messages]
331
+ # Tool-result messages are first-class observations in native Ollama agent loops. Preserve their
332
+ # role and tool_name/tool_call linkage; compact only their textual payload.
333
+ observations = [
334
+ index
335
+ for index in range(pinned, len(trimmed))
336
+ if trimmed[index].get("role") in {"user", "tool"}
337
+ and isinstance(trimmed[index].get("content"), str)
338
+ ]
339
+ protected = set(observations[-keep_recent:]) if keep_recent else set()
340
+ compacted = False
341
+ for index in observations:
342
+ if count(trimmed) <= budget:
343
+ break
344
+ if index in protected:
345
+ continue
346
+ content = trimmed[index].get("content", "")
347
+ replacement = _compact(content, policy)
348
+ if len(replacement) < len(content):
349
+ trimmed[index]["content"] = replacement
350
+ compacted = True
351
+
352
+ # Still over after compacting every droppable observation: the recent turns alone exceed the
353
+ # window. Clip the protected ones too rather than hand Ollama a prompt it will truncate blind.
354
+ #
355
+ # This shrinks the largest observation repeatedly instead of computing one subtraction, because
356
+ # the token estimate rounds up per message: a single-pass calculation lands just over budget
357
+ # and "just over" is exactly as silently truncated as "far over". Loop until it actually fits.
358
+ guard = 0
359
+ while count(trimmed) > budget and guard < 200:
360
+ guard += 1
361
+ widest = max(
362
+ observations,
363
+ key=lambda index: len(trimmed[index].get("content", "")),
364
+ default=None,
365
+ )
366
+ if widest is None:
367
+ break
368
+ content = trimmed[widest].get("content", "")
369
+ keep = max(int(len(content) * policy.compact_retain_ratio), policy.compact_preview_chars)
370
+ if keep >= len(content):
371
+ break
372
+ trimmed[widest]["content"] = clip(content, keep, policy.clip_head_ratio)
373
+ compacted = True
374
+ # Old reasoning traces can dwarf the answer and are not required to preserve a tool-call pair.
375
+ # Keep the field typed, but reduce its payload after observations have already been compacted.
376
+ for message in trimmed[pinned:-2]:
377
+ thinking = message.get("thinking")
378
+ if count(trimmed) <= budget:
379
+ break
380
+ if isinstance(thinking, str) and len(thinking) > policy.compact_preview_chars:
381
+ message["thinking"] = _compact(thinking, policy)
382
+ compacted = True
383
+
384
+ # Compact any remaining non-pinned textual history before considering the pinned contract.
385
+ guard = 0
386
+ while count(trimmed) > budget and guard < 200:
387
+ guard += 1
388
+ candidates = [
389
+ index
390
+ for index, message in enumerate(trimmed[pinned:], start=pinned)
391
+ if isinstance(message.get("content"), str)
392
+ and len(message.get("content", "")) > policy.compact_preview_chars
393
+ ]
394
+ if not candidates:
395
+ break
396
+ widest = max(candidates, key=lambda index: len(trimmed[index].get("content", "")))
397
+ content = trimmed[widest]["content"]
398
+ keep = max(int(len(content) * policy.compact_retain_ratio), policy.compact_preview_chars)
399
+ if keep >= len(content):
400
+ break
401
+ trimmed[widest]["content"] = clip(content, keep, policy.clip_head_ratio)
402
+ compacted = True
403
+
404
+ # Pinned text is byte-preserved by default. An operator may explicitly choose `clip`, but the
405
+ # safe default fails before Ollama can silently front-truncate the system contract or question.
406
+ guard = 0
407
+ while policy.pinned_overflow == "clip" and count(trimmed) > budget and guard < 200:
408
+ guard += 1
409
+ candidates = [
410
+ index
411
+ for index, message in enumerate(trimmed[:pinned])
412
+ if isinstance(message.get("content"), str)
413
+ and len(message.get("content", "")) > policy.compact_preview_chars
414
+ ]
415
+ if not candidates:
416
+ break
417
+ widest = max(candidates, key=lambda index: len(trimmed[index].get("content", "")))
418
+ content = trimmed[widest]["content"]
419
+ keep = max(int(len(content) * policy.compact_retain_ratio), policy.compact_preview_chars)
420
+ if keep >= len(content):
421
+ break
422
+ trimmed[widest]["content"] = clip(content, keep, policy.clip_head_ratio)
423
+ compacted = True
424
+ if count(trimmed) > budget:
425
+ raise ValueError(
426
+ "pinned system/question content and minimum history exceed context budget: "
427
+ f"{count(trimmed)} > {budget} estimated tokens. Increase FREELLAMA_AGENT_NUM_CTX, "
428
+ "reduce the task/instructions, or explicitly set FREELLAMA_AGENT_PINNED_OVERFLOW=clip."
429
+ )
430
+ return trimmed, compacted
431
+
432
+
433
+ class AgentContextManager:
434
+ """Stateful adapter facade: fit, calibrate, count compactions, and report policy."""
435
+
436
+ def __init__(
437
+ self,
438
+ runtime: AgentRuntimeConfig,
439
+ *,
440
+ model: str = "",
441
+ calibration_dir: Path | None = None,
442
+ ) -> None:
443
+ self.runtime = runtime
444
+ self.model = model
445
+ self.calibration_dir = calibration_dir
446
+ loaded = self._load_calibration()
447
+ self.budgeter = ContextBudgeter(
448
+ runtime.context,
449
+ initial_scale=loaded.get("scale", 1.0),
450
+ calibration_samples=loaded.get("samples", 0),
451
+ )
452
+ self.calibration_source = "persistent_model_cache" if loaded else "current_process"
453
+ self.compactions = 0
454
+
455
+ def _model_key(self) -> str:
456
+ return hashlib.sha256(self.model.encode("utf-8")).hexdigest()[:24]
457
+
458
+ def _load_calibration(self) -> dict[str, Any]:
459
+ if not self.model or self.calibration_dir is None or not self.calibration_dir.exists():
460
+ return {}
461
+ best_scale = 1.0
462
+ samples = 0
463
+ for path in self.calibration_dir.glob(f"{self._model_key()}-*.json"):
464
+ try:
465
+ record = json.loads(path.read_text(encoding="utf-8"))
466
+ if record.get("schema_version") != 1 or record.get("model") != self.model:
467
+ continue
468
+ best_scale = max(best_scale, float(record.get("scale", 1.0)))
469
+ samples = max(samples, int(record.get("samples", 0)))
470
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
471
+ continue
472
+ return {"scale": best_scale, "samples": samples} if samples else {}
473
+
474
+ def _persist_calibration(self) -> None:
475
+ if not self.model or self.calibration_dir is None:
476
+ return
477
+ self.calibration_dir.mkdir(parents=True, exist_ok=True)
478
+ key = self._model_key()
479
+ record = {
480
+ "schema_version": 1,
481
+ "model": self.model,
482
+ "scale": self.budgeter.scale,
483
+ "samples": self.budgeter.calibration_samples,
484
+ }
485
+ final = self.calibration_dir / f"{key}-{time.time_ns()}-{os.getpid()}.json"
486
+ temporary = final.with_suffix(".tmp")
487
+ temporary.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
488
+ os.replace(temporary, final)
489
+ # Bounded, race-tolerant history: independent records make concurrent writers monotonic;
490
+ # retaining the newest 32 avoids an unbounded cache without a cross-platform file lock.
491
+ records = sorted(self.calibration_dir.glob(f"{key}-*.json"), key=lambda path: path.name)
492
+ for stale in records[:-32]:
493
+ try:
494
+ stale.unlink()
495
+ except FileNotFoundError:
496
+ pass
497
+
498
+ def fit(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
499
+ fitted, compacted = fit_to_context(
500
+ messages,
501
+ num_ctx=self.runtime.num_ctx,
502
+ num_predict=self.runtime.num_predict,
503
+ policy=self.runtime.context,
504
+ token_counter=self.budgeter.estimate,
505
+ )
506
+ self.compactions += int(compacted)
507
+ return fitted
508
+
509
+ def observe(self, messages: list[dict[str, Any]], prompt_eval_count: Any) -> None:
510
+ if self.budgeter.observe(messages, prompt_eval_count):
511
+ self._persist_calibration()
512
+
513
+ def metadata(self) -> dict[str, Any]:
514
+ return {
515
+ **self.runtime.context.metadata(),
516
+ **self.budgeter.metadata(),
517
+ "calibration_source": self.calibration_source,
518
+ "estimated_input_budget": context_budget(
519
+ self.runtime.num_ctx,
520
+ self.runtime.num_predict,
521
+ self.runtime.context.safety_margin_tokens,
522
+ ),
523
+ "compactions": self.compactions,
524
+ }
525
+
526
+
527
+ def write_failure_result(result_path: Path, answer: str) -> None:
528
+ """Write the minimum valid adapter result for a failure before the normal result path."""
529
+ result_path.parent.mkdir(parents=True, exist_ok=True)
530
+ result_path.write_text(
531
+ json.dumps({"final_answer": answer, "tool_calls": [], "usage": {}}, indent=2) + "\n",
532
+ encoding="utf-8",
533
+ )
534
+
535
+
536
+ def parse_json_action(content: str) -> dict[str, Any]:
537
+ """Recover one JSON object while leaving adapter-specific action validation to the caller."""
538
+ text = content.strip()
539
+ if text.startswith("```"):
540
+ lines = text.splitlines()
541
+ text = "\n".join(lines[1:-1]).strip()
542
+ try:
543
+ value = json.loads(text)
544
+ except json.JSONDecodeError:
545
+ decoder = json.JSONDecoder()
546
+ start = text.find("{")
547
+ if start < 0:
548
+ raise ValueError("model did not return a JSON action") from None
549
+ try:
550
+ value, _ = decoder.raw_decode(text, start)
551
+ except json.JSONDecodeError:
552
+ raise ValueError("model did not return a valid JSON action") from None
553
+ if not isinstance(value, dict) or not isinstance(value.get("action"), str):
554
+ raise ValueError("model action must be a JSON object with an action")
555
+ return value
556
+
557
+
558
+ def call_signature(payload: Any) -> str:
559
+ """Stable identity for a tool call, so an exact repeat can be recognized.
560
+
561
+ Both adapters tell the model "do not repeat prior calls" and neither enforced it. A repeat is
562
+ not just a wasted turn out of ten — it also re-appends an observation the model has already
563
+ seen, spending context twice on one fact.
564
+ """
565
+ return json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
566
+
567
+
568
+ REPEAT_NOTICE = (
569
+ "(identical call already made this run — its result is above and was not re-executed. "
570
+ "Use a different call, or finish now with the evidence already collected.)"
571
+ )
572
+
573
+
574
+ # How many unparseable turns to try to repair before giving up on the run. A model that emits prose
575
+ # instead of JSON once will usually correct when told so explicitly; aborting on the first one threw
576
+ # away an entire delegation — including every tool result already gathered — and surfaced as
577
+ # "model did not return a valid JSON action", which reads like model weakness rather than a loop
578
+ # with no error recovery.
579
+ MAX_PARSE_REPAIRS = 2
580
+
581
+ PARSE_REPAIR_NOTICE = (
582
+ "Your last reply was not a valid JSON action, so it was discarded. Reply with EXACTLY one JSON "
583
+ "object and nothing else — no prose before or after, no markdown fence. Use the action shapes "
584
+ "given in your instructions. If you already have enough evidence, send the finish action now."
585
+ )
586
+
587
+ # ---------------------------------------------------------------------------------------------
588
+ # Pagination — the alternative to throwing data away
589
+ #
590
+ # Clipping an observation is silent data loss the model can never recover: a routine
591
+ # `grep -rn "def " .` over jinja produces ~27,000 characters, and a 3,000-character clip discarded
592
+ # 89% of it. The model cannot ask for the rest, cannot tell how much it is missing, and will
593
+ # happily conclude "not found" from a window that never contained the answer.
594
+ #
595
+ # Pagination keeps every byte. The full observation is retained, the model is shown one page plus
596
+ # an exact instruction for requesting the next, and asking for page 2 re-serves stored text rather
597
+ # than re-running the command — so paging costs a turn, never a duplicate subprocess.
598
+ # ---------------------------------------------------------------------------------------------
599
+
600
+ def paginate(text: str, page: int = 1, page_size: int = OBSERVATION_PAGE_CHARS) -> tuple[str, int, int]:
601
+ """Return `(chunk, page, total_pages)`, splitting on line boundaries.
602
+
603
+ Line-aware on purpose: these observations are grep hits, directory listings and file slices,
604
+ where a chunk cut mid-line yields a truncated path or a half identifier — exactly the kind of
605
+ fragment that gets misread as data rather than as damage.
606
+ """
607
+ if not text:
608
+ return "", 1, 1
609
+ lines = text.splitlines(keepends=True)
610
+ pages: list[list[str]] = [[]]
611
+ size = 0
612
+ for line in lines:
613
+ # A single line longer than a page gets its own page rather than being split.
614
+ if size and size + len(line) > page_size:
615
+ pages.append([])
616
+ size = 0
617
+ pages[-1].append(line)
618
+ size += len(line)
619
+ total = max(1, len(pages))
620
+ page = max(1, min(page, total))
621
+ return "".join(pages[page - 1]), page, total
622
+
623
+
624
+ def page_footer(step: int, page: int, total: int, total_chars: int) -> str:
625
+ """Tell the model exactly how to get the rest — or that there is no rest."""
626
+ if total <= 1:
627
+ return ""
628
+ remaining = total - page
629
+ nxt = page + 1 if remaining else 1
630
+ return (
631
+ f'\n\n[page {page} of {total} — {total_chars} characters total, nothing discarded. '
632
+ f'For the next page send exactly: {{"action":"page","step":{step},"page":{nxt}}} '
633
+ f"— that re-reads stored output and does NOT re-run the command.]"
634
+ )
635
+
636
+
637
+ class ObservationStore:
638
+ """Keeps every observation in full so any page can be served later.
639
+
640
+ This is what makes pagination lossless rather than a nicer-looking clip: the text stays here
641
+ whole, and the conversation only ever holds the page the model asked for.
642
+ """
643
+
644
+ def __init__(self, page_size: int = OBSERVATION_PAGE_CHARS) -> None:
645
+ if page_size <= 0:
646
+ raise ValueError("observation page size must be > 0")
647
+ self._by_step: dict[int, str] = {}
648
+ self.page_size = page_size
649
+
650
+ def put(self, step: int, text: str) -> None:
651
+ self._by_step[step] = text
652
+
653
+ def get(self, step: int) -> str | None:
654
+ return self._by_step.get(step)
655
+
656
+ def view(self, step: int, page: int = 1) -> tuple[str, str]:
657
+ """Return `(body, footer)` for one page of a stored observation."""
658
+ text = self._by_step.get(step)
659
+ if text is None:
660
+ known = ", ".join(str(k) for k in sorted(self._by_step)) or "none yet"
661
+ return (f"no stored output for step {step} (known steps: {known})", "")
662
+ chunk, page, total = paginate(text, page, self.page_size)
663
+ return chunk, page_footer(step, page, total, len(text))