@octocodeai/freellama-mcp-server 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,9 @@ variable under test is the tool surface: this agent has no structured tool schem
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from contextlib import ExitStack
11
12
  import json
12
13
  import os
13
- import re
14
14
  import subprocess
15
15
  import time
16
16
  from pathlib import Path
@@ -25,50 +25,21 @@ from agent_context import (
25
25
  REPEAT_NOTICE,
26
26
  ObservationStore,
27
27
  call_signature,
28
- paginate,
29
- page_footer,
30
28
  parse_json_action,
31
29
  write_failure_result,
32
30
  )
33
- from agent_transport import chat_request, request_headers, unwrap_chat_response
31
+ import shell_sandbox
32
+ from shell_sandbox import ReadOnlyShell
33
+ from agent_transport import chat_request, request_headers, unwrap_chat_response, PromptCacheUsage, retryable_chat_error, resolve_model_identity, chat_error_details
34
34
 
35
35
  # Nothing is clipped any more. `calls[].result` is written to result.json on disk and read by the
36
36
  # MCP layer — it never enters the model's context, so there was never a reason to shorten it. What
37
37
  # the MODEL sees is paginated instead: one page plus an exact instruction for fetching the next.
38
38
  # See the pagination section of agent_context.py for why clipping was data loss, not economy.
39
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}")
40
+ # Validation, restricted bash, a scrubbed environment and (where the host allows) an OS sandbox
41
+ # live in shell_sandbox; see its module docstring for why the old regex denylist was not enough.
42
+ assert_command_confined = shell_sandbox.assert_command_confined
72
43
 
73
44
 
74
45
  def request_json(url: str, payload: dict[str, Any], timeout_seconds: float) -> dict[str, Any]:
@@ -96,28 +67,15 @@ def parse_action(content: str) -> dict[str, Any]:
96
67
  return value
97
68
 
98
69
 
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
70
+ def run_shell(root: Path, command_text: str, timeout_seconds: float, shell: ReadOnlyShell | None = None) -> str:
71
+ if shell is not None:
72
+ return shell.run(command_text, timeout_seconds)
73
+ with ReadOnlyShell(root) as scoped:
74
+ return scoped.run(command_text, timeout_seconds)
117
75
 
118
76
 
119
77
  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:
78
+ return """You are a local code-research agent in a read-only workspace rooted at the current directory. Use raw POSIX shell commands without editors, special tools, or network access. Return exactly one JSON object per turn:
121
79
 
122
80
  {"action":"shell","command":"one shell command, e.g. grep -n \\"class Group\\" click/src/click/core.py"}
123
81
  {"action":"page","step":2,"page":2}
@@ -126,33 +84,35 @@ def system_prompt() -> str:
126
84
  These are the complete action schemas. Use only `shell`, `page`, or `finish`; every shown field is
127
85
  required with the shown type. An invalid shape is rejected and costs one bounded repair turn.
128
86
 
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.
87
+ Full command output is stored in pages; you see page 1 and the total. Context fitting can shorten
88
+ an observation in the conversation. Request its stored page to recover the evidence without
89
+ re-running the command. Claim "not found" only after checking the relevant search scope and pages.
133
90
 
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.
91
+ The shell is READ-ONLY and restricted. Available tools: ls, find, grep, rg, cat, head, tail, wc,
92
+ sort, uniq, cut, tr, nl, sed (bounded read-only inline programs), jq, diff, xargs
93
+ (only echo, printf, true, or false targets), tree, file, stat, and read-only git
94
+ (log, show, grep, ls-files, diff, blame, status). There is no awk, python, network, output
95
+ redirection, script-file loading, recursive symlink following, paths outside the workspace,
96
+ $(...) substitution, $VARIABLES, or loops: write paths and patterns literally and
97
+ chain tools with pipes, one pipeline per turn. A refused command costs a turn, so follow these
98
+ rules. Be decisive: most tasks need 2-6 commands. Call finish as soon as the requested facts are
99
+ established.
137
100
 
138
101
  SCOPE YOUR SEARCHES. A real workspace holds far more than its source, and an unscoped grep drowns
139
102
  the answer in vendored and generated files. Always exclude them:
140
103
  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.
104
+ For implementation questions, prefer src/, packages/*/src/, lib/ and the repo root before fixtures,
105
+ mocks, or examples. If the question targets tests or examples, inspect those files. Name the file
106
+ that supplies the answer.
144
107
  If a search returns nothing, widen the pattern before concluding the thing does not exist: absence
145
108
  of a grep hit is weak evidence, and "not found" is only a real answer once you have looked in the
146
109
  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."""
