@octocodeai/freellama-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ """Shared Ollama/FreeLlama chat transport for local research adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def request_headers() -> dict[str, str]:
11
+ """Build transport headers, reading a bearer token from a file rather than process args."""
12
+ headers = {"content-type": "application/json"}
13
+ token_file = os.environ.get("FREELLAMA_AUTH_TOKEN_FILE", "").strip()
14
+ if not token_file:
15
+ return headers
16
+ token = Path(token_file).read_text(encoding="utf-8").strip()
17
+ if len(token) < 32 or any(character.isspace() for character in token):
18
+ raise ValueError("FREELLAMA_AUTH_TOKEN_FILE must contain one token of at least 32 bytes")
19
+ headers["authorization"] = f"Bearer {token}"
20
+ return headers
21
+
22
+
23
+ def chat_request(
24
+ endpoint: str,
25
+ model: str,
26
+ messages: list[dict[str, Any]],
27
+ options: dict[str, Any],
28
+ think: bool,
29
+ keep_alive: str,
30
+ execution_preference: str = "auto",
31
+ min_placement_evidence: str = "configured",
32
+ ) -> tuple[str, dict[str, Any]]:
33
+ """Build one direct-Ollama or managed-FreeLlama request.
34
+
35
+ `endpoint` selects the transport: a URL ending in `/_freellama/v1/tasks` is managed. The
36
+ benchmark can still target raw Ollama for controlled comparisons; MCP always supplies the
37
+ managed URL so coding-agent turns share routing, admission, physical-placement receipts, and
38
+ adaptive feedback with ordinary `run_task` calls.
39
+ """
40
+ endpoint = endpoint.rstrip("/")
41
+ if endpoint.endswith("/_freellama/v1/tasks"):
42
+ managed_options = {key: value for key, value in options.items() if key != "num_ctx"}
43
+ return endpoint, {
44
+ "task": "coding",
45
+ "objective": "fastest",
46
+ "model": model,
47
+ "context_tokens": options["num_ctx"],
48
+ "execution_preference": execution_preference,
49
+ "min_placement_evidence": min_placement_evidence,
50
+ "messages": messages,
51
+ "keep_alive": keep_alive,
52
+ "request_options": {
53
+ "format": "json",
54
+ "think": think,
55
+ "options": managed_options,
56
+ },
57
+ }
58
+ return f"{endpoint}/api/chat", {
59
+ "model": model,
60
+ "messages": messages,
61
+ "stream": False,
62
+ "format": "json",
63
+ "think": think,
64
+ "keep_alive": keep_alive,
65
+ "options": options,
66
+ }
67
+
68
+
69
+ def unwrap_chat_response(
70
+ payload: dict[str, Any], execution_receipts: list[dict[str, Any]]
71
+ ) -> dict[str, Any]:
72
+ """Return the Ollama response and retain managed execution proof when present."""
73
+ response = payload.get("response")
74
+ if not isinstance(response, dict):
75
+ return payload
76
+ execution_receipts.append(
77
+ {
78
+ "model": payload.get("route", {}).get("selected_model"),
79
+ "execution": payload.get("execution"),
80
+ "admission": payload.get("admission"),
81
+ "feedback": payload.get("feedback"),
82
+ }
83
+ )
84
+ return response
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """Agent B adapter: local Ollama model restricted to raw Linux/bash commands only.
3
+
4
+ Same adapter contract and decoding settings as octocode_agent.py (see
5
+ benchmark/harness/references/adapters.md and benchmark/local/docs/03-agent-b-bash.md) — the only
6
+ variable under test is the tool surface: this agent has no structured tool schema, just a shell.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
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
+ # Nothing is clipped any more. `calls[].result` is written to result.json on disk and read by the
36
+ # MCP layer — it never enters the model's context, so there was never a reason to shorten it. What
37
+ # the MODEL sees is paginated instead: one page plus an exact instruction for fetching the next.
38
+ # See the pagination section of agent_context.py for why clipping was data loss, not economy.
39
+
40
+ DENYLIST = re.compile(
41
+ r"\bsudo\b|\brm\s+-rf\s+/(?!\S)|:\(\)\s*\{.*:\|:.*\}|\bcurl\b|\bwget\b|\bnc\b|\bssh\b|>\s*/dev/(sd|nvme|disk)",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+ # Home and well-known absolute prefixes. A relative `grep /pattern/` is not a filesystem path;
46
+ # `/etc/passwd` is. `..` as a path component walks out of cwd=workspace.
47
+ _HOME_ESCAPE = re.compile(r"(?:^|[\s=\"'])(?:~(?:/|$)|\$HOME\b|\$\{HOME\})")
48
+ _ABS_PATH = re.compile(r"(?:^|[\s=\"'])(/(?:[^\s\"']+))")
49
+ _DOTDOT_PATH = re.compile(r"(?:^|[\s=\"'/])\.\.(?:/|[\s\"']|$)")
50
+ _FS_ABS_PREFIX = re.compile(
51
+ r"^/(?:etc|usr|home|Users|var|tmp|private|opt|root|System|Library|bin|sbin|dev|Applications|Volumes)(?:/|$)"
52
+ )
53
+
54
+
55
+ def assert_command_confined(root: Path, command_text: str) -> None:
56
+ """Reject commands that read outside the workspace. The MCP allowlist only constrains
57
+ the workspace *root*; without this, `cat /etc/hosts` from cwd=root succeeds."""
58
+ if _HOME_ESCAPE.search(command_text):
59
+ raise ValueError("command blocked: home-directory path is outside the workspace")
60
+ if _DOTDOT_PATH.search(command_text):
61
+ raise ValueError("command blocked: '..' walks outside the workspace")
62
+ root = root.resolve()
63
+ for match in _ABS_PATH.finditer(command_text):
64
+ raw = match.group(1)
65
+ candidate = Path(raw)
66
+ looks_like_fs = _FS_ABS_PREFIX.match(raw) is not None or candidate.exists()
67
+ if not looks_like_fs:
68
+ continue
69
+ resolved = candidate.resolve()
70
+ if resolved != root and root not in resolved.parents:
71
+ raise ValueError(f"command blocked: path escapes workspace: {raw}")
72
+
73
+
74
+ def request_json(url: str, payload: dict[str, Any], timeout_seconds: float) -> dict[str, Any]:
75
+ request = Request(url, data=json.dumps(payload).encode(), headers=request_headers(), method="POST")
76
+ with urlopen(request, timeout=timeout_seconds) as response:
77
+ return json.loads(response.read())
78
+
79
+
80
+ def parse_action(content: str) -> dict[str, Any]:
81
+ value = parse_json_action(content)
82
+ action = value["action"]
83
+ if action == "shell" and (not isinstance(value.get("command"), str) or not value["command"].strip()):
84
+ raise ValueError("shell action requires a non-empty string command")
85
+ if action == "page" and (
86
+ not isinstance(value.get("step"), int)
87
+ or value["step"] < 1
88
+ or not isinstance(value.get("page"), int)
89
+ or value["page"] < 1
90
+ ):
91
+ raise ValueError("page action requires positive integer step and page")
92
+ if action == "finish" and not isinstance(value.get("answer"), str):
93
+ raise ValueError("finish action requires a string answer")
94
+ if action not in {"shell", "page", "finish"}:
95
+ raise ValueError(f"unsupported action for bash adapter: {action}")
96
+ return value
97
+
98
+
99
+ def run_shell(root: Path, command_text: str, timeout_seconds: float) -> str:
100
+ if not command_text.strip():
101
+ raise ValueError("empty shell command")
102
+ if DENYLIST.search(command_text):
103
+ raise ValueError("command blocked by safety denylist")
104
+ assert_command_confined(root, command_text)
105
+ result = subprocess.run(
106
+ ["/bin/bash", "-c", command_text],
107
+ cwd=root,
108
+ text=True,
109
+ capture_output=True,
110
+ timeout=timeout_seconds,
111
+ check=False,
112
+ )
113
+ output = (result.stdout or "") + (result.stderr or "")
114
+ if not output.strip():
115
+ output = f"(no output, exit code {result.returncode})"
116
+ return output
117
+
118
+
119
+ def system_prompt() -> str:
120
+ return """You are a local coding agent in an isolated benchmark workspace containing a pinned repository, rooted at the current directory. You solve tasks using ONLY raw POSIX shell commands — no editors, no special tools, no network access. Return exactly one JSON object per turn:
121
+
122
+ {"action":"shell","command":"one shell command, e.g. grep -n \\"class Group\\" click/src/click/core.py"}
123
+ {"action":"page","step":2,"page":2}
124
+ {"action":"finish","answer":"concise final answer with repository-relative evidence"}
125
+
126
+ These are the complete action schemas. Use only `shell`, `page`, or `finish`; every shown field is
127
+ required with the shown type. An invalid shape is rejected and costs one bounded repair turn.
128
+
129
+ Long output is PAGINATED, never truncated: you are shown page 1 and told the total. Nothing is
130
+ discarded, so "not found" is only a real answer once you have seen every page you need. Send the
131
+ page action to read another page of a previous step — it re-reads stored output and does not re-run
132
+ the command, so it is cheaper than repeating the search.
133
+
134
+ Use standard Unix utilities: ls, find, cat, grep, sed, awk, head, tail, wc, tree (if present). Chain
135
+ with pipes if needed, but keep each turn to a single shell invocation. Never edit files. Be decisive:
136
+ most tasks need 2-6 commands. Call finish as soon as the requested facts are established.
137
+
138
+ SCOPE YOUR SEARCHES. A real workspace holds far more than its source, and an unscoped grep drowns
139
+ the answer in vendored and generated files. Always exclude them:
140
+ grep -rn "PATTERN" . --exclude-dir={node_modules,target,.venv,venv,dist,build,.git,vendor,__pycache__,.octocode,site-packages}
141
+ Matches under fixtures/, test/fixtures/, mocks/ or examples/ are usually scaffolding, NOT the real
142
+ implementation — a mock named like the thing you are looking for is a trap, not an answer. Prefer
143
+ src/, packages/*/src/, lib/ and the repo root, and name the file you took the answer from.
144
+ If a search returns nothing, widen the pattern before concluding the thing does not exist: absence
145
+ of a grep hit is weak evidence, and "not found" is only a real answer once you have looked in the
146
+ source directories.
147
+ ASKED FOR A DEFAULT? Find where it is DECLARED, not where it appears. A value like a port or a
148
+ timeout is scattered across tests, docs and examples that merely pass it; those are occurrences, not
149
+ the default. The declaration is an attribute or initializer — `default_value = `, `unwrap_or(`,
150
+ `const `, `static `, a settings schema, a clap/argparse arg. Grep for the declaration form, and if
151
+ you can only find occurrences, say which file you took it from and that you did not find a
152
+ declaration. Test files (`tests/`, `*_test.*`, `*_contract.*`) define nothing — they consume it."""
153
+
154
+
155
+ def main() -> int:
156
+ model = os.environ.get("FREELLAMA_TARGET_MODEL") or os.environ["FREELLAMA_BENCH_MODEL"]
157
+ workspace = Path(os.environ["FREELLAMA_BENCH_WORKSPACE"]).resolve()
158
+ prompt = Path(os.environ["FREELLAMA_BENCH_PROMPT"]).read_text(encoding="utf-8")
159
+ result_path = Path(os.environ["FREELLAMA_AGENT_RESULT"])
160
+ endpoint = os.environ.get("FREELLAMA_OLLAMA_ENDPOINT", "http://127.0.0.1:11434").rstrip("/")
161
+ managed_endpoint = os.environ.get("FREELLAMA_AGENT_MANAGED_ENDPOINT", "").rstrip("/")
162
+ execution_preference = os.environ.get("FREELLAMA_AGENT_EXECUTION_PREFERENCE", "auto")
163
+ min_placement_evidence = os.environ.get("FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE", "configured")
164
+ try:
165
+ runtime = AgentRuntimeConfig.from_env(default_tool_timeout_seconds=30)
166
+ except ValueError as error:
167
+ answer = f"invalid research adapter configuration: {error}"
168
+ write_failure_result(result_path, answer)
169
+ print(answer)
170
+ return 1
171
+ messages: list[dict[str, Any]] = [
172
+ {"role": "system", "content": system_prompt()},
173
+ {"role": "user", "content": prompt},
174
+ ]
175
+ calls: list[dict[str, Any]] = []
176
+ usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": None, "cache_write_tokens": None}
177
+ metrics = {"load_ms": 0.0, "prompt_eval_ms": 0.0, "eval_ms": 0.0}
178
+ execution_receipts: list[dict[str, Any]] = []
179
+ answer = ""
180
+ failure: str | None = None
181
+ calibration_dir = os.environ.get("FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR", "").strip()
182
+ context_manager = AgentContextManager(
183
+ runtime,
184
+ model=model,
185
+ calibration_dir=Path(calibration_dir) if calibration_dir else None,
186
+ )
187
+ try:
188
+ messages = context_manager.fit(messages)
189
+ except ValueError as error:
190
+ answer = f"research task does not fit the configured context safely: {error}"
191
+ write_failure_result(result_path, answer)
192
+ print(answer)
193
+ return 1
194
+ chat_options = {
195
+ "temperature": runtime.temperature,
196
+ "seed": runtime.seed,
197
+ "num_ctx": runtime.num_ctx,
198
+ "num_predict": runtime.num_predict,
199
+ }
200
+
201
+ def call_model() -> dict[str, Any]:
202
+ # The proxy already retries 500/502/504 (packages/rust-core/src/proxy.rs); this loop is a
203
+ # second, slower layer for outages that outlast the proxy's own retry budget — losing a
204
+ # whole multi-turn conversation to one bad turn would be wasteful.
205
+ last_error: Exception | None = None
206
+ for attempt in range(runtime.retry_attempts):
207
+ try:
208
+ transport_endpoint = (
209
+ f"{managed_endpoint}/_freellama/v1/tasks" if managed_endpoint else endpoint
210
+ )
211
+ url, payload = chat_request(
212
+ transport_endpoint,
213
+ model,
214
+ messages,
215
+ chat_options,
216
+ runtime.think,
217
+ runtime.keep_alive,
218
+ execution_preference,
219
+ min_placement_evidence,
220
+ )
221
+ response = unwrap_chat_response(
222
+ request_json(url, payload, runtime.request_timeout_seconds), execution_receipts
223
+ )
224
+ break
225
+ except (HTTPError, URLError, TimeoutError) as error:
226
+ last_error = error
227
+ if attempt + 1 < runtime.retry_attempts:
228
+ time.sleep(runtime.retry_backoff_seconds)
229
+ else:
230
+ raise last_error # type: ignore[misc]
231
+ usage["input_tokens"] += int(response.get("prompt_eval_count", 0))
232
+ usage["output_tokens"] += int(response.get("eval_count", 0))
233
+ metrics["load_ms"] += float(response.get("load_duration", 0)) / 1_000_000
234
+ metrics["prompt_eval_ms"] += float(response.get("prompt_eval_duration", 0)) / 1_000_000
235
+ metrics["eval_ms"] += float(response.get("eval_duration", 0)) / 1_000_000
236
+ context_manager.observe(messages, response.get("prompt_eval_count"))
237
+ return response
238
+
239
+ def refit() -> bool:
240
+ nonlocal messages, failure
241
+ try:
242
+ messages = context_manager.fit(messages)
243
+ return True
244
+ except ValueError as error:
245
+ failure = f"research context cannot be fitted safely: {error}"
246
+ return False
247
+
248
+ seen_calls: dict[str, int] = {}
249
+ observations = ObservationStore(runtime.context.observation_page_chars)
250
+ parse_failures = 0
251
+ for _ in range(runtime.max_turns):
252
+ # Transport failures are terminal (call_model already retried them). A *parse* failure is
253
+ # not: tell the model what was wrong with its reply and let it correct itself, keeping every
254
+ # tool result gathered so far.
255
+ try:
256
+ response = call_model()
257
+ except (HTTPError, URLError, TimeoutError) as error:
258
+ failure = f"agent response failed: {type(error).__name__}: {error}"
259
+ break
260
+ raw = str(response.get("message", {}).get("content", ""))
261
+ try:
262
+ action = parse_action(raw)
263
+ except (ValueError, json.JSONDecodeError) as error:
264
+ parse_failures += 1
265
+ if parse_failures > runtime.max_parse_repairs:
266
+ failure = f"agent gave {parse_failures} unparseable replies: {type(error).__name__}: {error}"
267
+ break
268
+ messages.append({"role": "assistant", "content": raw[: runtime.parse_repair_echo_chars]})
269
+ messages.append({"role": "user", "content": PARSE_REPAIR_NOTICE})
270
+ if not refit():
271
+ break
272
+ continue
273
+ parse_failures = 0
274
+ # Paging re-serves stored output. It costs a turn but never a subprocess, and no byte of
275
+ # any observation is ever unreachable — which is the whole point of paginating instead of
276
+ # clipping.
277
+ if action["action"] == "page":
278
+ try:
279
+ want_step = int(action.get("step", 0))
280
+ want_page = int(action.get("page", 1))
281
+ except (TypeError, ValueError):
282
+ want_step, want_page = 0, 1
283
+ body, footer = observations.view(want_step, want_page)
284
+ messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
285
+ messages.append({"role": "user", "content": f"Observation (step {want_step}):\n{body}{footer}"})
286
+ if not refit():
287
+ break
288
+ continue
289
+ if action["action"] == "finish":
290
+ answer = str(action.get("answer", "")).strip()
291
+ break
292
+ started = time.perf_counter()
293
+ command_text = str(action.get("command", ""))
294
+ # An exact repeat costs a turn out of ten AND a second copy of an observation the model has
295
+ # already seen. Answer it from the prior step instead of re-running the command.
296
+ signature = call_signature({"command": command_text})
297
+ if signature in seen_calls:
298
+ observation = REPEAT_NOTICE
299
+ status = "repeat"
300
+ else:
301
+ try:
302
+ observation = run_shell(workspace, command_text, runtime.tool_timeout_seconds)
303
+ status = "ok"
304
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
305
+ observation = f"tool error: {type(error).__name__}: {error}"
306
+ status = "error"
307
+ seen_calls[signature] = len(calls) + 1
308
+ calls.append({
309
+ "name": "shell",
310
+ "raw_name": action.get("action", ""),
311
+ "arguments": {"command": command_text},
312
+ "status": status,
313
+ "duration_ms": round((time.perf_counter() - started) * 1000, 3),
314
+ "result": observation,
315
+ })
316
+ messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
317
+ remaining = runtime.max_turns - len(calls)
318
+ step = len(calls)
319
+ observations.put(step, observation)
320
+ body, shown_page, total_pages = paginate(
321
+ observation, page_size=runtime.context.observation_page_chars
322
+ )
323
+ footer = page_footer(step, shown_page, total_pages, len(observation))
324
+ messages.append({"role": "user", "content": f"Observation (step {step}):\n{body}{footer}\n\nCommands remaining: {remaining}. Finish now if the task is answerable; do not repeat prior commands."})
325
+ if not refit():
326
+ break
327
+ else:
328
+ messages.append({"role": "user", "content": "Command budget is exhausted. Return exactly a finish JSON action now using the evidence collected. Do not request another command."})
329
+ if refit():
330
+ try:
331
+ response = call_model()
332
+ final_action = parse_action(str(response.get("message", {}).get("content", "")))
333
+ if final_action.get("action") != "finish":
334
+ raise ValueError("forced final response was not finish")
335
+ answer = str(final_action.get("answer", "")).strip()
336
+ except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as error:
337
+ failure = f"agent exceeded {runtime.max_turns} turns and finalization failed: {type(error).__name__}: {error}"
338
+
339
+ if not answer:
340
+ answer = failure or "agent stopped without a final answer"
341
+ result = {
342
+ "final_answer": answer,
343
+ "tool_calls": calls,
344
+ "usage": usage,
345
+ "provider_metrics": metrics,
346
+ "model_metadata": {
347
+ "adapter": "bash_shell_agent_v1",
348
+ "temperature": chat_options["temperature"],
349
+ "seed": chat_options["seed"],
350
+ "num_ctx": chat_options["num_ctx"],
351
+ "num_predict": chat_options["num_predict"],
352
+ "max_turns": runtime.max_turns,
353
+ "runtime_config": runtime.metadata(),
354
+ "context_management": context_manager.metadata(),
355
+ "context_compactions": context_manager.compactions,
356
+ "transport": "managed" if managed_endpoint else "direct_ollama",
357
+ "execution_preference": execution_preference,
358
+ "min_placement_evidence": min_placement_evidence,
359
+ "execution_receipts": execution_receipts,
360
+ "cache_token_metrics": "not_reported_by_ollama",
361
+ },
362
+ }
363
+ result_path.parent.mkdir(parents=True, exist_ok=True)
364
+ result_path.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
365
+ print(answer)
366
+ return 1 if failure else 0
367
+
368
+
369
+ if __name__ == "__main__":
370
+ raise SystemExit(main())