@octocodeai/freellama-mcp-server 0.1.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +108 -0
- package/DEVELOPING.md +109 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +18 -0
- package/README.md +261 -243
- package/assets/logo.jpg +0 -0
- package/dist/index.js +2305 -1332
- package/dist/research/bash-agent.js +2435 -0
- package/dist/research/octocode-agent.js +1639 -0
- package/docs/ARCHITECTURE.md +294 -87
- package/docs/CLI.md +171 -53
- package/docs/CPU_GPU_ROUTING.md +69 -23
- package/docs/MODEL_SELECTION.md +126 -3
- package/docs/MONITORING.md +293 -0
- package/docs/PRODUCTION.md +53 -25
- package/docs/README.md +36 -0
- package/docs/SCOPES_AND_WARMING.md +225 -0
- package/docs/TESTING.md +113 -8
- package/native/index.d.ts +186 -150
- package/native/index.js +3 -3
- package/package.json +15 -11
- package/adapters/agent_context.py +0 -663
- package/adapters/agent_transport.py +0 -84
- package/adapters/bash_agent.py +0 -370
- package/adapters/octocode_agent.py +0 -394
- package/docs/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md +0 -55
- package/docs/ECONOMICS.md +0 -105
- package/docs/INDEX.md +0 -15
- package/docs/OLLAMA_SIDECAR.md +0 -76
- package/docs/OLLAMA_SYSTEM_OPTIMIZATION.md +0 -254
- package/docs/PRODUCT_POSITIONING.md +0 -291
|
@@ -1,84 +0,0 @@
|
|
|
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
|
package/adapters/bash_agent.py
DELETED
|
@@ -1,370 +0,0 @@
|
|
|
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())
|