@octocodeai/freellama-mcp-server 0.5.0 → 0.6.1

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.
@@ -1,850 +0,0 @@
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 re
24
- import struct
25
- import tempfile
26
- import time
27
- from dataclasses import asdict, dataclass
28
- from pathlib import Path
29
- from typing import Any, Callable, Mapping
30
-
31
- # Ollama reports no token counts until after a call, so the first budget uses a configurable
32
- # approximation. Real prompt_eval_count values then raise the multiplier whenever this estimate
33
- # was optimistic; it never lowers itself from a sample.
34
- CHARS_PER_TOKEN = 4
35
- # Headroom held back from `num_ctx` on top of `num_predict`: chat templating, tool-call scaffolding
36
- # and the token estimate's own error all land inside the same window.
37
- SAFETY_MARGIN_TOKENS = 256
38
- # Base64 bytes are not text tokens. Multimodal templates turn each image into a bounded token
39
- # sequence; charging a conservative fixed amount avoids treating a 5MB JPEG as 1.25M text tokens.
40
- IMAGE_TOKEN_ESTIMATE = 1024
41
- # Observations kept verbatim. The two most recent are what the model is actually reasoning over;
42
- # older ones have usually already been distilled into the answer it is assembling.
43
- DEFAULT_KEEP_RECENT = 2
44
- # How much of a compacted observation survives as a breadcrumb, so the model still knows the call
45
- # happened and roughly what it returned rather than silently losing the step.
46
- COMPACT_PREVIEW_CHARS = 180
47
- # Emergency clipping keeps this fraction of the current payload on each pass. It is deliberately
48
- # iterative: rounding and per-message metadata make a one-shot subtraction unsafe.
49
- COMPACT_RETAIN_RATIO = 0.8
50
- CLIP_HEAD_RATIO = 2 / 3
51
- # Full observations are kept outside the prompt and served in line-aware pages of this size.
52
- OBSERVATION_PAGE_CHARS = 3000
53
- # Once an overflow requires changing old observations, reclaim modest headroom in the same pass.
54
- # This is a target after overflow, not an early trigger; already-fitting prefixes stay untouched.
55
- COMPACTION_TARGET_RATIO = 0.9
56
- COMPACT_MARKER = "[earlier observation compacted to save context"
57
-
58
-
59
- def _env_int(env: Mapping[str, str], name: str, default: int, *, minimum: int = 0) -> int:
60
- raw = env.get(name)
61
- if raw is None or raw == "":
62
- return default
63
- try:
64
- value = int(raw)
65
- except ValueError as error:
66
- raise ValueError(f"{name} must be an integer, got {raw!r}") from error
67
- if value < minimum:
68
- raise ValueError(f"{name} must be >= {minimum}, got {value}")
69
- return value
70
-
71
-
72
- def _env_float(env: Mapping[str, str], name: str, default: float, *, minimum: float = 0.0) -> float:
73
- raw = env.get(name)
74
- if raw is None or raw == "":
75
- return default
76
- try:
77
- value = float(raw)
78
- except ValueError as error:
79
- raise ValueError(f"{name} must be a number, got {raw!r}") from error
80
- if not math.isfinite(value) or value < minimum:
81
- raise ValueError(f"{name} must be finite and >= {minimum}, got {raw!r}")
82
- return value
83
-
84
-
85
- def _env_bool(env: Mapping[str, str], name: str, default: bool) -> bool:
86
- raw = env.get(name)
87
- if raw is None or raw == "":
88
- return default
89
- normalized = raw.strip().lower()
90
- if normalized in {"1", "true", "yes", "on"}:
91
- return True
92
- if normalized in {"0", "false", "no", "off"}:
93
- return False
94
- raise ValueError(f"{name} must be true/false, got {raw!r}")
95
-
96
-
97
- @dataclass(frozen=True)
98
- class ContextPolicy:
99
- """Validated context-management policy shared by every research adapter."""
100
-
101
- chars_per_token: float = CHARS_PER_TOKEN
102
- safety_margin_tokens: int = SAFETY_MARGIN_TOKENS
103
- image_token_estimate: int = IMAGE_TOKEN_ESTIMATE
104
- keep_recent: int = DEFAULT_KEEP_RECENT
105
- compact_preview_chars: int = COMPACT_PREVIEW_CHARS
106
- compact_retain_ratio: float = COMPACT_RETAIN_RATIO
107
- clip_head_ratio: float = CLIP_HEAD_RATIO
108
- observation_page_chars: int = OBSERVATION_PAGE_CHARS
109
- pinned_overflow: str = "error"
110
-
111
- def __post_init__(self) -> None:
112
- if not math.isfinite(self.chars_per_token) or self.chars_per_token <= 0:
113
- raise ValueError("chars_per_token must be finite and > 0")
114
- if self.safety_margin_tokens < 0 or self.image_token_estimate < 0 or self.keep_recent < 0:
115
- raise ValueError("context token estimates and keep_recent must be >= 0")
116
- if self.compact_preview_chars <= 0 or self.observation_page_chars <= 0:
117
- raise ValueError("preview and page sizes must be > 0")
118
- if not 0 < self.compact_retain_ratio < 1:
119
- raise ValueError("compact_retain_ratio must be between 0 and 1")
120
- if not 0 < self.clip_head_ratio < 1:
121
- raise ValueError("clip_head_ratio must be between 0 and 1")
122
- if self.pinned_overflow not in {"error", "clip"}:
123
- raise ValueError("pinned_overflow must be 'error' or 'clip'")
124
-
125
- @classmethod
126
- def from_env(cls, env: Mapping[str, str] | None = None) -> "ContextPolicy":
127
- values = os.environ if env is None else env
128
- pinned_overflow = values.get("FREELLAMA_AGENT_PINNED_OVERFLOW", "error").strip().lower()
129
- return cls(
130
- chars_per_token=_env_float(values, "FREELLAMA_AGENT_CHARS_PER_TOKEN", CHARS_PER_TOKEN, minimum=0.000001),
131
- safety_margin_tokens=_env_int(values, "FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS", SAFETY_MARGIN_TOKENS),
132
- image_token_estimate=_env_int(values, "FREELLAMA_AGENT_IMAGE_TOKEN_ESTIMATE", IMAGE_TOKEN_ESTIMATE),
133
- keep_recent=_env_int(values, "FREELLAMA_AGENT_KEEP_RECENT", DEFAULT_KEEP_RECENT),
134
- compact_preview_chars=_env_int(values, "FREELLAMA_AGENT_COMPACT_PREVIEW_CHARS", COMPACT_PREVIEW_CHARS, minimum=1),
135
- compact_retain_ratio=_env_float(values, "FREELLAMA_AGENT_COMPACT_RETAIN_RATIO", COMPACT_RETAIN_RATIO, minimum=0.000001),
136
- clip_head_ratio=_env_float(values, "FREELLAMA_AGENT_CLIP_HEAD_RATIO", CLIP_HEAD_RATIO, minimum=0.000001),
137
- observation_page_chars=_env_int(values, "FREELLAMA_AGENT_OBSERVATION_PAGE_CHARS", OBSERVATION_PAGE_CHARS, minimum=1),
138
- pinned_overflow=pinned_overflow,
139
- )
140
-
141
- def metadata(self) -> dict[str, Any]:
142
- return asdict(self)
143
-
144
-
145
- @dataclass(frozen=True)
146
- class AgentRuntimeConfig:
147
- """Typed operational settings; safety confinement remains an invariant, not a knob."""
148
-
149
- max_turns: int
150
- num_ctx: int
151
- num_predict: int
152
- temperature: float
153
- seed: int
154
- request_timeout_seconds: float
155
- retry_attempts: int
156
- retry_backoff_seconds: float
157
- tool_timeout_seconds: float
158
- keep_alive: str
159
- think: bool
160
- max_parse_repairs: int
161
- parse_repair_echo_chars: int
162
- context: ContextPolicy
163
-
164
- @classmethod
165
- def from_env(
166
- cls,
167
- *,
168
- default_tool_timeout_seconds: float,
169
- env: Mapping[str, str] | None = None,
170
- ) -> "AgentRuntimeConfig":
171
- values = os.environ if env is None else env
172
- context = ContextPolicy.from_env(values)
173
- config = cls(
174
- max_turns=_env_int(values, "FREELLAMA_AGENT_MAX_TURNS", 10, minimum=1),
175
- num_ctx=_env_int(values, "FREELLAMA_AGENT_NUM_CTX", 8192, minimum=1),
176
- num_predict=_env_int(values, "FREELLAMA_AGENT_NUM_PREDICT", 512, minimum=1),
177
- temperature=_env_float(values, "FREELLAMA_AGENT_TEMPERATURE", 0.0),
178
- seed=_env_int(values, "FREELLAMA_AGENT_SEED", 42),
179
- request_timeout_seconds=_env_float(values, "FREELLAMA_AGENT_REQUEST_TIMEOUT_SECONDS", 600.0, minimum=0.001),
180
- retry_attempts=_env_int(values, "FREELLAMA_AGENT_RETRY_ATTEMPTS", 2, minimum=1),
181
- retry_backoff_seconds=_env_float(values, "FREELLAMA_AGENT_RETRY_BACKOFF_SECONDS", 5.0),
182
- tool_timeout_seconds=_env_float(values, "FREELLAMA_AGENT_TOOL_TIMEOUT_SECONDS", default_tool_timeout_seconds, minimum=0.001),
183
- keep_alive=values.get("FREELLAMA_AGENT_KEEP_ALIVE", "5m").strip(),
184
- think=_env_bool(values, "FREELLAMA_AGENT_THINK", False),
185
- max_parse_repairs=_env_int(values, "FREELLAMA_AGENT_MAX_PARSE_REPAIRS", MAX_PARSE_REPAIRS, minimum=0),
186
- parse_repair_echo_chars=_env_int(values, "FREELLAMA_AGENT_PARSE_REPAIR_ECHO_CHARS", 500, minimum=1),
187
- context=context,
188
- )
189
- if context_budget(config.num_ctx, config.num_predict, context.safety_margin_tokens) <= 0:
190
- raise ValueError(
191
- "FREELLAMA_AGENT_NUM_CTX must exceed FREELLAMA_AGENT_NUM_PREDICT plus "
192
- "FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS"
193
- )
194
- if not config.keep_alive:
195
- raise ValueError("FREELLAMA_AGENT_KEEP_ALIVE must not be empty")
196
- return config
197
-
198
- def metadata(self) -> dict[str, Any]:
199
- result = asdict(self)
200
- result.pop("context", None)
201
- return result
202
-
203
-
204
- def estimate_tokens(text: str, chars_per_token: float = CHARS_PER_TOKEN) -> int:
205
- """Approximate a first-call token count; later model counts calibrate the multiplier."""
206
- return math.floor(len(text) / chars_per_token) + 1
207
-
208
-
209
- def messages_tokens(messages: list[dict[str, Any]], policy: ContextPolicy | None = None) -> int:
210
- """Estimate typed Ollama messages without flattening images/tool calls into text."""
211
- policy = policy or ContextPolicy()
212
- total = 0
213
- for message in messages:
214
- content = message.get("content", "")
215
- total += estimate_tokens(
216
- content if isinstance(content, str) else json.dumps(content, ensure_ascii=False),
217
- policy.chars_per_token,
218
- )
219
- images = message.get("images", [])
220
- if isinstance(images, list):
221
- total += len(images) * policy.image_token_estimate
222
- metadata = {key: value for key, value in message.items() if key not in {"content", "images"}}
223
- total += estimate_tokens(
224
- json.dumps(metadata, ensure_ascii=False, default=str), policy.chars_per_token
225
- )
226
- return total
227
-
228
-
229
- class ContextBudgeter:
230
- """Conservative estimate calibrated by Ollama's real post-call prompt count.
231
-
232
- Ollama currently has no stable preflight tokenizer endpoint. The first call therefore uses the
233
- configured character estimate. After every successful call, `prompt_eval_count` raises (never
234
- lowers) the estimate multiplier. A model tag alone does not identify an immutable template.
235
- """
236
-
237
- def __init__(
238
- self,
239
- policy: ContextPolicy | None = None,
240
- *,
241
- initial_scale: float = 1.0,
242
- calibration_samples: int = 0,
243
- ) -> None:
244
- self.policy = policy or ContextPolicy()
245
- self.scale = max(float(initial_scale), 1.0)
246
- self.calibration_samples = max(int(calibration_samples), 0)
247
-
248
- def estimate(self, messages: list[dict[str, Any]]) -> int:
249
- return math.ceil(messages_tokens(messages, self.policy) * self.scale)
250
-
251
- def observe(self, messages: list[dict[str, Any]], prompt_eval_count: Any) -> bool:
252
- try:
253
- actual = int(prompt_eval_count)
254
- except (TypeError, ValueError):
255
- return False
256
- if actual <= 0:
257
- return False
258
- baseline = max(messages_tokens(messages, self.policy), 1)
259
- previous = self.scale
260
- self.scale = max(self.scale, actual / baseline)
261
- self.calibration_samples += 1
262
- return self.scale > previous
263
-
264
- def metadata(self) -> dict[str, Any]:
265
- return {
266
- "token_counting": "model_calibrated_estimate" if self.calibration_samples else "configured_estimate",
267
- "estimate_scale": round(self.scale, 6),
268
- "calibration_samples": self.calibration_samples,
269
- }
270
-
271
-
272
- def clip(text: str, limit: int, head_ratio: float = CLIP_HEAD_RATIO) -> str:
273
- """Truncate to `limit` characters keeping both ends.
274
-
275
- The adapters previously did `text[-limit:]`, keeping only the tail. That is the wrong half for
276
- almost every tool used here: a directory listing, a grep result and a file read all put their
277
- most identifying content first, so tail-only truncation threw away the part the model needed
278
- and kept the trailing noise. Keeping a majority head plus a minority tail preserves the
279
- beginning of the result while still showing where it ended.
280
- """
281
- if not 0 < head_ratio < 1:
282
- raise ValueError("head_ratio must be between 0 and 1")
283
- if limit <= 0 or len(text) <= limit:
284
- return text
285
- marker_template = "\n… [{dropped} characters elided] …\n"
286
- # Reserve room for the marker itself so the result never exceeds `limit`.
287
- marker_budget = len(marker_template.format(dropped=len(text)))
288
- usable = max(limit - marker_budget, 0)
289
- if usable == 0:
290
- return text[:limit]
291
- head_chars = int(usable * head_ratio)
292
- tail_chars = usable - head_chars
293
- dropped = len(text) - head_chars - tail_chars
294
- tail = text[-tail_chars:] if tail_chars else ""
295
- return text[:head_chars] + marker_template.format(dropped=dropped) + tail
296
-
297
-
298
- def context_budget(
299
- num_ctx: int,
300
- num_predict: int,
301
- safety_margin_tokens: int = SAFETY_MARGIN_TOKENS,
302
- ) -> int:
303
- """Tokens available for the conversation itself, once generation and slack are reserved."""
304
- return max(num_ctx - num_predict - safety_margin_tokens, 0)
305
-
306
-
307
- def _compact(content: str, policy: ContextPolicy) -> str:
308
- """Keep a deterministic evidence breadcrumb and a usable stored-output handle.
309
-
310
- This is an extract, not a semantic summary: no inferred facts or decisions are added. Existing
311
- breadcrumbs remain byte-identical so later overflows do not invalidate the prefix again.
312
- """
313
- if content.startswith(COMPACT_MARKER):
314
- return content
315
- step_match = re.match(r"Observation \(step ([0-9]+)\):\n", content)
316
- recovery = ""
317
- body = content
318
- if step_match:
319
- step = int(step_match.group(1))
320
- body = content[step_match.end():]
321
- # Adapter-generated ledger precedes untrusted tool bytes. Preserve its concrete handles
322
- # and status; this never promotes a tool result into instructions or inferred facts.
323
- ledger_line, separator, remainder = body.partition("\n")
324
- ledger = ledger_line if ledger_line.startswith("[evidence ledger: ") and ledger_line.endswith("]") else ""
325
- if ledger and separator:
326
- body = remainder
327
- # Adapter loop reminders describe the old turn, not the evidence. The latest turn retains
328
- # its live reminder; exclude this known suffix from the tail extract of older results.
329
- body = re.sub(
330
- r"\n\n(?:Commands|Tool calls) remaining: [0-9]+\. Finish now if the task is answerable; "
331
- r"do not repeat prior (?:commands|calls)\.$", "", body,
332
- )
333
- # Read only the footer shape generated by page_footer, preserving page completeness.
334
- footer_match = re.search(r"\n\n\[page ([0-9]+) of ([0-9]+) — [0-9]+ characters total, nothing discarded\. ", body)
335
- page = int(footer_match.group(1)) if footer_match else 1
336
- total = int(footer_match.group(2)) if footer_match else 1
337
- if footer_match:
338
- body = body[:footer_match.start()]
339
- recovery = (
340
- f'; step {step}, page {page} of {total}; retrieve stored output: '
341
- f'{{"action":"page","step":{step},"page":{page}}}'
342
- )
343
- if ledger:
344
- recovery += f"; {ledger}"
345
- preview = clip(body, policy.compact_preview_chars, policy.clip_head_ratio)
346
- return f"{COMPACT_MARKER}{recovery}] {preview}"
347
-
348
-
349
- def fit_to_context(
350
- messages: list[dict[str, Any]],
351
- *,
352
- num_ctx: int,
353
- num_predict: int,
354
- keep_recent: int | None = None,
355
- policy: ContextPolicy | None = None,
356
- token_counter: Callable[[list[dict[str, Any]]], int] | None = None,
357
- ) -> tuple[list[dict[str, Any]], bool]:
358
- """Trim a conversation to fit `num_ctx`, protecting the parts that must never be dropped.
359
-
360
- Returns `(messages, compacted)`. The system prompt (index 0) and the original question
361
- (index 1) are pinned: they are the two messages Ollama's own front-truncation would delete
362
- first, and losing either is what turns a working agent into one that emits prose instead of
363
- JSON. Compaction walks oldest-first through the observation turns, so the model keeps a
364
- breadcrumb of every step it took even when the full text of the early ones is gone.
365
- """
366
- policy = policy or ContextPolicy()
367
- keep_recent = policy.keep_recent if keep_recent is None else keep_recent
368
- budget = context_budget(num_ctx, num_predict, policy.safety_margin_tokens)
369
- count = token_counter or (lambda value: messages_tokens(value, policy))
370
- if count(messages) <= budget:
371
- return messages, False
372
-
373
- pinned = min(2, len(messages))
374
- trimmed = [dict(message) for message in messages]
375
- # Tool-result messages are first-class observations in native Ollama agent loops. Preserve their
376
- # role and tool_name/tool_call linkage; compact only their textual payload.
377
- observations = [
378
- index
379
- for index in range(pinned, len(trimmed))
380
- if trimmed[index].get("role") in {"user", "tool"}
381
- and isinstance(trimmed[index].get("content"), str)
382
- ]
383
- protected = set(observations[-keep_recent:]) if keep_recent else set()
384
- compacted = False
385
- target = math.floor(budget * COMPACTION_TARGET_RATIO)
386
- for index in observations:
387
- if count(trimmed) <= target:
388
- break
389
- if index in protected:
390
- continue
391
- content = trimmed[index].get("content", "")
392
- replacement = _compact(content, policy)
393
- if len(replacement) < len(content):
394
- trimmed[index]["content"] = replacement
395
- compacted = True
396
-
397
- # Still over after compacting every droppable observation: the recent turns alone exceed the
398
- # window. Clip the protected ones too rather than hand Ollama a prompt it will truncate blind.
399
- #
400
- # This shrinks the largest observation repeatedly instead of computing one subtraction, because
401
- # the token estimate rounds up per message: a single-pass calculation lands just over budget
402
- # and "just over" is exactly as silently truncated as "far over". Loop until it actually fits.
403
- guard = 0
404
- while count(trimmed) > budget and guard < 200:
405
- guard += 1
406
- widest = max(
407
- observations,
408
- key=lambda index: len(trimmed[index].get("content", "")),
409
- default=None,
410
- )
411
- if widest is None:
412
- break
413
- content = trimmed[widest].get("content", "")
414
- keep = max(int(len(content) * policy.compact_retain_ratio), policy.compact_preview_chars)
415
- if keep >= len(content):
416
- break
417
- trimmed[widest]["content"] = clip(content, keep, policy.clip_head_ratio)
418
- compacted = True
419
- # Old reasoning traces can dwarf the answer and are not required to preserve a tool-call pair.
420
- # Keep the field typed, but reduce its payload after observations have already been compacted.
421
- for message in trimmed[pinned:-2]:
422
- thinking = message.get("thinking")
423
- if count(trimmed) <= budget:
424
- break
425
- if isinstance(thinking, str) and len(thinking) > policy.compact_preview_chars:
426
- message["thinking"] = _compact(thinking, policy)
427
- compacted = True
428
-
429
- # Compact any remaining non-pinned textual history before considering the pinned contract.
430
- guard = 0
431
- while count(trimmed) > budget and guard < 200:
432
- guard += 1
433
- candidates = [
434
- index
435
- for index, message in enumerate(trimmed[pinned:], start=pinned)
436
- if isinstance(message.get("content"), str)
437
- and len(message.get("content", "")) > policy.compact_preview_chars
438
- ]
439
- if not candidates:
440
- break
441
- widest = max(candidates, key=lambda index: len(trimmed[index].get("content", "")))
442
- content = trimmed[widest]["content"]
443
- keep = max(int(len(content) * policy.compact_retain_ratio), policy.compact_preview_chars)
444
- if keep >= len(content):
445
- break
446
- trimmed[widest]["content"] = clip(content, keep, policy.clip_head_ratio)
447
- compacted = True
448
-
449
- # Pinned text is byte-preserved by default. An operator may explicitly choose `clip`, but the
450
- # safe default fails before Ollama can silently front-truncate the system contract or question.
451
- guard = 0
452
- while policy.pinned_overflow == "clip" and count(trimmed) > budget and guard < 200:
453
- guard += 1
454
- candidates = [
455
- index
456
- for index, message in enumerate(trimmed[:pinned])
457
- if isinstance(message.get("content"), str)
458
- and len(message.get("content", "")) > policy.compact_preview_chars
459
- ]
460
- if not candidates:
461
- break
462
- widest = max(candidates, key=lambda index: len(trimmed[index].get("content", "")))
463
- content = trimmed[widest]["content"]
464
- keep = max(int(len(content) * policy.compact_retain_ratio), policy.compact_preview_chars)
465
- if keep >= len(content):
466
- break
467
- trimmed[widest]["content"] = clip(content, keep, policy.clip_head_ratio)
468
- compacted = True
469
- if count(trimmed) > budget:
470
- raise ValueError(
471
- "pinned system/question content and minimum history exceed context budget: "
472
- f"{count(trimmed)} > {budget} estimated tokens. Increase FREELLAMA_AGENT_NUM_CTX, "
473
- "reduce the task/instructions, or explicitly set FREELLAMA_AGENT_PINNED_OVERFLOW=clip."
474
- )
475
- return trimmed, compacted
476
-
477
-
478
- class AgentContextManager:
479
- """Stateful adapter facade: fit, calibrate, count compactions, and report policy."""
480
-
481
- def __init__(
482
- self,
483
- runtime: AgentRuntimeConfig,
484
- *,
485
- model: str = "",
486
- calibration_dir: Path | None = None,
487
- calibration_identity: str = "",
488
- ) -> None:
489
- self.runtime = runtime
490
- self.model = model
491
- self.calibration_dir = calibration_dir
492
- self.calibration_identity = calibration_identity
493
- loaded = self._load_calibration()
494
- self.budgeter = ContextBudgeter(
495
- runtime.context,
496
- initial_scale=loaded.get("scale", 1.0),
497
- calibration_samples=loaded.get("samples", 0),
498
- )
499
- self.calibration_source = "persistent_model_cache" if loaded else "current_process"
500
- self.compactions = 0
501
-
502
- def _model_key(self) -> str:
503
- return hashlib.sha256(json.dumps(self._calibration_contract(), sort_keys=True).encode("utf-8")).hexdigest()[:24]
504
-
505
- def _calibration_contract(self) -> dict[str, Any]:
506
- return {
507
- "schema_version": 2,
508
- "model": self.model,
509
- "identity": self.calibration_identity,
510
- "estimator_version": 1,
511
- "chars_per_token": self.runtime.context.chars_per_token,
512
- "image_token_estimate": self.runtime.context.image_token_estimate,
513
- }
514
-
515
- def _load_calibration(self) -> dict[str, Any]:
516
- if not self.model or self.calibration_dir is None or not self.calibration_dir.exists():
517
- return {}
518
- best_scale = 1.0
519
- samples = 0
520
- for path in self.calibration_dir.glob(f"{self._model_key()}-*.json"):
521
- try:
522
- record = json.loads(path.read_text(encoding="utf-8"))
523
- if not isinstance(record, dict):
524
- continue
525
- if any(record.get(key) != value for key, value in self._calibration_contract().items()):
526
- continue
527
- scale = float(record.get("scale", 1.0))
528
- record_samples = int(record.get("samples", 0))
529
- if not math.isfinite(scale) or scale < 1 or record_samples <= 0:
530
- continue
531
- best_scale = max(best_scale, scale)
532
- samples = max(samples, record_samples)
533
- except (OSError, ValueError, TypeError, OverflowError):
534
- continue
535
- return {"scale": best_scale, "samples": samples} if samples else {}
536
-
537
- def _persist_calibration(self) -> None:
538
- if not self.model or self.calibration_dir is None:
539
- return
540
- self.calibration_dir.mkdir(parents=True, exist_ok=True)
541
- key = self._model_key()
542
- record = {
543
- **self._calibration_contract(),
544
- "scale": self.budgeter.scale,
545
- "samples": self.budgeter.calibration_samples,
546
- }
547
- final = self.calibration_dir / f"{key}-{time.time_ns()}-{os.getpid()}.json"
548
- temporary = final.with_suffix(".tmp")
549
- temporary.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
550
- os.replace(temporary, final)
551
- # Bounded, race-tolerant history: independent records make concurrent writers monotonic;
552
- # retaining the newest 32 avoids an unbounded cache without a cross-platform file lock.
553
- records = sorted(self.calibration_dir.glob(f"{key}-*.json"), key=lambda path: path.name)
554
- for stale in records[:-32]:
555
- try:
556
- stale.unlink()
557
- except FileNotFoundError:
558
- pass
559
-
560
- def fit(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
561
- fitted, compacted = fit_to_context(
562
- messages,
563
- num_ctx=self.runtime.num_ctx,
564
- num_predict=self.runtime.num_predict,
565
- policy=self.runtime.context,
566
- token_counter=self.budgeter.estimate,
567
- )
568
- self.compactions += int(compacted)
569
- return fitted
570
-
571
- def observe(self, messages: list[dict[str, Any]], prompt_eval_count: Any) -> None:
572
- if self.budgeter.observe(messages, prompt_eval_count):
573
- self._persist_calibration()
574
-
575
- def metadata(self) -> dict[str, Any]:
576
- return {
577
- **self.runtime.context.metadata(),
578
- **self.budgeter.metadata(),
579
- "calibration_source": self.calibration_source,
580
- "calibration_scope": "provided_identity_and_estimator" if self.calibration_identity else "model_tag_and_estimator",
581
- "compaction_target_ratio": COMPACTION_TARGET_RATIO,
582
- "estimated_input_budget": context_budget(
583
- self.runtime.num_ctx,
584
- self.runtime.num_predict,
585
- self.runtime.context.safety_margin_tokens,
586
- ),
587
- "compactions": self.compactions,
588
- }
589
-
590
-
591
- def write_failure_result(result_path: Path, answer: str) -> None:
592
- """Write the minimum valid adapter result for a failure before the normal result path."""
593
- result_path.parent.mkdir(parents=True, exist_ok=True)
594
- result_path.write_text(
595
- json.dumps({"final_answer": answer, "tool_calls": [], "usage": {}}, indent=2) + "\n",
596
- encoding="utf-8",
597
- )
598
-
599
-
600
- def parse_json_action(content: str) -> dict[str, Any]:
601
- """Recover one JSON object while leaving adapter-specific action validation to the caller."""
602
- text = content.strip()
603
- if text.startswith("```"):
604
- lines = text.splitlines()
605
- text = "\n".join(lines[1:-1]).strip()
606
- try:
607
- value = json.loads(text)
608
- except json.JSONDecodeError:
609
- decoder = json.JSONDecoder()
610
- start = text.find("{")
611
- if start < 0:
612
- raise ValueError("model did not return a JSON action") from None
613
- try:
614
- value, _ = decoder.raw_decode(text, start)
615
- except json.JSONDecodeError:
616
- raise ValueError("model did not return a valid JSON action") from None
617
- if not isinstance(value, dict) or not isinstance(value.get("action"), str):
618
- raise ValueError("model action must be a JSON object with an action")
619
- return value
620
-
621
-
622
- def call_signature(payload: Any) -> str:
623
- """Stable identity for a tool call, so an exact repeat can be recognized.
624
-
625
- Both adapters tell the model "do not repeat prior calls" and neither enforced it. A repeat is
626
- not just a wasted turn out of ten — it also re-appends an observation the model has already
627
- seen, spending context twice on one fact.
628
- """
629
- return json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
630
-
631
-
632
- REPEAT_NOTICE = (
633
- "(identical call already made this run — its result is above and was not re-executed. "
634
- "Use a different call, or finish now with the evidence already collected.)"
635
- )
636
-
637
-
638
- # How many unparseable turns to try to repair before giving up on the run. A model that emits prose
639
- # instead of JSON once will usually correct when told so explicitly; aborting on the first one threw
640
- # away an entire delegation — including every tool result already gathered — and surfaced as
641
- # "model did not return a valid JSON action", which reads like model weakness rather than a loop
642
- # with no error recovery.
643
- MAX_PARSE_REPAIRS = 2
644
-
645
- PARSE_REPAIR_NOTICE = (
646
- "Your last reply was not a valid JSON action, so it was discarded. Reply with EXACTLY one JSON "
647
- "object and nothing else — no prose before or after, no markdown fence. Use the action shapes "
648
- "given in your instructions. If you already have enough evidence, send the finish action now."
649
- )
650
-
651
- # ---------------------------------------------------------------------------------------------
652
- # Pagination — the alternative to throwing data away
653
- #
654
- # Clipping an observation is silent data loss the model can never recover: a routine
655
- # `grep -rn "def " .` over jinja produces ~27,000 characters, and a 3,000-character clip discarded
656
- # 89% of it. The model cannot ask for the rest, cannot tell how much it is missing, and will
657
- # happily conclude "not found" from a window that never contained the answer.
658
- #
659
- # Pagination keeps every byte. The full observation is retained, the model is shown one page plus
660
- # an exact instruction for requesting the next, and asking for page 2 re-serves stored text rather
661
- # than re-running the command — so paging costs a turn, never a duplicate subprocess.
662
- # ---------------------------------------------------------------------------------------------
663
-
664
- def paginate(text: str, page: int = 1, page_size: int = OBSERVATION_PAGE_CHARS) -> tuple[str, int, int]:
665
- """Return `(chunk, page, total_pages)`, preferring line boundaries within a hard limit.
666
-
667
- Line-aware on purpose: these observations are grep hits, directory listings and file slices,
668
- where a chunk cut mid-line yields a truncated path or a half identifier — exactly the kind of
669
- fragment that gets misread as data rather than as damage. An oversized individual line is
670
- split to keep even minified JSON/logs bounded; joining all chunks reproduces the exact input.
671
- """
672
- if page_size <= 0:
673
- raise ValueError("observation page size must be > 0")
674
- if not text:
675
- return "", 1, 1
676
- lines = text.splitlines(keepends=True)
677
- pages: list[list[str]] = [[]]
678
- size = 0
679
- for line in lines:
680
- if size and size + len(line) > page_size:
681
- pages.append([])
682
- size = 0
683
- for start in range(0, len(line), page_size):
684
- part = line[start:start + page_size]
685
- if size and size + len(part) > page_size:
686
- pages.append([])
687
- size = 0
688
- pages[-1].append(part)
689
- size += len(part)
690
- total = max(1, len(pages))
691
- page = max(1, min(page, total))
692
- return "".join(pages[page - 1]), page, total
693
-
694
-
695
- def page_footer(step: int, page: int, total: int, total_chars: int) -> str:
696
- """Tell the model exactly how to get the rest — or that there is no rest."""
697
- if total <= 1:
698
- return ""
699
- if page >= total:
700
- return (
701
- f'\n\n[page {page} of {total} — {total_chars} characters total, nothing discarded. '
702
- 'End of stored output.]'
703
- )
704
- nxt = page + 1
705
- return (
706
- f'\n\n[page {page} of {total} — {total_chars} characters total, nothing discarded. '
707
- f'For the next page send exactly: {{"action":"page","step":{step},"page":{nxt}}} '
708
- f"— that re-reads stored output and does NOT re-run the command.]"
709
- )
710
-
711
-
712
- @dataclass(frozen=True)
713
- class StoredObservation:
714
- path: Path
715
- index_path: Path
716
- characters: int
717
- pages: int
718
- metadata: dict[str, str]
719
-
720
-
721
- class ObservationStore:
722
- """Disk-backed exact observations with one small descriptor per step in memory.
723
-
724
- Page offsets also live on disk; an arbitrary page read loads one fixed-width offset record
725
- and one page. The subprocess result exists transiently at put(), never across model turns.
726
- """
727
-
728
- def __init__(self, page_size: int = OBSERVATION_PAGE_CHARS) -> None:
729
- if page_size <= 0:
730
- raise ValueError("observation page size must be > 0")
731
- self._temporary = tempfile.TemporaryDirectory(prefix="freellama-observations-")
732
- self.directory = Path(self._temporary.name)
733
- self._by_step: dict[int, StoredObservation] = {}
734
- self.page_size = page_size
735
-
736
- def __enter__(self) -> "ObservationStore":
737
- return self
738
-
739
- def __exit__(self, *_: Any) -> None:
740
- self.close()
741
-
742
- def close(self) -> None:
743
- self._by_step.clear()
744
- self._temporary.cleanup()
745
-
746
- def put(self, step: int, text: str, *, metadata: Mapping[str, Any] | None = None) -> None:
747
- if step < 1:
748
- raise ValueError("observation step must be positive")
749
- path = self.directory / f"{step}.utf8"
750
- index_path = self.directory / f"{step}.index"
751
- start = offset = pages = 0
752
- with path.open("wb") as output, index_path.open("wb") as index:
753
- while start < len(text):
754
- end = min(start + self.page_size, len(text))
755
- # Prefer a complete line without allocating or splitting the full result. A line
756
- # longer than a page is split at the hard character limit, losslessly.
757
- if end < len(text):
758
- newline = text.rfind("\n", start, end)
759
- if newline >= start:
760
- end = newline + 1
761
- encoded = text[start:end].encode("utf-8")
762
- output.write(encoded)
763
- next_offset = offset + len(encoded)
764
- index.write(struct.pack(">QQ", offset, next_offset))
765
- offset = next_offset
766
- pages += 1
767
- start = end
768
- if not pages:
769
- index.write(struct.pack(">QQ", 0, 0))
770
- pages = 1
771
- bounded_metadata = {
772
- key: clip(str(value), 240)
773
- for key, value in (metadata or {}).items()
774
- if key in {"action", "status", "source"}
775
- }
776
- self._by_step[step] = StoredObservation(path, index_path, len(text), pages, bounded_metadata)
777
-
778
- def get(self, step: int) -> str | None:
779
- """Explicit full materialization for consumers that need it; adapters use view/write_result."""
780
- record = self._by_step.get(step)
781
- return record.path.read_bytes().decode("utf-8") if record else None
782
-
783
- def page_count(self, step: int) -> int:
784
- return self._by_step[step].pages
785
-
786
- def view(self, step: int, page: int = 1) -> tuple[str, str]:
787
- """Return `(body, footer)` for one page of a stored observation."""
788
- record = self._by_step.get(step)
789
- if record is None:
790
- known = ", ".join(str(k) for k in sorted(self._by_step)) or "none yet"
791
- return (f"no stored output for step {step} (known steps: {known})", "")
792
- page = max(1, min(page, record.pages))
793
- with record.index_path.open("rb") as index:
794
- index.seek((page - 1) * 16)
795
- start, end = struct.unpack(">QQ", index.read(16))
796
- with record.path.open("rb") as output:
797
- output.seek(start)
798
- chunk = output.read(end - start).decode("utf-8")
799
- return chunk, page_footer(step, page, record.pages, record.characters)
800
-
801
- def message(self, step: int, page: int = 1) -> str:
802
- body, footer = self.view(step, page)
803
- record = self._by_step.get(step)
804
- if record is None:
805
- return f"Observation (step {step}):\n{body}"
806
- page = max(1, min(page, record.pages))
807
- ledger = {
808
- **record.metadata, "step": step, "page": page, "total_pages": record.pages,
809
- "trust": "untrusted_tool_output",
810
- }
811
- return (
812
- f"Observation (step {step}):\n[evidence ledger: "
813
- f"{json.dumps(ledger, ensure_ascii=False, separators=(',', ':'))}]\n{body}{footer}"
814
- )
815
-
816
- def write_result(self, result_path: Path, result: dict[str, Any]) -> None:
817
- """Atomically stream the public JSON result, restoring full call results from disk."""
818
- result_path.parent.mkdir(parents=True, exist_ok=True)
819
- temporary: Path | None = None
820
- try:
821
- with tempfile.NamedTemporaryFile(
822
- mode="w", encoding="utf-8", dir=result_path.parent,
823
- prefix=f".{result_path.name}.", suffix=".tmp", delete=False,
824
- ) as output:
825
- temporary = Path(output.name)
826
- output.write("{")
827
- for field_index, (key, value) in enumerate(result.items()):
828
- if field_index:
829
- output.write(",")
830
- output.write(json.dumps(key) + ":")
831
- if key != "tool_calls":
832
- json.dump(value, output, ensure_ascii=False)
833
- continue
834
- output.write("[")
835
- for step, call in enumerate(value, start=1):
836
- if step > 1:
837
- output.write(",")
838
- metadata = {key: value for key, value in call.items() if key != "result"}
839
- encoded = json.dumps(metadata, ensure_ascii=False)
840
- output.write(encoded[:-1] + ("," if metadata else "") + '"result":"')
841
- with self._by_step[step].path.open("r", encoding="utf-8", newline="") as source:
842
- while chunk := source.read(16_384):
843
- output.write(json.dumps(chunk, ensure_ascii=False)[1:-1])
844
- output.write('"}')
845
- output.write("]")
846
- output.write("}\n")
847
- os.replace(temporary, result_path)
848
- finally:
849
- if temporary is not None and temporary.exists():
850
- temporary.unlink()