@octocodeai/freellama-mcp-server 0.1.0 → 0.5.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.
@@ -20,6 +20,9 @@ import json
20
20
  import hashlib
21
21
  import math
22
22
  import os
23
+ import re
24
+ import struct
25
+ import tempfile
23
26
  import time
24
27
  from dataclasses import asdict, dataclass
25
28
  from pathlib import Path
@@ -47,6 +50,10 @@ COMPACT_RETAIN_RATIO = 0.8
47
50
  CLIP_HEAD_RATIO = 2 / 3
48
51
  # Full observations are kept outside the prompt and served in line-aware pages of this size.
49
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"
50
57
 
51
58
 
52
59
  def _env_int(env: Mapping[str, str], name: str, default: int, *, minimum: int = 0) -> int:
@@ -224,7 +231,7 @@ class ContextBudgeter:
224
231
 
225
232
  Ollama currently has no stable preflight tokenizer endpoint. The first call therefore uses the
226
233
  configured character estimate. After every successful call, `prompt_eval_count` raises (never
227
- lowers) the estimate multiplier for this exact model/chat template.
234
+ lowers) the estimate multiplier. A model tag alone does not identify an immutable template.
228
235
  """
229
236
 
230
237
  def __init__(
@@ -298,8 +305,45 @@ def context_budget(
298
305
 
299
306
 
300
307
  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}…"
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}"
303
347
 
304
348
 
305
349
  def fit_to_context(
@@ -338,8 +382,9 @@ def fit_to_context(
338
382
  ]
339
383
  protected = set(observations[-keep_recent:]) if keep_recent else set()
340
384
  compacted = False
385
+ target = math.floor(budget * COMPACTION_TARGET_RATIO)
341
386
  for index in observations:
342
- if count(trimmed) <= budget:
387
+ if count(trimmed) <= target:
343
388
  break
344
389
  if index in protected:
345
390
  continue
@@ -439,10 +484,12 @@ class AgentContextManager:
439
484
  *,
440
485
  model: str = "",
441
486
  calibration_dir: Path | None = None,
487
+ calibration_identity: str = "",
442
488
  ) -> None:
443
489
  self.runtime = runtime
444
490
  self.model = model
445
491
  self.calibration_dir = calibration_dir
492
+ self.calibration_identity = calibration_identity
446
493
  loaded = self._load_calibration()
447
494
  self.budgeter = ContextBudgeter(
448
495
  runtime.context,
@@ -453,7 +500,17 @@ class AgentContextManager:
453
500
  self.compactions = 0
454
501
 
455
502
  def _model_key(self) -> str:
456
- return hashlib.sha256(self.model.encode("utf-8")).hexdigest()[:24]
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
+ }
457
514
 
458
515
  def _load_calibration(self) -> dict[str, Any]:
459
516
  if not self.model or self.calibration_dir is None or not self.calibration_dir.exists():
@@ -463,11 +520,17 @@ class AgentContextManager:
463
520
  for path in self.calibration_dir.glob(f"{self._model_key()}-*.json"):
464
521
  try:
465
522
  record = json.loads(path.read_text(encoding="utf-8"))
466
- if record.get("schema_version") != 1 or record.get("model") != self.model:
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:
467
530
  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):
531
+ best_scale = max(best_scale, scale)
532
+ samples = max(samples, record_samples)
533
+ except (OSError, ValueError, TypeError, OverflowError):
471
534
  continue
472
535
  return {"scale": best_scale, "samples": samples} if samples else {}
473
536
 
@@ -477,8 +540,7 @@ class AgentContextManager:
477
540
  self.calibration_dir.mkdir(parents=True, exist_ok=True)
478
541
  key = self._model_key()
479
542
  record = {
480
- "schema_version": 1,
481
- "model": self.model,
543
+ **self._calibration_contract(),
482
544
  "scale": self.budgeter.scale,
483
545
  "samples": self.budgeter.calibration_samples,
484
546
  }
@@ -515,6 +577,8 @@ class AgentContextManager:
515
577
  **self.runtime.context.metadata(),
516
578
  **self.budgeter.metadata(),
517
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,
518
582
  "estimated_input_budget": context_budget(
519
583
  self.runtime.num_ctx,
520
584
  self.runtime.num_predict,
@@ -598,24 +662,31 @@ PARSE_REPAIR_NOTICE = (
598
662
  # ---------------------------------------------------------------------------------------------
599
663
 
600
664
  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.
665
+ """Return `(chunk, page, total_pages)`, preferring line boundaries within a hard limit.
602
666
 
603
667
  Line-aware on purpose: these observations are grep hits, directory listings and file slices,