110
+ For a production default, find its declaration before relying on test occurrences. Declarations look like
111
+ `default=`, `unwrap_or(`, `const `, `static `, or a settings schema initializer.
112
+ For a test-specific default or fixture, inspect its test declaration."""
153
113
 
154
114
 
155
- def main() -> int:
115
+ def _main(resources: ExitStack) -> int:
156
116
  model = os.environ.get("FREELLAMA_TARGET_MODEL") or os.environ["FREELLAMA_BENCH_MODEL"]
157
117
  workspace = Path(os.environ["FREELLAMA_BENCH_WORKSPACE"]).resolve()
158
118
  prompt = Path(os.environ["FREELLAMA_BENCH_PROMPT"]).read_text(encoding="utf-8")
@@ -174,15 +134,22 @@ def main() -> int:
174
134
  ]
175
135
  calls: list[dict[str, Any]] = []
176
136
  usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": None, "cache_write_tokens": None}
137
+ cache_usage = PromptCacheUsage()
177
138
  metrics = {"load_ms": 0.0, "prompt_eval_ms": 0.0, "eval_ms": 0.0}
178
139
  execution_receipts: list[dict[str, Any]] = []
179
140
  answer = ""
180
141
  failure: str | None = None
142
+ transport_error: dict[str, Any] | None = None
181
143
  calibration_dir = os.environ.get("FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR", "").strip()
144
+ calibration_model_identity = (
145
+ resolve_model_identity(managed_endpoint or endpoint, model, messages[0]["content"])
146
+ if calibration_dir else {"verified": False, "scope": "persistence_disabled", "identity": ""}
147
+ )
182
148
  context_manager = AgentContextManager(
183
149
  runtime,
184
150
  model=model,
185
- calibration_dir=Path(calibration_dir) if calibration_dir else None,
151
+ calibration_identity=calibration_model_identity["identity"],
152
+ calibration_dir=Path(calibration_dir) if calibration_dir and calibration_model_identity["verified"] else None,
186
153
  )
187
154
  try:
188
155
  messages = context_manager.fit(messages)
@@ -199,9 +166,8 @@ def main() -> int:
199
166
  }
200
167
 
201
168
  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.
169
+ # Retry explicit overload/connection refusals only. Replaying a timeout or an uncertain
170
+ # transport failure can overlap a generation that is still consuming the runner.
205
171
  last_error: Exception | None = None
206
172
  for attempt in range(runtime.retry_attempts):
207
173
  try:
@@ -223,12 +189,16 @@ def main() -> int:
223
189
  )
224
190
  break
225
191
  except (HTTPError, URLError, TimeoutError) as error:
192
+ if not retryable_chat_error(error):
193
+ raise
226
194
  last_error = error
227
195
  if attempt + 1 < runtime.retry_attempts:
228
196
  time.sleep(runtime.retry_backoff_seconds)
229
197
  else:
230
198
  raise last_error # type: ignore[misc]
231
199
  usage["input_tokens"] += int(response.get("prompt_eval_count", 0))
200
+ cache_usage.observe(response)
201
+ usage["cache_read_tokens"] = cache_usage.tokens
232
202
  usage["output_tokens"] += int(response.get("eval_count", 0))
233
203
  metrics["load_ms"] += float(response.get("load_duration", 0)) / 1_000_000
234
204
  metrics["prompt_eval_ms"] += float(response.get("prompt_eval_duration", 0)) / 1_000_000
@@ -246,7 +216,8 @@ def main() -> int:
246
216
  return False
247
217
 
248
218
  seen_calls: dict[str, int] = {}
249
- observations = ObservationStore(runtime.context.observation_page_chars)
219
+ observations = resources.enter_context(ObservationStore(runtime.context.observation_page_chars))
220
+ shell = resources.enter_context(ReadOnlyShell(workspace))
250
221
  parse_failures = 0
251
222
  for _ in range(runtime.max_turns):
252
223
  # Transport failures are terminal (call_model already retried them). A *parse* failure is
@@ -255,7 +226,8 @@ def main() -> int:
255
226
  try:
256
227
  response = call_model()
257
228
  except (HTTPError, URLError, TimeoutError) as error:
258
- failure = f"agent response failed: {type(error).__name__}: {error}"
229
+ diagnostic, transport_error = chat_error_details(error)
230
+ failure = f"agent response failed: {diagnostic}"
259
231
  break
260
232
  raw = str(response.get("message", {}).get("content", ""))
261
233
  try:
@@ -280,9 +252,8 @@ def main() -> int:
280
252
  want_page = int(action.get("page", 1))
281
253
  except (TypeError, ValueError):
282
254
  want_step, want_page = 0, 1
283
- body, footer = observations.view(want_step, want_page)
284
255
  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}"})
256
+ messages.append({"role": "user", "content": observations.message(want_step, want_page)})
286
257
  if not refit():
287
258
  break
288
259
  continue
@@ -299,7 +270,7 @@ def main() -> int:
299
270
  status = "repeat"
300
271
  else:
301
272
  try:
302
- observation = run_shell(workspace, command_text, runtime.tool_timeout_seconds)
273
+ observation = run_shell(workspace, command_text, runtime.tool_timeout_seconds, shell)
303
274
  status = "ok"
304
275
  except (OSError, ValueError, subprocess.TimeoutExpired) as error:
305
276
  observation = f"tool error: {type(error).__name__}: {error}"
@@ -311,17 +282,13 @@ def main() -> int:
311
282
  "arguments": {"command": command_text},
312
283
  "status": status,
313
284
  "duration_ms": round((time.perf_counter() - started) * 1000, 3),
314
- "result": observation,
315
285
  })
316
286
  messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
317
287
  remaining = runtime.max_turns - len(calls)
318
288
  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."})
289
+ observations.put(step, observation, metadata={"action": "shell", "status": status, "source": command_text})
290
+ del observation
291
+ messages.append({"role": "user", "content": observations.message(step) + f"\n\nCommands remaining: {remaining}. Finish now if the task is answerable; do not repeat prior commands."})
325
292
  if not refit():
326
293
  break
327
294
  else:
@@ -334,7 +301,8 @@ def main() -> int:
334
301
  raise ValueError("forced final response was not finish")
335
302
  answer = str(final_action.get("answer", "")).strip()
336
303
  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}"
304
+ diagnostic, transport_error = chat_error_details(error)
305
+ failure = f"agent exceeded {runtime.max_turns} turns and finalization failed: {diagnostic}"
338
306
 
339
307
  if not answer:
340
308
  answer = failure or "agent stopped without a final answer"
@@ -345,6 +313,7 @@ def main() -> int:
345
313
  "provider_metrics": metrics,
346
314
  "model_metadata": {
347
315
  "adapter": "bash_shell_agent_v1",
316
+ "sandbox": shell.kind,
348
317
  "temperature": chat_options["temperature"],
349
318
  "seed": chat_options["seed"],
350
319
  "num_ctx": chat_options["num_ctx"],
@@ -357,14 +326,20 @@ def main() -> int:
357
326
  "execution_preference": execution_preference,
358
327
  "min_placement_evidence": min_placement_evidence,
359
328
  "execution_receipts": execution_receipts,
360
- "cache_token_metrics": "not_reported_by_ollama",
329
+ **({"transport_error": transport_error} if transport_error else {}),
330
+ "cache_token_metrics": cache_usage.metadata(),
331
+ "calibration_model_identity": calibration_model_identity,
361
332
  },
362
333
  }
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")
334
+ observations.write_result(result_path, result)
365
335
  print(answer)
366
336
  return 1 if failure else 0
367
337
 
368
338
 
339
+ def main() -> int:
340
+ with ExitStack() as resources:
341
+ return _main(resources)
342
+
343
+
369
344
  if __name__ == "__main__":
370
345
  raise SystemExit(main())
@@ -9,6 +9,7 @@ mirrors verbatim.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from contextlib import ExitStack
12
13
  import json
13
14
  import os
14
15
  import subprocess
@@ -25,12 +26,10 @@ from agent_context import (
25
26
  REPEAT_NOTICE,
26
27
  ObservationStore,
27
28
  call_signature,
28
- paginate,
29
- page_footer,
30
29
  parse_json_action,
31
30
  write_failure_result,
32
31
  )
33
- from agent_transport import chat_request, request_headers, unwrap_chat_response
32
+ from agent_transport import chat_request, request_headers, unwrap_chat_response, PromptCacheUsage, retryable_chat_error, resolve_model_identity, chat_error_details
34
33
 
35
34
  OCTOCODE_TOOLS = {"localViewStructure", "localFindFiles", "localSearchCode", "localGetFileContent", "lspGetSemantics"}
36
35
  PATH_KEYS = ("path", "uri")
@@ -78,6 +77,12 @@ def parse_action(content: str) -> dict[str, Any]:
78
77
  return value
79
78
 
80
79
 
80
+ # Pinned: an unpinned `npx octocode` downloads whatever is latest at call time (a supply-chain
81
+ # risk for a tool that reads the user's workspace) and the first download can exceed the tool
82
+ # timeout. Override deliberately with FREELLAMA_AGENT_OCTOCODE_PACKAGE.
83
+ OCTOCODE_PACKAGE = os.environ.get("FREELLAMA_AGENT_OCTOCODE_PACKAGE", "octocode@19.1.0")
84
+
85
+
81
86
  def run_octocode(
82
87
  root: Path,
83
88
  tool_name: str,
@@ -90,7 +95,7 @@ def run_octocode(
90
95
  for key in PATH_KEYS:
91
96
  if key in resolved and isinstance(resolved[key], str):
92
97
  resolved[key] = str(safe_resolve(root, resolved[key]))
93
- command = ["npx", "octocode", "tools", tool_name, "--queries", json.dumps(resolved), "--compact"]
98
+ command = ["npx", "--yes", OCTOCODE_PACKAGE, "tools", tool_name, "--queries", json.dumps(resolved), "--compact"]
94
99
  result = subprocess.run(
95
100
  command,
96
101
  cwd=root,
@@ -106,46 +111,41 @@ def run_octocode(
106
111
 
107
112
 
108
113
  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.
114
+ return f"""You are a local code-research agent in a read-only workspace at {workspace}. Read or search files only through the `octocode` local tools below. Return exactly one JSON object per turn.
110
115
 
