@octocodeai/freellama-mcp-server 0.1.0 → 0.6.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/CONFIGURATION.md +108 -0
- package/DEVELOPING.md +109 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +18 -0
- package/README.md +261 -243
- package/assets/logo.jpg +0 -0
- package/dist/index.js +2305 -1332
- package/dist/research/bash-agent.js +2435 -0
- package/dist/research/octocode-agent.js +1639 -0
- package/docs/ARCHITECTURE.md +294 -87
- package/docs/CLI.md +171 -53
- package/docs/CPU_GPU_ROUTING.md +69 -23
- package/docs/MODEL_SELECTION.md +126 -3
- package/docs/MONITORING.md +293 -0
- package/docs/PRODUCTION.md +53 -25
- package/docs/README.md +36 -0
- package/docs/SCOPES_AND_WARMING.md +225 -0
- package/docs/TESTING.md +113 -8
- package/native/index.d.ts +186 -150
- package/native/index.js +3 -3
- package/package.json +15 -11
- package/adapters/agent_context.py +0 -663
- package/adapters/agent_transport.py +0 -84
- package/adapters/bash_agent.py +0 -370
- package/adapters/octocode_agent.py +0 -394
- package/docs/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md +0 -55
- package/docs/ECONOMICS.md +0 -105
- package/docs/INDEX.md +0 -15
- package/docs/OLLAMA_SIDECAR.md +0 -76
- package/docs/OLLAMA_SYSTEM_OPTIMIZATION.md +0 -254
- package/docs/PRODUCT_POSITIONING.md +0 -291
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Agent A adapter: local Ollama model whose only research tool is the `octocode` CLI.
|
|
3
|
-
|
|
4
|
-
Implements the FreeLlama benchmark adapter contract (benchmark/harness/references/adapters.md):
|
|
5
|
-
reads FREELLAMA_BENCH_MODEL/PROMPT/WORKSPACE/AGENT_RESULT, drives its own Ollama chat loop, and
|
|
6
|
-
writes agent-result.json. See benchmark/local/docs/02-agent-a-octocode.md for the full spec this
|
|
7
|
-
mirrors verbatim.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
import subprocess
|
|
15
|
-
import time
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Any
|
|
18
|
-
from urllib.error import HTTPError, URLError
|
|
19
|
-
from urllib.request import Request, urlopen
|
|
20
|
-
|
|
21
|
-
from agent_context import (
|
|
22
|
-
AgentContextManager,
|
|
23
|
-
AgentRuntimeConfig,
|
|
24
|
-
PARSE_REPAIR_NOTICE,
|
|
25
|
-
REPEAT_NOTICE,
|
|
26
|
-
ObservationStore,
|
|
27
|
-
call_signature,
|
|
28
|
-
paginate,
|
|
29
|
-
page_footer,
|
|
30
|
-
parse_json_action,
|
|
31
|
-
write_failure_result,
|
|
32
|
-
)
|
|
33
|
-
from agent_transport import chat_request, request_headers, unwrap_chat_response
|
|
34
|
-
|
|
35
|
-
OCTOCODE_TOOLS = {"localViewStructure", "localFindFiles", "localSearchCode", "localGetFileContent", "lspGetSemantics"}
|
|
36
|
-
PATH_KEYS = ("path", "uri")
|
|
37
|
-
# Nothing is clipped any more — see the pagination section of agent_context.py. `calls[].result`
|
|
38
|
-
# goes to result.json on disk and never into the model's context, so it is stored in full; the model
|
|
39
|
-
# is shown one page at a time with an exact instruction for fetching the next.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def request_json(url: str, payload: dict[str, Any], timeout_seconds: float) -> dict[str, Any]:
|
|
43
|
-
request = Request(url, data=json.dumps(payload).encode(), headers=request_headers(), method="POST")
|
|
44
|
-
with urlopen(request, timeout=timeout_seconds) as response:
|
|
45
|
-
return json.loads(response.read())
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def safe_resolve(root: Path, value: str) -> Path:
|
|
49
|
-
candidate = Path(value)
|
|
50
|
-
candidate = candidate if candidate.is_absolute() else (root / candidate)
|
|
51
|
-
candidate = candidate.resolve()
|
|
52
|
-
if candidate != root and root not in candidate.parents:
|
|
53
|
-
raise ValueError(f"path escapes benchmark workspace: {value}")
|
|
54
|
-
if ".git" in candidate.parts:
|
|
55
|
-
raise ValueError("git metadata is outside the task surface")
|
|
56
|
-
return candidate
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def parse_action(content: str) -> dict[str, Any]:
|
|
60
|
-
value = parse_json_action(content)
|
|
61
|
-
action = value["action"]
|
|
62
|
-
if action == "octocode":
|
|
63
|
-
if value.get("tool") not in OCTOCODE_TOOLS:
|
|
64
|
-
raise ValueError(f"octocode action requires one supported tool: {sorted(OCTOCODE_TOOLS)}")
|
|
65
|
-
if not isinstance(value.get("queries"), dict):
|
|
66
|
-
raise ValueError("octocode action requires a queries object")
|
|
67
|
-
if action == "page" and (
|
|
68
|
-
not isinstance(value.get("step"), int)
|
|
69
|
-
or value["step"] < 1
|
|
70
|
-
or not isinstance(value.get("page"), int)
|
|
71
|
-
or value["page"] < 1
|
|
72
|
-
):
|
|
73
|
-
raise ValueError("page action requires positive integer step and page")
|
|
74
|
-
if action == "finish" and not isinstance(value.get("answer"), str):
|
|
75
|
-
raise ValueError("finish action requires a string answer")
|
|
76
|
-
if action not in {"octocode", "page", "finish"}:
|
|
77
|
-
raise ValueError(f"unsupported action for octocode adapter: {action}")
|
|
78
|
-
return value
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def run_octocode(
|
|
82
|
-
root: Path,
|
|
83
|
-
tool_name: str,
|
|
84
|
-
queries: dict[str, Any],
|
|
85
|
-
timeout_seconds: float,
|
|
86
|
-
) -> str:
|
|
87
|
-
if tool_name not in OCTOCODE_TOOLS:
|
|
88
|
-
raise ValueError(f"unsupported octocode tool: {tool_name}")
|
|
89
|
-
resolved = dict(queries)
|
|
90
|
-
for key in PATH_KEYS:
|
|
91
|
-
if key in resolved and isinstance(resolved[key], str):
|
|
92
|
-
resolved[key] = str(safe_resolve(root, resolved[key]))
|
|
93
|
-
command = ["npx", "octocode", "tools", tool_name, "--queries", json.dumps(resolved), "--compact"]
|
|
94
|
-
result = subprocess.run(
|
|
95
|
-
command,
|
|
96
|
-
cwd=root,
|
|
97
|
-
text=True,
|
|
98
|
-
capture_output=True,
|
|
99
|
-
timeout=timeout_seconds,
|
|
100
|
-
check=False,
|
|
101
|
-
)
|
|
102
|
-
output = (result.stdout or "") + (("\nSTDERR:\n" + result.stderr) if result.returncode != 0 else "")
|
|
103
|
-
if not output.strip():
|
|
104
|
-
output = "(empty result)"
|
|
105
|
-
return output
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def system_prompt(workspace: str) -> str:
|
|
109
|
-
return f"""You are a local coding agent in an isolated benchmark workspace at {workspace}. You may not read or search files directly — you can only call the `octocode` local tools below. Return exactly one JSON object per turn.
|
|
110
|
-
|
|
111
|
-
Tools (call as {{"action":"octocode","tool":"<name>","queries":{{...}}}}):
|
|
112
|
-
|
|
113
|
-
localViewStructure - browse a directory tree, no content loaded; cheapest first orientation step.
|
|
114
|
-
queries: path (string, required, absolute), maxDepth (int), recursive (bool), filesOnly (bool),
|
|
115
|
-
directoriesOnly (bool), pattern (glob/substring filter).
|
|
116
|
-
|
|
117
|
-
localFindFiles - find files/dirs by name, glob, regex, or type; returns paths only, not content.
|
|
118
|
-
queries: path (string, required, absolute), names (array of globs), pathPattern (glob over full
|
|
119
|
-
path), regex (basename regex), entryType ("f"|"d"), excludeDir (array, e.g. ["node_modules",".git"]).
|
|
120
|
-
|
|
121
|
-
localSearchCode - search file contents for text/regex; returns file+line matches. Your main tool.
|
|
122
|
-
queries: path (string, required, absolute), keywords (string; literal or regex search term),
|
|
123
|
-
mode ("paginated"|"discovery"|"detailed"), include/exclude (glob arrays),
|
|
124
|
-
caseInsensitive (bool), maxFiles (int).
|
|
125
|
-
Do NOT pass mode "structural" with keywords — structural (AST) search takes `pattern`/`rule`
|
|
126
|
-
instead and hard-errors on keywords, costing you a turn for nothing.
|
|
127
|
-
|
|
128
|
-
localGetFileContent - read one file or a line range/matched slice of it.
|
|
129
|
-
queries: path (string, required, absolute), fullContent (bool; small files only), startLine +
|
|
130
|
-
endLine (ints, both required together), matchString (anchor text/regex), minify
|
|
131
|
-
("none"|"standard"|"symbols" — "symbols" gives a cheap outline first).
|
|
132
|
-
|
|
133
|
-
lspGetSemantics - LSP semantic queries: definitions, references, callers/callees, symbol outline.
|
|
134
|
-
queries: uri (string, required, absolute path), type ("definition"|"references"|"callers"|
|
|
135
|
-
"callees"|"documentSymbols"|"hover"|...), symbolName (exact identifier), lineHint (int; get this
|
|
136
|
-
from a prior search/documentSymbols call, never guess it).
|
|
137
|
-
An EMPTY result is ambiguous, and it will not tell you which case you hit: it reports
|
|
138
|
-
serverAvailable=true whether the language server is still indexing the project or genuinely
|
|
139
|
-
cannot analyse the file. Measured here on the same files: a cold server returned
|
|
140
|
-
totalSymbols=0 for Rust, and once warm the identical call returned 35. Python returned 139-430,
|
|
141
|
-
TypeScript 45. So totalSymbols=0 means "ask again or search instead", NOT "no such symbol" and
|
|
142
|
-
NOT "unsupported". Do not spend more than one retry on it — prefer localSearchCode, which needs
|
|
143
|
-
no index and cannot be cold.
|
|
144
|
-
|
|
145
|
-
Read another page of an earlier step: {{"action":"page","step":2,"page":2}}
|
|
146
|
-
Long output is PAGINATED, never truncated — you see page 1 and the total, and nothing is discarded.
|
|
147
|
-
"Not found" is only a real answer once you have seen every page you need. Paging re-reads stored
|
|
148
|
-
output and does NOT re-run the tool, so it is cheaper than repeating a search.
|
|
149
|
-
|
|
150
|
-
Finish with: {{"action":"finish","answer":"concise final answer with repository-relative evidence"}}
|
|
151
|
-
|
|
152
|
-
These are the complete action schemas. Use only `octocode`, `page`, or `finish`; every shown field
|
|
153
|
-
is required with the shown type, and `tool` must be one of the five names above. An invalid shape
|
|
154
|
-
is rejected and costs one bounded repair turn.
|
|
155
|
-
|
|
156
|
-
SCOPE YOUR SEARCHES. Pass excludeDir/exclude on every search in a real workspace —
|
|
157
|
-
["node_modules","target",".venv","dist","build",".git","vendor","__pycache__",".octocode"] — or
|
|
158
|
-
vendored and generated files will bury the answer. Matches under fixtures/, mocks/ or examples/ are
|
|
159
|
-
scaffolding, NOT the real implementation. Prefer src/, packages/*/src/ and lib/, and name the file
|
|
160
|
-
you took the answer from. Absence of a match is weak evidence: widen the pattern before concluding
|
|
161
|
-
something does not exist.
|
|
162
|
-
ASKED FOR A DEFAULT? Find where it is DECLARED, not where it appears. A value like a port or a
|
|
163
|
-
timeout is scattered across tests, docs and examples that merely pass it; those are occurrences, not
|
|
164
|
-
the default. The declaration is an attribute or initializer — `default_value = `, `unwrap_or(`,
|
|
165
|
-
`const `, `static `, a settings schema, a clap/argparse arg. Grep for the declaration form, and if
|
|
166
|
-
you can only find occurrences, say which file you took it from and that you did not find a
|
|
167
|
-
declaration. Test files (`tests/`, `*_test.*`, `*_contract.*`) define nothing — they consume it.
|
|
168
|
-
|
|
169
|
-
All paths you pass must resolve inside the workspace; relative paths are resolved against the
|
|
170
|
-
workspace root automatically. Orient cheap (localViewStructure/documentSymbols) before reading in
|
|
171
|
-
full. Be decisive: most tasks need 2-6 tool calls. Never edit or write files — you only have
|
|
172
|
-
read-only tools. Call finish as soon as the requested facts are established."""
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def main() -> int:
|
|
176
|
-
model = os.environ.get("FREELLAMA_TARGET_MODEL") or os.environ["FREELLAMA_BENCH_MODEL"]
|
|
177
|
-
workspace = Path(os.environ["FREELLAMA_BENCH_WORKSPACE"]).resolve()
|
|
178
|
-
prompt = Path(os.environ["FREELLAMA_BENCH_PROMPT"]).read_text(encoding="utf-8")
|
|
179
|
-
result_path = Path(os.environ["FREELLAMA_AGENT_RESULT"])
|
|
180
|
-
endpoint = os.environ.get("FREELLAMA_OLLAMA_ENDPOINT", "http://127.0.0.1:11434").rstrip("/")
|
|
181
|
-
managed_endpoint = os.environ.get("FREELLAMA_AGENT_MANAGED_ENDPOINT", "").rstrip("/")
|
|
182
|
-
execution_preference = os.environ.get("FREELLAMA_AGENT_EXECUTION_PREFERENCE", "auto")
|
|
183
|
-
min_placement_evidence = os.environ.get("FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE", "configured")
|
|
184
|
-
try:
|
|
185
|
-
runtime = AgentRuntimeConfig.from_env(default_tool_timeout_seconds=45)
|
|
186
|
-
except ValueError as error:
|
|
187
|
-
answer = f"invalid research adapter configuration: {error}"
|
|
188
|
-
write_failure_result(result_path, answer)
|
|
189
|
-
print(answer)
|
|
190
|
-
return 1
|
|
191
|
-
messages: list[dict[str, Any]] = [
|
|
192
|
-
{"role": "system", "content": system_prompt(str(workspace))},
|
|
193
|
-
{"role": "user", "content": prompt},
|
|
194
|
-
]
|
|
195
|
-
calls: list[dict[str, Any]] = []
|
|
196
|
-
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": None, "cache_write_tokens": None}
|
|
197
|
-
metrics = {"load_ms": 0.0, "prompt_eval_ms": 0.0, "eval_ms": 0.0}
|
|
198
|
-
execution_receipts: list[dict[str, Any]] = []
|
|
199
|
-
answer = ""
|
|
200
|
-
failure: str | None = None
|
|
201
|
-
calibration_dir = os.environ.get("FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR", "").strip()
|
|
202
|
-
context_manager = AgentContextManager(
|
|
203
|
-
runtime,
|
|
204
|
-
model=model,
|
|
205
|
-
calibration_dir=Path(calibration_dir) if calibration_dir else None,
|
|
206
|
-
)
|
|
207
|
-
try:
|
|
208
|
-
messages = context_manager.fit(messages)
|
|
209
|
-
except ValueError as error:
|
|
210
|
-
answer = f"research task does not fit the configured context safely: {error}"
|
|
211
|
-
write_failure_result(result_path, answer)
|
|
212
|
-
print(answer)
|
|
213
|
-
return 1
|
|
214
|
-
chat_options = {
|
|
215
|
-
"temperature": runtime.temperature,
|
|
216
|
-
"seed": runtime.seed,
|
|
217
|
-
"num_ctx": runtime.num_ctx,
|
|
218
|
-
"num_predict": runtime.num_predict,
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
def call_model() -> dict[str, Any]:
|
|
222
|
-
# The proxy already retries 500/502/504 (packages/rust-core/src/proxy.rs); this loop is a
|
|
223
|
-
# second, slower layer for outages that outlast the proxy's own retry budget — losing a
|
|
224
|
-
# whole multi-turn conversation to one bad turn would be wasteful.
|
|
225
|
-
last_error: Exception | None = None
|
|
226
|
-
for attempt in range(runtime.retry_attempts):
|
|
227
|
-
try:
|
|
228
|
-
transport_endpoint = (
|
|
229
|
-
f"{managed_endpoint}/_freellama/v1/tasks" if managed_endpoint else endpoint
|
|
230
|
-
)
|
|
231
|
-
url, payload = chat_request(
|
|
232
|
-
transport_endpoint,
|
|
233
|
-
model,
|
|
234
|
-
messages,
|
|
235
|
-
chat_options,
|
|
236
|
-
runtime.think,
|
|
237
|
-
runtime.keep_alive,
|
|
238
|
-
execution_preference,
|
|
239
|
-
min_placement_evidence,
|
|
240
|
-
)
|
|
241
|
-
response = unwrap_chat_response(
|
|
242
|
-
request_json(url, payload, runtime.request_timeout_seconds), execution_receipts
|
|
243
|
-
)
|
|
244
|
-
break
|
|
245
|
-
except (HTTPError, URLError, TimeoutError) as error:
|
|
246
|
-
last_error = error
|
|
247
|
-
if attempt + 1 < runtime.retry_attempts:
|
|
248
|
-
time.sleep(runtime.retry_backoff_seconds)
|
|
249
|
-
else:
|
|
250
|
-
raise last_error # type: ignore[misc]
|
|
251
|
-
usage["input_tokens"] += int(response.get("prompt_eval_count", 0))
|
|
252
|
-
usage["output_tokens"] += int(response.get("eval_count", 0))
|
|
253
|
-
metrics["load_ms"] += float(response.get("load_duration", 0)) / 1_000_000
|
|
254
|
-
metrics["prompt_eval_ms"] += float(response.get("prompt_eval_duration", 0)) / 1_000_000
|
|
255
|
-
metrics["eval_ms"] += float(response.get("eval_duration", 0)) / 1_000_000
|
|
256
|
-
context_manager.observe(messages, response.get("prompt_eval_count"))
|
|
257
|
-
return response
|
|
258
|
-
|
|
259
|
-
def refit() -> bool:
|
|
260
|
-
nonlocal messages, failure
|
|
261
|
-
try:
|
|
262
|
-
messages = context_manager.fit(messages)
|
|
263
|
-
return True
|
|
264
|
-
except ValueError as error:
|
|
265
|
-
failure = f"research context cannot be fitted safely: {error}"
|
|
266
|
-
return False
|
|
267
|
-
|
|
268
|
-
seen_calls: dict[str, int] = {}
|
|
269
|
-
observations = ObservationStore(runtime.context.observation_page_chars)
|
|
270
|
-
parse_failures = 0
|
|
271
|
-
for _ in range(runtime.max_turns):
|
|
272
|
-
# Transport failures are terminal (call_model already retried them). A *parse* failure is
|
|
273
|
-
# not: tell the model what was wrong with its reply and let it correct itself, keeping every
|
|
274
|
-
# tool result gathered so far.
|
|
275
|
-
try:
|
|
276
|
-
response = call_model()
|
|
277
|
-
except (HTTPError, URLError, TimeoutError) as error:
|
|
278
|
-
failure = f"agent response failed: {type(error).__name__}: {error}"
|
|
279
|
-
break
|
|
280
|
-
raw = str(response.get("message", {}).get("content", ""))
|
|
281
|
-
try:
|
|
282
|
-
action = parse_action(raw)
|
|
283
|
-
except (ValueError, json.JSONDecodeError) as error:
|
|
284
|
-
parse_failures += 1
|
|
285
|
-
if parse_failures > runtime.max_parse_repairs:
|
|
286
|
-
failure = f"agent gave {parse_failures} unparseable replies: {type(error).__name__}: {error}"
|
|
287
|
-
break
|
|
288
|
-
messages.append({"role": "assistant", "content": raw[: runtime.parse_repair_echo_chars]})
|
|
289
|
-
messages.append({"role": "user", "content": PARSE_REPAIR_NOTICE})
|
|
290
|
-
if not refit():
|
|
291
|
-
break
|
|
292
|
-
continue
|
|
293
|
-
parse_failures = 0
|
|
294
|
-
# Paging re-serves stored output. It costs a turn but never a subprocess, and no byte of
|
|
295
|
-
# any observation is ever unreachable — which is the whole point of paginating instead of
|
|
296
|
-
# clipping.
|
|
297
|
-
if action["action"] == "page":
|
|
298
|
-
try:
|
|
299
|
-
want_step = int(action.get("step", 0))
|
|
300
|
-
want_page = int(action.get("page", 1))
|
|
301
|
-
except (TypeError, ValueError):
|
|
302
|
-
want_step, want_page = 0, 1
|
|
303
|
-
body, footer = observations.view(want_step, want_page)
|
|
304
|
-
messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
|
|
305
|
-
messages.append({"role": "user", "content": f"Observation (step {want_step}):\n{body}{footer}"})
|
|
306
|
-
if not refit():
|
|
307
|
-
break
|
|
308
|
-
continue
|
|
309
|
-
if action["action"] == "finish":
|
|
310
|
-
answer = str(action.get("answer", "")).strip()
|
|
311
|
-
break
|
|
312
|
-
started = time.perf_counter()
|
|
313
|
-
tool_name = str(action.get("tool", ""))
|
|
314
|
-
queries = action.get("queries", {}) if isinstance(action.get("queries"), dict) else {}
|
|
315
|
-
# An exact repeat costs a turn out of ten AND a second copy of an observation the model
|
|
316
|
-
# has already seen. Answer it from the prior step instead of re-running the subprocess.
|
|
317
|
-
signature = call_signature({"tool": tool_name, "queries": queries})
|
|
318
|
-
repeated = signature in seen_calls
|
|
319
|
-
if repeated:
|
|
320
|
-
observation = REPEAT_NOTICE
|
|
321
|
-
status = "repeat"
|
|
322
|
-
else:
|
|
323
|
-
try:
|
|
324
|
-
observation = run_octocode(
|
|
325
|
-
workspace, tool_name, queries, runtime.tool_timeout_seconds
|
|
326
|
-
)
|
|
327
|
-
status = "ok"
|
|
328
|
-
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
329
|
-
observation = f"tool error: {type(error).__name__}: {error}"
|
|
330
|
-
status = "error"
|
|
331
|
-
seen_calls[signature] = len(calls) + 1
|
|
332
|
-
calls.append({
|
|
333
|
-
"name": f"mcp.octocode.{tool_name}" if tool_name else "mcp.octocode.unknown",
|
|
334
|
-
"raw_name": action.get("action", ""),
|
|
335
|
-
"arguments": {"tool": tool_name, "queries": queries},
|
|
336
|
-
"status": status,
|
|
337
|
-
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
338
|
-
"result": observation,
|
|
339
|
-
})
|
|
340
|
-
messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
|
|
341
|
-
remaining = runtime.max_turns - len(calls)
|
|
342
|
-
step = len(calls)
|
|
343
|
-
observations.put(step, observation)
|
|
344
|
-
body, shown_page, total_pages = paginate(
|
|
345
|
-
observation, page_size=runtime.context.observation_page_chars
|
|
346
|
-
)
|
|
347
|
-
footer = page_footer(step, shown_page, total_pages, len(observation))
|
|
348
|
-
messages.append({"role": "user", "content": f"Observation (step {step}):\n{body}{footer}\n\nTool calls remaining: {remaining}. Finish now if the task is answerable; do not repeat prior calls."})
|
|
349
|
-
if not refit():
|
|
350
|
-
break
|
|
351
|
-
else:
|
|
352
|
-
messages.append({"role": "user", "content": "Tool budget is exhausted. Return exactly a finish JSON action now using the evidence collected. Do not request another tool."})
|
|
353
|
-
if refit():
|
|
354
|
-
try:
|
|
355
|
-
response = call_model()
|
|
356
|
-
final_action = parse_action(str(response.get("message", {}).get("content", "")))
|
|
357
|
-
if final_action.get("action") != "finish":
|
|
358
|
-
raise ValueError("forced final response was not finish")
|
|
359
|
-
answer = str(final_action.get("answer", "")).strip()
|
|
360
|
-
except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as error:
|
|
361
|
-
failure = f"agent exceeded {runtime.max_turns} turns and finalization failed: {type(error).__name__}: {error}"
|
|
362
|
-
|
|
363
|
-
if not answer:
|
|
364
|
-
answer = failure or "agent stopped without a final answer"
|
|
365
|
-
result = {
|
|
366
|
-
"final_answer": answer,
|
|
367
|
-
"tool_calls": calls,
|
|
368
|
-
"usage": usage,
|
|
369
|
-
"provider_metrics": metrics,
|
|
370
|
-
"model_metadata": {
|
|
371
|
-
"adapter": "octocode_cli_agent_v1",
|
|
372
|
-
"temperature": chat_options["temperature"],
|
|
373
|
-
"seed": chat_options["seed"],
|
|
374
|
-
"num_ctx": chat_options["num_ctx"],
|
|
375
|
-
"num_predict": chat_options["num_predict"],
|
|
376
|
-
"max_turns": runtime.max_turns,
|
|
377
|
-
"runtime_config": runtime.metadata(),
|
|
378
|
-
"context_management": context_manager.metadata(),
|
|
379
|
-
"context_compactions": context_manager.compactions,
|
|
380
|
-
"transport": "managed" if managed_endpoint else "direct_ollama",
|
|
381
|
-
"execution_preference": execution_preference,
|
|
382
|
-
"min_placement_evidence": min_placement_evidence,
|
|
383
|
-
"execution_receipts": execution_receipts,
|
|
384
|
-
"cache_token_metrics": "not_reported_by_ollama",
|
|
385
|
-
},
|
|
386
|
-
}
|
|
387
|
-
result_path.parent.mkdir(parents=True, exist_ok=True)
|
|
388
|
-
result_path.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
389
|
-
print(answer)
|
|
390
|
-
return 1 if failure else 0
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if __name__ == "__main__":
|
|
394
|
-
raise SystemExit(main())
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# ADR: Resource-aware backend routing
|
|
2
|
-
|
|
3
|
-
Status: Accepted and implemented
|
|
4
|
-
|
|
5
|
-
## Context
|
|
6
|
-
|
|
7
|
-
FreeLlama can isolate explicitly assigned helper models in a second Ollama process. Live trials
|
|
8
|
-
proved that CPU and GPU requests can overlap, but exact operator assignment alone was too rigid for
|
|
9
|
-
agents, and one global admission pool can let GPU work starve a CPU helper. Unconstrained
|
|
10
|
-
automatic migration is unsafe because capability, quality, session, and memory constraints
|
|
11
|
-
must continue to win.
|
|
12
|
-
|
|
13
|
-
## Decision
|
|
14
|
-
|
|
15
|
-
Keep backend eligibility operator-owned and add three bounded controls:
|
|
16
|
-
|
|
17
|
-
1. Callers may send `auto`, `prefer_cpu`, or `prefer_gpu`; this is a fallback-capable hint.
|
|
18
|
-
2. GPU and CPU backends own independent weighted admission pools and transition locks.
|
|
19
|
-
3. `auto` can use task-specific normalized warm latency after three samples exist on both backends
|
|
20
|
-
and one backend has more than a 10% advantage.
|
|
21
|
-
|
|
22
|
-
Explicit models and session affinity override the hint. Quality routing never follows latency.
|
|
23
|
-
Generation normalizes by output tokens; embeddings normalize by input tokens. Cold-load duration
|
|
24
|
-
does not enter feedback. Raw Ollama passthrough remains byte-transparent to the primary upstream.
|
|
25
|
-
Verified feedback is stored in a bounded, versioned snapshot and replaced atomically. A corrupt or
|
|
26
|
-
unsupported snapshot fails startup instead of silently changing learned routing behavior.
|
|
27
|
-
|
|
28
|
-
The contract is hardware-neutral: the operator supplies loopback endpoints and exact CPU-eligible
|
|
29
|
-
tags, while FreeLlama discovers host capacity and observes Ollama placement. The measured 48 GB Mac
|
|
30
|
-
result is validation evidence, not a routing constant. Device-visibility variables remain
|
|
31
|
-
Ollama-process configuration because NVIDIA, ROCm, Vulkan, Metal, and CPU-only hosts differ.
|
|
32
|
-
|
|
33
|
-
## Alternatives
|
|
34
|
-
|
|
35
|
-
| Alternative | Decision | Reason |
|
|
36
|
-
|---|---|---|
|
|
37
|
-
| Exact assignment only | Rejected | Safe but too rigid for agents and blind to runtime capacity |
|
|
38
|
-
| Let agents supply an upstream or `num_gpu` | Rejected | Bypasses operator eligibility and compatibility boundaries |
|
|
39
|
-
| Rewrite every raw Ollama request | Rejected | Breaks passthrough semantics and surprises existing clients |
|
|
40
|
-
| Persist every raw runtime event | Rejected | Unbounded traces add privacy and storage risk; persist only aggregate prompt-free feedback |
|
|
41
|
-
| Live memory/thermal controller | Deferred | No stable sensor and held-out workload yet justify it |
|
|
42
|
-
|
|
43
|
-
## Verification contract
|
|
44
|
-
|
|
45
|
-
- Preview returns placement, upstream, preference satisfaction, reason, and admission capacity.
|
|
46
|
-
- Two samples per backend do not change `auto`; the third makes the comparison decision-ready.
|
|
47
|
-
- Explicit model and session pins do not move.
|
|
48
|
-
- A one-unit GPU pool and one-unit CPU pool admit one task on each concurrently.
|
|
49
|
-
- CPU-managed requests contain `options.num_gpu: 0`; raw `/api/*` bodies are unchanged.
|
|
50
|
-
- Health exposes both pools and per-task feedback counts.
|
|
51
|
-
- Restart reloads the same bounded feedback counts; an unsupported schema refuses startup.
|
|
52
|
-
|
|
53
|
-
Rollback is configuration-only for callers: omit the preference or use `auto`. Operators can remove
|
|
54
|
-
the CPU backend to restore a single-primary layout. Reverting independent pools requires a code
|
|
55
|
-
rollback and is not recommended because it reintroduces cross-backend starvation.
|
package/docs/ECONOMICS.md
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# Token economics
|
|
2
|
-
|
|
3
|
-
This reference keeps benchmark detail out of the README's "what, why, and run it" path. The
|
|
4
|
-
per-call rows come from one machine (M4 Pro, 48 GB unified memory); values marked with `~` and the
|
|
5
|
-
session projection are estimates derived from those calls.
|
|
6
|
-
|
|
7
|
-
## Per-call, measured
|
|
8
|
-
|
|
9
|
-
| Work | Source tokens | Returned tokens | Preserved |
|
|
10
|
-
|---|---:|---:|---:|
|
|
11
|
-
| 6 grounded code questions | 59,208 | 1,742 | **97.1%** |
|
|
12
|
-
| 4 text embeddings (307 ms) | 17,600 | ~200 | **98.9%** |
|
|
13
|
-
| 1 image, OCR, byte-exact | 1,970 | 37 | **98.1%** |
|
|
14
|
-
| Historical tool-schema surface, per turn | <3.2k | 0 | **100%** |
|
|
15
|
-
|
|
16
|
-
## Current MCP-envelope audit (2026-09-03)
|
|
17
|
-
|
|
18
|
-
The earlier table describes a historical workload on an M4 Pro. This audit measures the current
|
|
19
|
-
eight-tool MCP build on the current host. JSON tokenization varies by client and model, so it records
|
|
20
|
-
bytes first and gives `characters / 4` only as a deliberately rough token estimate.
|
|
21
|
-
|
|
22
|
-
| Current result | Canonical structured chars | Compact text chars | Duplicate chars avoided | Rough duplicate-token estimate |
|
|
23
|
-
|---|---:|---:|---:|---:|
|
|
24
|
-
| `doctor {view:"summary"}` | 3,099 | 35 | 3,064 | ~766 |
|
|
25
|
-
| `doctor {view:"scheduler"}` | 3,933 | 35 | 3,898 | ~975 |
|
|
26
|
-
| `doctor {view:"config"}` | 7,373 | 86 | 7,287 | ~1,822 |
|
|
27
|
-
| `doctor {view:"full"}` | 10,836 | 185 | 10,651 | ~2,663 |
|
|
28
|
-
| `models {view:"raw", limit:1}` | 447 | 43 | 404 | ~101 |
|
|
29
|
-
|
|
30
|
-
These are **not automatically billed-token savings**. They are the second serialized text copy
|
|
31
|
-
that FreeLlama does not emit. An MCP host that feeds both `structuredContent` and text into a model
|
|
32
|
-
can avoid roughly the listed amount; a host that already keeps only one representation does not.
|
|
33
|
-
The canonical structured payload still exists for deterministic clients that need it.
|
|
34
|
-
|
|
35
|
-
The current eight-tool schema plus initialization instructions measured 17,935 characters, or
|
|
36
|
-
~4,484 tokens using the same rough conversion. The integration suite enforces a ceiling below 4,500
|
|
37
|
-
estimated tokens. This is tool-schema **rent**, not a saving: clients pay it whenever they expose
|
|
38
|
-
the entire tool list to a model. Read the bundled documentation resource on demand and do not expose
|
|
39
|
-
unneeded tools in a client that supports tool filtering.
|
|
40
|
-
|
|
41
|
-
Delegated research has a different economics boundary. It keeps source files and full tool
|
|
42
|
-
observations out of the calling agent's active prompt, but it spends local Ollama input/output
|
|
43
|
-
tokens in the delegated agent. The historical research rows below remain useful workload evidence;
|
|
44
|
-
they are not a claim that the current installed model or host can reproduce those savings. Measure
|
|
45
|
-
the same question, source size, model, context policy, and client transcript before using a number
|
|
46
|
-
as a production budget.
|
|
47
|
-
|
|
48
|
-
## Scaled to a session
|
|
49
|
-
|
|
50
|
-
20 grounded questions, 200 turns, one embedding index over 322 chunks:
|
|
51
|
-
|
|
52
|
-
| Component | Tokens preserved |
|
|
53
|
-
|---|---:|
|
|
54
|
-
| Research offload | 191,553 |
|
|
55
|
-
| Embedding vectors withheld | 354,200 |
|
|
56
|
-
| Schema rent avoided, after ~90% prompt-cache discount | 59,740 |
|
|
57
|
-
| **Total** | **≈ 605,000 — about 3 × a 200K window** |
|
|
58
|
-
|
|
59
|
-
## Measurement and projection limits
|
|
60
|
-
|
|
61
|
-
Schema rent is nominally **597,400** tokens (200 turns × the former eight-tool schemas). Including
|
|
62
|
-
the nominal figure puts the headline at **1.14M — nearly double**. This report discounts it by ~90%
|
|
63
|
-
because prompt caching makes cached input far cheaper,
|
|
64
|
-
and because the rent only accrues at all if a *separate* small model holds the tool schemas. The
|
|
65
|
-
first two rows are the uncontestable part: that data physically never reaches the orchestrator.
|
|
66
|
-
|
|
67
|
-
**Do not lead with this number.** Providers, caching strategies, context windows, and pricing all
|
|
68
|
-
move, and a token count moves with them. The durable benefit is **context isolation** — the
|
|
69
|
-
orchestrator never receives whole files, raw vectors, OCR output, intermediate research, or
|
|
70
|
-
repetitive schemas. That improves context *quality* as well as cost, and it does not depend on
|
|
71
|
-
anyone's price list.
|
|
72
|
-
|
|
73
|
-
## The cost side
|
|
74
|
-
|
|
75
|
-
| | |
|
|
76
|
-
|---|---|
|
|
77
|
-
| Grounded question latency | **7–62 s** vs ~1 s for a frontier model that already holds the file |
|
|
78
|
-
| Where it goes | **51% prompt re-evaluation**, 42% generation, 7% model load (measured on a 4-turn run) |
|
|
79
|
-
| Scaling | turn-dominated: `seconds ≈ 9.8 × tool_calls`, correlation **0.811** over 60 runs |
|
|
80
|
-
| Break-even | past ~1k tokens of source the token maths wins; below that, read the file directly |
|
|
81
|
-
| Judgment ceiling | ~67% accurate, in the same confident tone as the ~99% case |
|
|
82
|
-
|
|
83
|
-
**Correction (measured same day): prefix-cache reuse WORKS on this path.** An earlier revision of
|
|
84
|
-
this file claimed the opposite by misreading `prompt_eval_count`, which counts *all* prompt tokens
|
|
85
|
-
whether or not they were served from cache. The durations tell the truth:
|
|
86
|
-
|
|
87
|
-
| Probe (raw `/api/chat`, 2,462-token prefix) | prompt_eval |
|
|
88
|
-
|---|---:|
|
|
89
|
-
| turn 1, cold | 18,631 ms |
|
|
90
|
-
| turn 2, warm — tiny suffix appended | **281 ms** |
|
|
91
|
-
| a *different* conversation interjected | 22,467 ms (its own cold prefill) |
|
|
92
|
-
| turn 3 of the original, after the interjection | **285 ms — cache survived** |
|
|
93
|
-
|
|
94
|
-
So the per-turn cost is **prefill of NEW tokens plus generation**, not re-reading old context, and
|
|
95
|
-
the cache even survives interleaved conversations. The `seconds ≈ 9.8 × tool_calls` relationship
|
|
96
|
-
still holds empirically — each turn adds a fresh observation to prefill (~130 tok/s measured) and a
|
|
97
|
-
generation (~40 tok/s) — but the mechanism is new-token cost, not context re-evaluation. Fewer,
|
|
98
|
-
better-aimed commands remain the lever; "the cache is broken" is not the reason.
|
|
99
|
-
|
|
100
|
-
**One consequence worth acting on: context compaction is cache-hostile.** `fit_to_context` rewrites
|
|
101
|
-
old observations when the window is nearly full, which changes the byte prefix and invalidates the
|
|
102
|
-
cache from that point — the next turn re-prefills everything after the edit. The cheap mitigation is
|
|
103
|
-
headroom: `OLLAMA_KV_CACHE_TYPE=q8_0` roughly halves KV memory (flash attention is already auto-on,
|
|
104
|
-
measured and documented in `doctor`), which buys double the context for the same memory and makes
|
|
105
|
-
compaction — and therefore invalidation — rare.
|
package/docs/INDEX.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# FreeLlama packaged documentation
|
|
2
|
-
|
|
3
|
-
Read only the document relevant to the current operation. Repository `docs/` is the source of truth.
|
|
4
|
-
|
|
5
|
-
- [ADR: Resource-aware backend routing](ADR_RESOURCE_AWARE_BACKEND_ROUTING.md)
|
|
6
|
-
- [FreeLlama architecture](ARCHITECTURE.md)
|
|
7
|
-
- [CLI reference](CLI.md)
|
|
8
|
-
- [Run models on CPU and GPU](CPU_GPU_ROUTING.md)
|
|
9
|
-
- [Token economics](ECONOMICS.md)
|
|
10
|
-
- [Choose local models](MODEL_SELECTION.md)
|
|
11
|
-
- [Ollama sidecar boundary](OLLAMA_SIDECAR.md)
|
|
12
|
-
- [Ollama and FreeLlama optimization boundary](OLLAMA_SYSTEM_OPTIMIZATION.md)
|
|
13
|
-
- [Prepare FreeLlama for production](PRODUCTION.md)
|
|
14
|
-
- [Product positioning](PRODUCT_POSITIONING.md)
|
|
15
|
-
- [Test FreeLlama](TESTING.md)
|
package/docs/OLLAMA_SIDECAR.md
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# Ollama sidecar boundary
|
|
2
|
-
|
|
3
|
-
FreeLlama integrates with Ollama as a loopback HTTP sidecar, not an in-process inference plugin.
|
|
4
|
-
Ollama owns model execution; FreeLlama owns policy and coordination around it.
|
|
5
|
-
|
|
6
|
-
```mermaid
|
|
7
|
-
flowchart LR
|
|
8
|
-
C["Ollama-compatible client"] --> F["FreeLlama loopback sidecar"]
|
|
9
|
-
F -->|"raw /api and /v1"| G["Primary Ollama"]
|
|
10
|
-
F -->|"managed primary task"| G
|
|
11
|
-
F -->|"managed assigned task"| U["Optional CPU Ollama"]
|
|
12
|
-
G --> E["Metal, MLX, CUDA, ROCm, Vulkan, or CPU runner"]
|
|
13
|
-
U --> E2["CPU runner"]
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
This boundary avoids an Ollama fork and preserves its documented HTTP contract. It also makes a
|
|
17
|
-
second, process-isolated CPU backend possible without changing arbitrary client payloads.
|
|
18
|
-
|
|
19
|
-
## Divide ownership
|
|
20
|
-
|
|
21
|
-
| Problem | Owner |
|
|
22
|
-
|---|---|
|
|
23
|
-
| Kernels, prompt processing, and token generation | Ollama and its runner dependencies |
|
|
24
|
-
| Runner loading, eviction, and scheduling within one server | Ollama |
|
|
25
|
-
| Prompt templates and model defaults | Ollama model metadata or a `Modelfile` |
|
|
26
|
-
| Capability and memory filtering | FreeLlama managed routes |
|
|
27
|
-
| Evidence policy, confidence, and route receipts | FreeLlama managed routes |
|
|
28
|
-
| Session affinity, per-backend bounded admission, and warm runtime feedback | FreeLlama managed routes |
|
|
29
|
-
| Exact model assignment to an optional CPU process | FreeLlama configuration |
|
|
30
|
-
| Guarded CPU/GPU preference within assigned eligible models | FreeLlama managed routes |
|
|
31
|
-
| Authentication and remote exposure | FreeLlama bearer middleware; external ingress owns TLS and tenant authorization |
|
|
32
|
-
|
|
33
|
-
FreeLlama does not make an individual model decode faster. It can improve workload completion by
|
|
34
|
-
avoiding unsuitable routes, reducing avoidable transitions, refusing unsupported work early, and
|
|
35
|
-
overlapping independent CPU and GPU model requests when the machine benefits from that layout.
|
|
36
|
-
|
|
37
|
-
## Preserve compatibility
|
|
38
|
-
|
|
39
|
-
`packages/rust-core/src/proxy.rs` forwards paths, queries, request bodies, and streaming response
|
|
40
|
-
bodies. It removes hop-by-hop headers and adds `x-freellama-proxy: 1` to responses. Raw `/api/*`
|
|
41
|
-
and `/v1/*` requests always target the primary upstream and are not rewritten for CPU placement.
|
|
42
|
-
|
|
43
|
-
```mermaid
|
|
44
|
-
flowchart TD
|
|
45
|
-
R["Incoming request"] --> K{"Managed /_freellama/v1 route?"}
|
|
46
|
-
K -->|"No"| P["Stream unchanged to primary Ollama"]
|
|
47
|
-
K -->|"Yes"| V["Validate, route, assign backend, and admit"]
|
|
48
|
-
V --> X["Build bounded Ollama request"]
|
|
49
|
-
X --> O["Execute on assigned upstream"]
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
The distinction is intentional: existing Ollama clients retain predictable compatibility, while
|
|
53
|
-
clients that opt into the managed API receive routing and policy behavior.
|
|
54
|
-
|
|
55
|
-
## Know the remaining limits
|
|
56
|
-
|
|
57
|
-
Native passthrough traffic remains under Ollama's scheduler and does not participate in FreeLlama's
|
|
58
|
-
transition locks. The managed sidecar also does not enforce live memory-pressure or thermal
|
|
59
|
-
admission. A configured but unreachable CPU backend makes managed catalog discovery fail closed;
|
|
60
|
-
raw primary passthrough remains usable.
|
|
61
|
-
|
|
62
|
-
Runtime feedback is task-specific, token-normalized, and warm-only. It can steer
|
|
63
|
-
unpinned `fastest` or `balanced` work after three samples on each backend and a greater-than-10%
|
|
64
|
-
advantage; it cannot change CPU eligibility or steer quality routing. The CLI persists a bounded,
|
|
65
|
-
versioned snapshot atomically by default, so verified evidence survives restart without changing
|
|
66
|
-
Ollama residency.
|
|
67
|
-
|
|
68
|
-
Read [Architecture](ARCHITECTURE.md) for all managed flows,
|
|
69
|
-
[CPU and GPU model routing](CPU_GPU_ROUTING.md) for the two-process procedure, and
|
|
70
|
-
[Ollama and FreeLlama optimization](OLLAMA_SYSTEM_OPTIMIZATION.md) for the full tuning boundary.
|
|
71
|
-
|
|
72
|
-
## Sources
|
|
73
|
-
|
|
74
|
-
- [Ollama API introduction](https://docs.ollama.com/api/introduction)
|
|
75
|
-
- [Ollama streaming behavior](https://docs.ollama.com/api/streaming)
|
|
76
|
-
- [Ollama usage metrics](https://docs.ollama.com/api/usage)
|