604
668
  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.
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.
606
671
  """
672
+ if page_size <= 0:
673
+ raise ValueError("observation page size must be > 0")
607
674
  if not text:
608
675
  return "", 1, 1
609
676
  lines = text.splitlines(keepends=True)
610
677
  pages: list[list[str]] = [[]]
611
678
  size = 0
612
679
  for line in lines:
613
- # A single line longer than a page gets its own page rather than being split.
614
680
  if size and size + len(line) > page_size:
615
681
  pages.append([])
616
682
  size = 0
617
- pages[-1].append(line)
618
- size += len(line)
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)
619
690
  total = max(1, len(pages))
620
691
  page = max(1, min(page, total))
621
692
  return "".join(pages[page - 1]), page, total
@@ -625,8 +696,12 @@ def page_footer(step: int, page: int, total: int, total_chars: int) -> str:
625
696
  """Tell the model exactly how to get the rest — or that there is no rest."""
626
697
  if total <= 1:
627
698
  return ""
628
- remaining = total - page
629
- nxt = page + 1 if remaining else 1
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
630
705
  return (
631
706
  f'\n\n[page {page} of {total} — {total_chars} characters total, nothing discarded. '
632
707
  f'For the next page send exactly: {{"action":"page","step":{step},"page":{nxt}}} '
@@ -634,30 +709,142 @@ def page_footer(step: int, page: int, total: int, total_chars: int) -> str:
634
709
  )
635
710
 
636
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
+
637
721
  class ObservationStore:
638
- """Keeps every observation in full so any page can be served later.
722
+ """Disk-backed exact observations with one small descriptor per step in memory.
639
723
 
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.
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.
642
726
  """
643
727
 
644
728
  def __init__(self, page_size: int = OBSERVATION_PAGE_CHARS) -> None:
645
729
  if page_size <= 0:
646
730
  raise ValueError("observation page size must be > 0")
647
- self._by_step: dict[int, str] = {}
731
+ self._temporary = tempfile.TemporaryDirectory(prefix="freellama-observations-")
732
+ self.directory = Path(self._temporary.name)
733
+ self._by_step: dict[int, StoredObservation] = {}
648
734
  self.page_size = page_size
649
735
 
650
- def put(self, step: int, text: str) -> None:
651
- self._by_step[step] = text
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)
652
777
 
653
778
  def get(self, step: int) -> str | None:
654
- return self._by_step.get(step)
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
655
785
 
656
786
  def view(self, step: int, page: int = 1) -> tuple[str, str]:
657
787
  """Return `(body, footer)` for one page of a stored observation."""
658
- text = self._by_step.get(step)
659
- if text is None:
788
+ record = self._by_step.get(step)
789
+ if record is None:
660
790
  known = ", ".join(str(k) for k in sorted(self._by_step)) or "none yet"
661
791
  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))
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()
@@ -3,8 +3,137 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import hashlib
7
+ import json
8
+ import re
6
9
  from pathlib import Path