111
116
  Tools (call as {{"action":"octocode","tool":"<name>","queries":{{...}}}}):
112
117
 
113
118
  localViewStructure - browse a directory tree, no content loaded; cheapest first orientation step.
114
- queries: path (string, required, absolute), maxDepth (int), recursive (bool), filesOnly (bool),
119
+ path (string, required, absolute), maxDepth (int), recursive (bool), filesOnly (bool),
115
120
  directoriesOnly (bool), pattern (glob/substring filter).
116
121
 
117
122
  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"]).
123
+ path (string, required, absolute), names (array of globs), pathPattern (glob over full path),
124
+ regex (basename regex), entryType ("f"|"d"), excludeDir (array, e.g. ["node_modules",".git"]).
120
125
 
121
126
  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),
127
+ path (string, required, absolute), keywords (string; literal or regex search term),
123
128
  mode ("paginated"|"discovery"|"detailed"), include/exclude (glob arrays),
124
129
  caseInsensitive (bool), maxFiles (int).
125
130
  Do NOT pass mode "structural" with keywords — structural (AST) search takes `pattern`/`rule`
126
131
  instead and hard-errors on keywords, costing you a turn for nothing.
127
132
 
128
133
  localGetFileContent - read one file or a line range/matched slice of it.
129
- queries: path (string, required, absolute), fullContent (bool; small files only), startLine +
134
+ path (string, required, absolute), fullContent (bool; small files only), startLine +
130
135
  endLine (ints, both required together), matchString (anchor text/regex), minify
