@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,394 @@
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())