7
10
  from typing import Any
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.request import Request, urlopen
13
+
14
+
15
+ def _read_identity_json(url: str) -> dict[str, Any]:
16
+ with urlopen(Request(url, headers=request_headers()), timeout=5) as response:
17
+ payload = response.read(1024 * 1024 + 1)
18
+ if len(payload) > 1024 * 1024:
19
+ raise ValueError("model identity catalog exceeds size limit")
20
+ return json.loads(payload)
21
+
22
+
23
+ def resolve_model_identity(
24
+ endpoint: str, model: str, system_prompt: str, *, read_json=None,
25
+ ) -> dict[str, Any]:
26
+ """Resolve immutable manifest identity once per adapter run, without loading a model.
27
+
28
+ Managed catalogs honor exact CPU backend assignment. Failure disables persistent calibration;
29
+ a mutable tag or a hash of the prompt alone must never masquerade as model identity.
30
+ """
31
+ endpoint = endpoint.rstrip("/")
32
+ managed_suffix = "/_freellama/v1/tasks"
33
+ url = (endpoint[:-len(managed_suffix)] + "/_freellama/v1/models"
34
+ if endpoint.endswith(managed_suffix) else endpoint + "/api/tags")
35
+ unknown = {"identity": "", "verified": False, "scope": "current_process_only"}
36
+ try:
37
+ payload = (read_json or _read_identity_json)(url)
38
+ entries = payload.get("models", []) if isinstance(payload, dict) else []
39
+ if not isinstance(entries, list):
40
+ return unknown
41
+ names = {model}
42
+ if ":" not in model.rsplit("/", 1)[-1]:
43
+ names.add(model + ":latest")
44
+ entry = next((item for item in entries if isinstance(item, dict)
45
+ and item.get("name", item.get("model")) in names), None)
46
+ digest = entry.get("digest") if entry else None
47
+ if not isinstance(digest, str) or not re.fullmatch(r"(?:sha256:)?[0-9a-fA-F]{64}", digest):
48
+ return unknown
49
+ digest = digest.removeprefix("sha256:").lower()
50
+ contract = {"endpoint": endpoint, "model": model, "digest": digest,
51
+ "system_hash": hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()}
52
+ return {"identity": hashlib.sha256(json.dumps(contract, sort_keys=True).encode()).hexdigest(),
53
+ "verified": True, "scope": "manifest_digest_and_system_prompt", "digest": digest}
54
+ except (OSError, ValueError, TypeError):
55
+ return unknown
56
+
57
+
58
+ def retryable_chat_error(error: Exception) -> bool:
59
+ """Retry known refusals only; a timeout may leave inference running upstream."""
60
+ if isinstance(error, HTTPError):
61
+ return error.code == 503
62
+ return isinstance(error, URLError) and isinstance(error.reason, ConnectionRefusedError)
63
+
64
+
65
+ def chat_error_details(error: Exception) -> tuple[str, dict[str, Any] | None]:
66
+ """Retain bounded application refusal receipts, excluding request/auth payloads."""
67
+ diagnostic = f"{type(error).__name__}: {error}"
68
+ if not isinstance(error, HTTPError):
69
+ return diagnostic, None
70
+ details: dict[str, Any] = {"status": error.code}
71
+ try:
72
+ body = error.read(64 * 1024 + 1)
73
+ if len(body) > 64 * 1024:
74
+ return diagnostic, details
75
+ payload = json.loads(body)
76
+ if not isinstance(payload, dict) or not isinstance(payload.get("error"), str):
77
+ return diagnostic, details
78
+ receipt_fields = {
79
+ "error", "code", "reason", "retry_after_seconds", "resource_admission", "lifecycle",
80
+ "placement", "execution", "admission", "route", "decision", "quality", "feedback",
81
+ }
82
+ payload_fields = {
83
+ "prompt", "system", "system_prompt", "messages", "content", "input", "images", "tools",
84
+ "request", "request_options", "headers", "authorization", "token", "api_key", "auth_token",
85
+ }
86
+
87
+ def receipt_value(value: Any) -> Any:
88
+ if isinstance(value, dict):
89
+ return {key: receipt_value(item) for key, item in value.items() if key.lower() not in payload_fields}
90
+ if isinstance(value, list):
91
+ return [receipt_value(item) for item in value]
92
+ return value
93
+
94
+ receipt = {key: receipt_value(value) for key, value in payload.items() if key in receipt_fields}
95
+ details["receipt"] = receipt
96
+ diagnostic = f"HTTP {error.code}: {receipt['error']}"
97
+ except (OSError, ValueError, RecursionError):
98
+ pass
99
+ finally:
100
+ error.close()
101
+ return diagnostic, details
102
+
103
+
104
+ class PromptCacheUsage:
105
+ """Aggregate optional Ollama cache reads without treating unavailable turns as zero."""
106
+
107
+ def __init__(self) -> None:
108
+ self.calls = 0
109
+ self.reported_calls = 0
110
+ self.reported_tokens = 0
111
+ self.reported_prompt_tokens = 0
112
+
113
+ def observe(self, response: dict[str, Any]) -> None:
114
+ self.calls += 1
115
+ cached = response.get("prompt_eval_cached_count")
116
+ prompt = response.get("prompt_eval_count")
117
+ if type(cached) is int and type(prompt) is int and 0 <= cached <= prompt:
118
+ self.reported_calls += 1
119
+ self.reported_tokens += cached
120
+ self.reported_prompt_tokens += prompt
121
+
122
+ @property
123
+ def tokens(self) -> int | None:
124
+ return self.reported_tokens if self.calls > 0 and self.calls == self.reported_calls else None
125
+
126
+ def metadata(self) -> dict[str, Any]:
127
+ return {
128
+ "status": "reported" if self.tokens is not None else (
129
+ "partially_reported" if self.reported_calls else "not_reported"
130
+ ),
131
+ "calls": self.calls,
132
+ "reported_calls": self.reported_calls,
133
+ "reported_tokens": self.reported_tokens if self.reported_calls else None,
134
+ "hit_ratio": self.reported_tokens / self.reported_prompt_tokens
135
+ if self.tokens is not None and self.reported_prompt_tokens else None,
136
+ }
8
137
 
9
138
 
10
139
  def request_headers() -> dict[str, str]:
@@ -59,6 +188,8 @@ def chat_request(
59
188
  "model": model,
60
189
  "messages": messages,
61
190
  "stream": False,
191
+ "truncate": False,
192
+ "shift": False,
62
193
  "format": "json",
63
194
  "think": think,
64
195
  "keep_alive": keep_alive,