@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.
- package/README.md +426 -0
- package/adapters/agent_context.py +663 -0
- package/adapters/agent_transport.py +84 -0
- package/adapters/bash_agent.py +370 -0
- package/adapters/octocode_agent.py +394 -0
- package/dist/index.js +22971 -0
- package/docs/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md +55 -0
- package/docs/ARCHITECTURE.md +416 -0
- package/docs/CLI.md +219 -0
- package/docs/CPU_GPU_ROUTING.md +352 -0
- package/docs/ECONOMICS.md +105 -0
- package/docs/INDEX.md +15 -0
- package/docs/MODEL_SELECTION.md +65 -0
- package/docs/OLLAMA_SIDECAR.md +76 -0
- package/docs/OLLAMA_SYSTEM_OPTIMIZATION.md +254 -0
- package/docs/PRODUCTION.md +195 -0
- package/docs/PRODUCT_POSITIONING.md +291 -0
- package/docs/TESTING.md +125 -0
- package/native/index.d.ts +158 -0
- package/native/index.js +45 -0
- package/native/package.json +7 -0
- package/package.json +69 -0
|
@@ -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))
|