131
136
  ("none"|"standard"|"symbols" — "symbols" gives a cheap outline first).
132
137
 
133
138
  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.
139
+ uri (string, required, absolute path), type ("definition"|"references"|"callers"|
140
+ "callees"|"documentSymbols"|"hover"), symbolName (exact name), lineHint (int from a prior
141
+ search result — never guess it).
142
+ An empty result is ambiguous: indexing or unavailable analysis can produce no symbols.
143
+ Retry once or use localSearchCode; an empty LSP result alone does not prove absence.
144
144
 
145
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.
146
+ Full tool output is stored in pages; you see page 1 and the total. Context fitting can shorten
147
+ an observation in the conversation. Request its stored page to recover the evidence without
148
+ re-running the tool. Claim "not found" only after checking the relevant search scope and pages.
149
149
 
150
150
  Finish with: {{"action":"finish","answer":"concise final answer with repository-relative evidence"}}
151
151
 
@@ -155,24 +155,21 @@ is rejected and costs one bounded repair turn.
155
155
 
156
156
  SCOPE YOUR SEARCHES. Pass excludeDir/exclude on every search in a real workspace —
157
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
158
+ vendored and generated files will bury the answer. For implementation questions, prefer src/,
159
+ packages/*/src/ and lib/ before fixtures, mocks, or examples. If the question targets tests or examples,
160
+ inspect those files. Name the file that supplies the answer. Absence of a match is weak evidence: widen the pattern before concluding
161
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.
162
+ For a production default, find its declaration before relying on test occurrences. Declarations look like
163
+ `default=`, `unwrap_or(`, `const `, `static `, or a settings schema initializer.
164
+ For a test-specific default or fixture, inspect its test declaration.
168
165
 
169
166
  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
167
+ workspace root automatically. Orient cheap (localViewStructure) before reading in
171
168
  full. Be decisive: most tasks need 2-6 tool calls. Never edit or write files — you only have
172
169
  read-only tools. Call finish as soon as the requested facts are established."""
173
170
 
174
171
 
175
- def main() -> int:
172
+ def _main(resources: ExitStack) -> int:
176
173
  model = os.environ.get("FREELLAMA_TARGET_MODEL") or os.environ["FREELLAMA_BENCH_MODEL"]
177
174
  workspace = Path(os.environ["FREELLAMA_BENCH_WORKSPACE"]).resolve()
178
175
  prompt = Path(os.environ["FREELLAMA_BENCH_PROMPT"]).read_text(encoding="utf-8")
@@ -194,15 +191,22 @@ def main() -> int:
194
191
  ]
195
192
  calls: list[dict[str, Any]] = []
196
193
  usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": None, "cache_write_tokens": None}
194
+ cache_usage = PromptCacheUsage()
197
195
  metrics = {"load_ms": 0.0, "prompt_eval_ms": 0.0, "eval_ms": 0.0}
198
196
  execution_receipts: list[dict[str, Any]] = []
199
197
  answer = ""
200
198
  failure: str | None = None
199
+ transport_error: dict[str, Any] | None = None
201
200
  calibration_dir = os.environ.get("FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR", "").strip()
201
+ calibration_model_identity = (
202
+ resolve_model_identity(managed_endpoint or endpoint, model, messages[0]["content"])
203
+ if calibration_dir else {"verified": False, "scope": "persistence_disabled", "identity": ""}
204
+ )
202
205
  context_manager = AgentContextManager(
203
206
  runtime,
204
207
  model=model,
205
- calibration_dir=Path(calibration_dir) if calibration_dir else None,
208
+ calibration_identity=calibration_model_identity["identity"],
209
+ calibration_dir=Path(calibration_dir) if calibration_dir and calibration_model_identity["verified"] else None,
206
210
  )
207
211
  try:
208
212
  messages = context_manager.fit(messages)
@@ -219,9 +223,8 @@ def main() -> int:
219
223
  }
220
224
 
221
225
  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.
226
+ # Retry explicit overload/connection refusals only. Replaying a timeout or an uncertain
227
+ # transport failure can overlap a generation that is still consuming the runner.
225
228
  last_error: Exception | None = None
226
229
  for attempt in range(runtime.retry_attempts):
227
230
  try:
@@ -243,12 +246,16 @@ def main() -> int:
243
246
  )
244
247
  break
245
248
  except (HTTPError, URLError, TimeoutError) as error:
249
+ if not retryable_chat_error(error):
250
+ raise
246
251
  last_error = error
247
252
  if attempt + 1 < runtime.retry_attempts:
248
253
  time.sleep(runtime.retry_backoff_seconds)
249
254
  else:
250
255
  raise last_error # type: ignore[misc]
251
256
  usage["input_tokens"] += int(response.get("prompt_eval_count", 0))
257
+ cache_usage.observe(response)
258
+ usage["cache_read_tokens"] = cache_usage.tokens
252
259
  usage["output_tokens"] += int(response.get("eval_count", 0))
253
260
  metrics["load_ms"] += float(response.get("load_duration", 0)) / 1_000_000
254
261
  metrics["prompt_eval_ms"] += float(response.get("prompt_eval_duration", 0)) / 1_000_000
@@ -266,7 +273,7 @@ def main() -> int:
266
273
  return False
267
274
 
268
275
  seen_calls: dict[str, int] = {}
269
- observations = ObservationStore(runtime.context.observation_page_chars)
276
+ observations = resources.enter_context(ObservationStore(runtime.context.observation_page_chars))
270
277
  parse_failures = 0
271
278
  for _ in range(runtime.max_turns):
272
279
  # Transport failures are terminal (call_model already retried them). A *parse* failure is
@@ -275,7 +282,8 @@ def main() -> int:
275
282
  try:
276
283
  response = call_model()
277
284
  except (HTTPError, URLError, TimeoutError) as error:
278
- failure = f"agent response failed: {type(error).__name__}: {error}"
285
+ diagnostic, transport_error = chat_error_details(error)
286
+ failure = f"agent response failed: {diagnostic}"
279
287
  break
280
288
  raw = str(response.get("message", {}).get("content", ""))
281
289
  try:
@@ -300,9 +308,8 @@ def main() -> int:
300
308
  want_page = int(action.get("page", 1))
301
309
  except (TypeError, ValueError):
302
310
  want_step, want_page = 0, 1
303
- body, footer = observations.view(want_step, want_page)
304
311
  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}"})
312
+ messages.append({"role": "user", "content": observations.message(want_step, want_page)})
306
313
  if not refit():
307
314
  break
308
315
  continue
@@ -335,17 +342,13 @@ def main() -> int:
335
342
  "arguments": {"tool": tool_name, "queries": queries},
336
343
  "status": status,
337
344
  "duration_ms": round((time.perf_counter() - started) * 1000, 3),
338
- "result": observation,
339
345
  })
340
346
  messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
341
347
  remaining = runtime.max_turns - len(calls)
342
348
  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
+ observations.put(step, observation, metadata={"action": tool_name, "status": status, "source": json.dumps(queries, ensure_ascii=False)})
350
+ del observation
351
+ messages.append({"role": "user", "content": observations.message(step) + f"\n\nTool calls remaining: {remaining}. Finish now if the task is answerable; do not repeat prior calls."})
349
352
  if not refit():
350
353
  break
351
354
  else:
@@ -358,7 +361,8 @@ def main() -> int:
358
361
  raise ValueError("forced final response was not finish")
359
362
  answer = str(final_action.get("answer", "")).strip()
360
363
  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}"
364
+ diagnostic, transport_error = chat_error_details(error)
365
+ failure = f"agent exceeded {runtime.max_turns} turns and finalization failed: {diagnostic}"
362
366
 
363
367
  if not answer:
364
368
  answer = failure or "agent stopped without a final answer"
@@ -381,14 +385,20 @@ def main() -> int:
381
385
  "execution_preference": execution_preference,
382
386
  "min_placement_evidence": min_placement_evidence,
383
387
  "execution_receipts": execution_receipts,
384
- "cache_token_metrics": "not_reported_by_ollama",
388
+ **({"transport_error": transport_error} if transport_error else {}),
389
+ "cache_token_metrics": cache_usage.metadata(),
390
+ "calibration_model_identity": calibration_model_identity,
385
391
  },
386
392
  }
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")
393
+ observations.write_result(result_path, result)
389
394
  print(answer)
390
395
  return 1 if failure else 0
391
396
 
392
397
 
398
+ def main() -> int:
399
+ with ExitStack() as resources:
400
+ return _main(resources)
401
+
402
+
393
403
  if __name__ == "__main__":
394
404
  raise SystemExit(main())