@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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +18 -0
- package/README.md +203 -33
- package/adapters/agent_context.py +216 -29
- package/adapters/agent_transport.py +131 -0
- package/adapters/bash_agent.py +67 -92
- package/adapters/octocode_agent.py +63 -53
- package/adapters/shell_sandbox.py +487 -0
- package/assets/logo.jpg +0 -0
- package/dist/index.js +1287 -412
- package/docs/ARCHITECTURE.md +169 -26
- package/docs/CLI.md +107 -17
- package/docs/CPU_GPU_ROUTING.md +65 -20
- package/docs/ECONOMICS.md +20 -15
- package/docs/INDEX.md +8 -2
- package/docs/MODEL_METADATA.md +106 -0
- package/docs/MODEL_SELECTION.md +15 -2
- package/docs/MONITORING.md +285 -0
- package/docs/OLLAMA_SIDECAR.md +8 -6
- package/docs/PRODUCTION.md +49 -18
- package/docs/PRODUCT_POSITIONING.md +86 -256
- package/docs/README.md +40 -0
- package/docs/SCOPES_AND_WARMING.md +226 -0
- package/docs/TESTING.md +118 -1
- package/docs/dev/FINDINGS_AND_POSITIONING.md +290 -0
- package/docs/dev/LOCAL_PERFORMANCE_TESTING.md +298 -0
- package/docs/{OLLAMA_SYSTEM_OPTIMIZATION.md → dev/OLLAMA_SYSTEM_OPTIMIZATION.md} +19 -18
- package/native/index.d.ts +31 -0
- package/package.json +13 -10
- /package/docs/{ADR_RESOURCE_AWARE_BACKEND_ROUTING.md → dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md} +0 -0
package/adapters/bash_agent.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
203
|
-
#
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
"callees"|"documentSymbols"|"hover"
|
|
136
|
-
|
|
137
|
-
An
|
|
138
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
223
|
-
#
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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())
|