@octocodeai/freellama-mcp-server 0.5.0 → 0.6.1
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 +106 -0
- package/DEVELOPING.md +112 -0
- package/LICENSE-MIT +1 -1
- package/README.md +133 -284
- package/dist/index.js +1514 -1409
- package/dist/research/bash-agent.js +2435 -0
- package/dist/research/octocode-agent.js +1639 -0
- package/docs/ARCHITECTURE.md +176 -101
- package/docs/CLI.md +94 -63
- package/docs/CPU_GPU_ROUTING.md +15 -14
- package/docs/MODEL_SELECTION.md +116 -5
- package/docs/MONITORING.md +26 -16
- package/docs/PRODUCTION.md +33 -26
- package/docs/README.md +9 -17
- package/docs/SCOPES_AND_WARMING.md +8 -9
- package/docs/TESTING.md +22 -35
- package/native/index.d.ts +179 -174
- package/native/index.js +3 -3
- package/package.json +12 -10
- package/adapters/agent_context.py +0 -850
- package/adapters/agent_transport.py +0 -215
- package/adapters/bash_agent.py +0 -345
- package/adapters/octocode_agent.py +0 -404
- package/adapters/shell_sandbox.py +0 -487
- package/docs/ECONOMICS.md +0 -110
- package/docs/INDEX.md +0 -21
- package/docs/MODEL_METADATA.md +0 -106
- package/docs/OLLAMA_SIDECAR.md +0 -78
- package/docs/PRODUCT_POSITIONING.md +0 -121
- package/docs/dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md +0 -55
- package/docs/dev/FINDINGS_AND_POSITIONING.md +0 -290
- package/docs/dev/LOCAL_PERFORMANCE_TESTING.md +0 -298
- package/docs/dev/OLLAMA_SYSTEM_OPTIMIZATION.md +0 -255
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
"""Shared Ollama/FreeLlama chat transport for local research adapters."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import hashlib
|
|
7
|
-
import json
|
|
8
|
-
import re
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
11
|
-
from urllib.error import HTTPError, URLError
|
|
12
|
-
from urllib.request import Request, urlopen
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _read_identity_json(url: str) -> dict[str, Any]:
|
|
16
|
-
with urlopen(Request(url, headers=request_headers()), timeout=5) as response:
|
|
17
|
-
payload = response.read(1024 * 1024 + 1)
|
|
18
|
-
if len(payload) > 1024 * 1024:
|
|
19
|
-
raise ValueError("model identity catalog exceeds size limit")
|
|
20
|
-
return json.loads(payload)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def resolve_model_identity(
|
|
24
|
-
endpoint: str, model: str, system_prompt: str, *, read_json=None,
|
|
25
|
-
) -> dict[str, Any]:
|
|
26
|
-
"""Resolve immutable manifest identity once per adapter run, without loading a model.
|
|
27
|
-
|
|
28
|
-
Managed catalogs honor exact CPU backend assignment. Failure disables persistent calibration;
|
|
29
|
-
a mutable tag or a hash of the prompt alone must never masquerade as model identity.
|
|
30
|
-
"""
|
|
31
|
-
endpoint = endpoint.rstrip("/")
|
|
32
|
-
managed_suffix = "/_freellama/v1/tasks"
|
|
33
|
-
url = (endpoint[:-len(managed_suffix)] + "/_freellama/v1/models"
|
|
34
|
-
if endpoint.endswith(managed_suffix) else endpoint + "/api/tags")
|
|
35
|
-
unknown = {"identity": "", "verified": False, "scope": "current_process_only"}
|
|
36
|
-
try:
|
|
37
|
-
payload = (read_json or _read_identity_json)(url)
|
|
38
|
-
entries = payload.get("models", []) if isinstance(payload, dict) else []
|
|
39
|
-
if not isinstance(entries, list):
|
|
40
|
-
return unknown
|
|
41
|
-
names = {model}
|
|
42
|
-
if ":" not in model.rsplit("/", 1)[-1]:
|
|
43
|
-
names.add(model + ":latest")
|
|
44
|
-
entry = next((item for item in entries if isinstance(item, dict)
|
|
45
|
-
and item.get("name", item.get("model")) in names), None)
|
|
46
|
-
digest = entry.get("digest") if entry else None
|
|
47
|
-
if not isinstance(digest, str) or not re.fullmatch(r"(?:sha256:)?[0-9a-fA-F]{64}", digest):
|
|
48
|
-
return unknown
|
|
49
|
-
digest = digest.removeprefix("sha256:").lower()
|
|
50
|
-
contract = {"endpoint": endpoint, "model": model, "digest": digest,
|
|
51
|
-
"system_hash": hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()}
|
|
52
|
-
return {"identity": hashlib.sha256(json.dumps(contract, sort_keys=True).encode()).hexdigest(),
|
|
53
|
-
"verified": True, "scope": "manifest_digest_and_system_prompt", "digest": digest}
|
|
54
|
-
except (OSError, ValueError, TypeError):
|
|
55
|
-
return unknown
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def retryable_chat_error(error: Exception) -> bool:
|
|
59
|
-
"""Retry known refusals only; a timeout may leave inference running upstream."""
|
|
60
|
-
if isinstance(error, HTTPError):
|
|
61
|
-
return error.code == 503
|
|
62
|
-
return isinstance(error, URLError) and isinstance(error.reason, ConnectionRefusedError)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def chat_error_details(error: Exception) -> tuple[str, dict[str, Any] | None]:
|
|
66
|
-
"""Retain bounded application refusal receipts, excluding request/auth payloads."""
|
|
67
|
-
diagnostic = f"{type(error).__name__}: {error}"
|
|
68
|
-
if not isinstance(error, HTTPError):
|
|
69
|
-
return diagnostic, None
|
|
70
|
-
details: dict[str, Any] = {"status": error.code}
|
|
71
|
-
try:
|
|
72
|
-
body = error.read(64 * 1024 + 1)
|
|
73
|
-
if len(body) > 64 * 1024:
|
|
74
|
-
return diagnostic, details
|
|
75
|
-
payload = json.loads(body)
|
|
76
|
-
if not isinstance(payload, dict) or not isinstance(payload.get("error"), str):
|
|
77
|
-
return diagnostic, details
|
|
78
|
-
receipt_fields = {
|
|
79
|
-
"error", "code", "reason", "retry_after_seconds", "resource_admission", "lifecycle",
|
|
80
|
-
"placement", "execution", "admission", "route", "decision", "quality", "feedback",
|
|
81
|
-
}
|
|
82
|
-
payload_fields = {
|
|
83
|
-
"prompt", "system", "system_prompt", "messages", "content", "input", "images", "tools",
|
|
84
|
-
"request", "request_options", "headers", "authorization", "token", "api_key", "auth_token",
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
def receipt_value(value: Any) -> Any:
|
|
88
|
-
if isinstance(value, dict):
|
|
89
|
-
return {key: receipt_value(item) for key, item in value.items() if key.lower() not in payload_fields}
|
|
90
|
-
if isinstance(value, list):
|
|
91
|
-
return [receipt_value(item) for item in value]
|
|
92
|
-
return value
|
|
93
|
-
|
|
94
|
-
receipt = {key: receipt_value(value) for key, value in payload.items() if key in receipt_fields}
|
|
95
|
-
details["receipt"] = receipt
|
|
96
|
-
diagnostic = f"HTTP {error.code}: {receipt['error']}"
|
|
97
|
-
except (OSError, ValueError, RecursionError):
|
|
98
|
-
pass
|
|
99
|
-
finally:
|
|
100
|
-
error.close()
|
|
101
|
-
return diagnostic, details
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class PromptCacheUsage:
|
|
105
|
-
"""Aggregate optional Ollama cache reads without treating unavailable turns as zero."""
|
|
106
|
-
|
|
107
|
-
def __init__(self) -> None:
|
|
108
|
-
self.calls = 0
|
|
109
|
-
self.reported_calls = 0
|
|
110
|
-
self.reported_tokens = 0
|
|
111
|
-
self.reported_prompt_tokens = 0
|
|
112
|
-
|
|
113
|
-
def observe(self, response: dict[str, Any]) -> None:
|
|
114
|
-
self.calls += 1
|
|
115
|
-
cached = response.get("prompt_eval_cached_count")
|
|
116
|
-
prompt = response.get("prompt_eval_count")
|
|
117
|
-
if type(cached) is int and type(prompt) is int and 0 <= cached <= prompt:
|
|
118
|
-
self.reported_calls += 1
|
|
119
|
-
self.reported_tokens += cached
|
|
120
|
-
self.reported_prompt_tokens += prompt
|
|
121
|
-
|
|
122
|
-
@property
|
|
123
|
-
def tokens(self) -> int | None:
|
|
124
|
-
return self.reported_tokens if self.calls > 0 and self.calls == self.reported_calls else None
|
|
125
|
-
|
|
126
|
-
def metadata(self) -> dict[str, Any]:
|
|
127
|
-
return {
|
|
128
|
-
"status": "reported" if self.tokens is not None else (
|
|
129
|
-
"partially_reported" if self.reported_calls else "not_reported"
|
|
130
|
-
),
|
|
131
|
-
"calls": self.calls,
|
|
132
|
-
"reported_calls": self.reported_calls,
|
|
133
|
-
"reported_tokens": self.reported_tokens if self.reported_calls else None,
|
|
134
|
-
"hit_ratio": self.reported_tokens / self.reported_prompt_tokens
|
|
135
|
-
if self.tokens is not None and self.reported_prompt_tokens else None,
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def request_headers() -> dict[str, str]:
|
|
140
|
-
"""Build transport headers, reading a bearer token from a file rather than process args."""
|
|
141
|
-
headers = {"content-type": "application/json"}
|
|
142
|
-
token_file = os.environ.get("FREELLAMA_AUTH_TOKEN_FILE", "").strip()
|
|
143
|
-
if not token_file:
|
|
144
|
-
return headers
|
|
145
|
-
token = Path(token_file).read_text(encoding="utf-8").strip()
|
|
146
|
-
if len(token) < 32 or any(character.isspace() for character in token):
|
|
147
|
-
raise ValueError("FREELLAMA_AUTH_TOKEN_FILE must contain one token of at least 32 bytes")
|
|
148
|
-
headers["authorization"] = f"Bearer {token}"
|
|
149
|
-
return headers
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def chat_request(
|
|
153
|
-
endpoint: str,
|
|
154
|
-
model: str,
|
|
155
|
-
messages: list[dict[str, Any]],
|
|
156
|
-
options: dict[str, Any],
|
|
157
|
-
think: bool,
|
|
158
|
-
keep_alive: str,
|
|
159
|
-
execution_preference: str = "auto",
|
|
160
|
-
min_placement_evidence: str = "configured",
|
|
161
|
-
) -> tuple[str, dict[str, Any]]:
|
|
162
|
-
"""Build one direct-Ollama or managed-FreeLlama request.
|
|
163
|
-
|
|
164
|
-
`endpoint` selects the transport: a URL ending in `/_freellama/v1/tasks` is managed. The
|
|
165
|
-
benchmark can still target raw Ollama for controlled comparisons; MCP always supplies the
|
|
166
|
-
managed URL so coding-agent turns share routing, admission, physical-placement receipts, and
|
|
167
|
-
adaptive feedback with ordinary `run_task` calls.
|
|
168
|
-
"""
|
|
169
|
-
endpoint = endpoint.rstrip("/")
|
|
170
|
-
if endpoint.endswith("/_freellama/v1/tasks"):
|
|
171
|
-
managed_options = {key: value for key, value in options.items() if key != "num_ctx"}
|
|
172
|
-
return endpoint, {
|
|
173
|
-
"task": "coding",
|
|
174
|
-
"objective": "fastest",
|
|
175
|
-
"model": model,
|
|
176
|
-
"context_tokens": options["num_ctx"],
|
|
177
|
-
"execution_preference": execution_preference,
|
|
178
|
-
"min_placement_evidence": min_placement_evidence,
|
|
179
|
-
"messages": messages,
|
|
180
|
-
"keep_alive": keep_alive,
|
|
181
|
-
"request_options": {
|
|
182
|
-
"format": "json",
|
|
183
|
-
"think": think,
|
|
184
|
-
"options": managed_options,
|
|
185
|
-
},
|
|
186
|
-
}
|
|
187
|
-
return f"{endpoint}/api/chat", {
|
|
188
|
-
"model": model,
|
|
189
|
-
"messages": messages,
|
|
190
|
-
"stream": False,
|
|
191
|
-
"truncate": False,
|
|
192
|
-
"shift": False,
|
|
193
|
-
"format": "json",
|
|
194
|
-
"think": think,
|
|
195
|
-
"keep_alive": keep_alive,
|
|
196
|
-
"options": options,
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def unwrap_chat_response(
|
|
201
|
-
payload: dict[str, Any], execution_receipts: list[dict[str, Any]]
|
|
202
|
-
) -> dict[str, Any]:
|
|
203
|
-
"""Return the Ollama response and retain managed execution proof when present."""
|
|
204
|
-
response = payload.get("response")
|
|
205
|
-
if not isinstance(response, dict):
|
|
206
|
-
return payload
|
|
207
|
-
execution_receipts.append(
|
|
208
|
-
{
|
|
209
|
-
"model": payload.get("route", {}).get("selected_model"),
|
|
210
|
-
"execution": payload.get("execution"),
|
|
211
|
-
"admission": payload.get("admission"),
|
|
212
|
-
"feedback": payload.get("feedback"),
|
|
213
|
-
}
|
|
214
|
-
)
|
|
215
|
-
return response
|
package/adapters/bash_agent.py
DELETED
|
@@ -1,345 +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
|
-
from contextlib import ExitStack
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
import subprocess
|
|
15
|
-
import time
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Any
|
|
18
|
-
from urllib.error import HTTPError, URLError
|
|
19
|
-
from urllib.request import Request, urlopen
|
|
20
|
-
|
|
21
|
-
from agent_context import (
|
|
22
|
-
AgentContextManager,
|
|
23
|
-
AgentRuntimeConfig,
|
|
24
|
-
PARSE_REPAIR_NOTICE,
|
|
25
|
-
REPEAT_NOTICE,
|
|
26
|
-
ObservationStore,
|
|
27
|
-
call_signature,
|
|
28
|
-
parse_json_action,
|
|
29
|
-
write_failure_result,
|
|
30
|
-
)
|
|
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
|
-
|
|
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
|
-
# 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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def request_json(url: str, payload: dict[str, Any], timeout_seconds: float) -> dict[str, Any]:
|
|
46
|
-
request = Request(url, data=json.dumps(payload).encode(), headers=request_headers(), method="POST")
|
|
47
|
-
with urlopen(request, timeout=timeout_seconds) as response:
|
|
48
|
-
return json.loads(response.read())
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def parse_action(content: str) -> dict[str, Any]:
|
|
52
|
-
value = parse_json_action(content)
|
|
53
|
-
action = value["action"]
|
|
54
|
-
if action == "shell" and (not isinstance(value.get("command"), str) or not value["command"].strip()):
|
|
55
|
-
raise ValueError("shell action requires a non-empty string command")
|
|
56
|
-
if action == "page" and (
|
|
57
|
-
not isinstance(value.get("step"), int)
|
|
58
|
-
or value["step"] < 1
|
|
59
|
-
or not isinstance(value.get("page"), int)
|
|
60
|
-
or value["page"] < 1
|
|
61
|
-
):
|
|
62
|
-
raise ValueError("page action requires positive integer step and page")
|
|
63
|
-
if action == "finish" and not isinstance(value.get("answer"), str):
|
|
64
|
-
raise ValueError("finish action requires a string answer")
|
|
65
|
-
if action not in {"shell", "page", "finish"}:
|
|
66
|
-
raise ValueError(f"unsupported action for bash adapter: {action}")
|
|
67
|
-
return value
|
|
68
|
-
|
|
69
|
-
|
|
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)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def system_prompt() -> str:
|
|
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:
|
|
79
|
-
|
|
80
|
-
{"action":"shell","command":"one shell command, e.g. grep -n \\"class Group\\" click/src/click/core.py"}
|
|
81
|
-
{"action":"page","step":2,"page":2}
|
|
82
|
-
{"action":"finish","answer":"concise final answer with repository-relative evidence"}
|
|
83
|
-
|
|
84
|
-
These are the complete action schemas. Use only `shell`, `page`, or `finish`; every shown field is
|
|
85
|
-
required with the shown type. An invalid shape is rejected and costs one bounded repair turn.
|
|
86
|
-
|
|
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.
|
|
90
|
-
|
|
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.
|
|
100
|
-
|
|
101
|
-
SCOPE YOUR SEARCHES. A real workspace holds far more than its source, and an unscoped grep drowns
|
|
102
|
-
the answer in vendored and generated files. Always exclude them:
|
|
103
|
-
grep -rn "PATTERN" . --exclude-dir={node_modules,target,.venv,venv,dist,build,.git,vendor,__pycache__,.octocode,site-packages}
|
|
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.
|
|
107
|
-
If a search returns nothing, widen the pattern before concluding the thing does not exist: absence
|
|
108
|
-
of a grep hit is weak evidence, and "not found" is only a real answer once you have looked in the
|
|
109
|
-
source directories.
|
|
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."""
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _main(resources: ExitStack) -> int:
|
|
116
|
-
model = os.environ.get("FREELLAMA_TARGET_MODEL") or os.environ["FREELLAMA_BENCH_MODEL"]
|
|
117
|
-
workspace = Path(os.environ["FREELLAMA_BENCH_WORKSPACE"]).resolve()
|
|
118
|
-
prompt = Path(os.environ["FREELLAMA_BENCH_PROMPT"]).read_text(encoding="utf-8")
|
|
119
|
-
result_path = Path(os.environ["FREELLAMA_AGENT_RESULT"])
|
|
120
|
-
endpoint = os.environ.get("FREELLAMA_OLLAMA_ENDPOINT", "http://127.0.0.1:11434").rstrip("/")
|
|
121
|
-
managed_endpoint = os.environ.get("FREELLAMA_AGENT_MANAGED_ENDPOINT", "").rstrip("/")
|
|
122
|
-
execution_preference = os.environ.get("FREELLAMA_AGENT_EXECUTION_PREFERENCE", "auto")
|
|
123
|
-
min_placement_evidence = os.environ.get("FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE", "configured")
|
|
124
|
-
try:
|
|
125
|
-
runtime = AgentRuntimeConfig.from_env(default_tool_timeout_seconds=30)
|
|
126
|
-
except ValueError as error:
|
|
127
|
-
answer = f"invalid research adapter configuration: {error}"
|
|
128
|
-
write_failure_result(result_path, answer)
|
|
129
|
-
print(answer)
|
|
130
|
-
return 1
|
|
131
|
-
messages: list[dict[str, Any]] = [
|
|
132
|
-
{"role": "system", "content": system_prompt()},
|
|
133
|
-
{"role": "user", "content": prompt},
|
|
134
|
-
]
|
|
135
|
-
calls: list[dict[str, Any]] = []
|
|
136
|
-
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": None, "cache_write_tokens": None}
|
|
137
|
-
cache_usage = PromptCacheUsage()
|
|
138
|
-
metrics = {"load_ms": 0.0, "prompt_eval_ms": 0.0, "eval_ms": 0.0}
|
|
139
|
-
execution_receipts: list[dict[str, Any]] = []
|
|
140
|
-
answer = ""
|
|
141
|
-
failure: str | None = None
|
|
142
|
-
transport_error: dict[str, Any] | None = None
|
|
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
|
-
)
|
|
148
|
-
context_manager = AgentContextManager(
|
|
149
|
-
runtime,
|
|
150
|
-
model=model,
|
|
151
|
-
calibration_identity=calibration_model_identity["identity"],
|
|
152
|
-
calibration_dir=Path(calibration_dir) if calibration_dir and calibration_model_identity["verified"] else None,
|
|
153
|
-
)
|
|
154
|
-
try:
|
|
155
|
-
messages = context_manager.fit(messages)
|
|
156
|
-
except ValueError as error:
|
|
157
|
-
answer = f"research task does not fit the configured context safely: {error}"
|
|
158
|
-
write_failure_result(result_path, answer)
|
|
159
|
-
print(answer)
|
|
160
|
-
return 1
|
|
161
|
-
chat_options = {
|
|
162
|
-
"temperature": runtime.temperature,
|
|
163
|
-
"seed": runtime.seed,
|
|
164
|
-
"num_ctx": runtime.num_ctx,
|
|
165
|
-
"num_predict": runtime.num_predict,
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
def call_model() -> dict[str, Any]:
|
|
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.
|
|
171
|
-
last_error: Exception | None = None
|
|
172
|
-
for attempt in range(runtime.retry_attempts):
|
|
173
|
-
try:
|
|
174
|
-
transport_endpoint = (
|
|
175
|
-
f"{managed_endpoint}/_freellama/v1/tasks" if managed_endpoint else endpoint
|
|
176
|
-
)
|
|
177
|
-
url, payload = chat_request(
|
|
178
|
-
transport_endpoint,
|
|
179
|
-
model,
|
|
180
|
-
messages,
|
|
181
|
-
chat_options,
|
|
182
|
-
runtime.think,
|
|
183
|
-
runtime.keep_alive,
|
|
184
|
-
execution_preference,
|
|
185
|
-
min_placement_evidence,
|
|
186
|
-
)
|
|
187
|
-
response = unwrap_chat_response(
|
|
188
|
-
request_json(url, payload, runtime.request_timeout_seconds), execution_receipts
|
|
189
|
-
)
|
|
190
|
-
break
|
|
191
|
-
except (HTTPError, URLError, TimeoutError) as error:
|
|
192
|
-
if not retryable_chat_error(error):
|
|
193
|
-
raise
|
|
194
|
-
last_error = error
|
|
195
|
-
if attempt + 1 < runtime.retry_attempts:
|
|
196
|
-
time.sleep(runtime.retry_backoff_seconds)
|
|
197
|
-
else:
|
|
198
|
-
raise last_error # type: ignore[misc]
|
|
199
|
-
usage["input_tokens"] += int(response.get("prompt_eval_count", 0))
|
|
200
|
-
cache_usage.observe(response)
|
|
201
|
-
usage["cache_read_tokens"] = cache_usage.tokens
|
|
202
|
-
usage["output_tokens"] += int(response.get("eval_count", 0))
|
|
203
|
-
metrics["load_ms"] += float(response.get("load_duration", 0)) / 1_000_000
|
|
204
|
-
metrics["prompt_eval_ms"] += float(response.get("prompt_eval_duration", 0)) / 1_000_000
|
|
205
|
-
metrics["eval_ms"] += float(response.get("eval_duration", 0)) / 1_000_000
|
|
206
|
-
context_manager.observe(messages, response.get("prompt_eval_count"))
|
|
207
|
-
return response
|
|
208
|
-
|
|
209
|
-
def refit() -> bool:
|
|
210
|
-
nonlocal messages, failure
|
|
211
|
-
try:
|
|
212
|
-
messages = context_manager.fit(messages)
|
|
213
|
-
return True
|
|
214
|
-
except ValueError as error:
|
|
215
|
-
failure = f"research context cannot be fitted safely: {error}"
|
|
216
|
-
return False
|
|
217
|
-
|
|
218
|
-
seen_calls: dict[str, int] = {}
|
|
219
|
-
observations = resources.enter_context(ObservationStore(runtime.context.observation_page_chars))
|
|
220
|
-
shell = resources.enter_context(ReadOnlyShell(workspace))
|
|
221
|
-
parse_failures = 0
|
|
222
|
-
for _ in range(runtime.max_turns):
|
|
223
|
-
# Transport failures are terminal (call_model already retried them). A *parse* failure is
|
|
224
|
-
# not: tell the model what was wrong with its reply and let it correct itself, keeping every
|
|
225
|
-
# tool result gathered so far.
|
|
226
|
-
try:
|
|
227
|
-
response = call_model()
|
|
228
|
-
except (HTTPError, URLError, TimeoutError) as error:
|
|
229
|
-
diagnostic, transport_error = chat_error_details(error)
|
|
230
|
-
failure = f"agent response failed: {diagnostic}"
|
|
231
|
-
break
|
|
232
|
-
raw = str(response.get("message", {}).get("content", ""))
|
|
233
|
-
try:
|
|
234
|
-
action = parse_action(raw)
|
|
235
|
-
except (ValueError, json.JSONDecodeError) as error:
|
|
236
|
-
parse_failures += 1
|
|
237
|
-
if parse_failures > runtime.max_parse_repairs:
|
|
238
|
-
failure = f"agent gave {parse_failures} unparseable replies: {type(error).__name__}: {error}"
|
|
239
|
-
break
|
|
240
|
-
messages.append({"role": "assistant", "content": raw[: runtime.parse_repair_echo_chars]})
|
|
241
|
-
messages.append({"role": "user", "content": PARSE_REPAIR_NOTICE})
|
|
242
|
-
if not refit():
|
|
243
|
-
break
|
|
244
|
-
continue
|
|
245
|
-
parse_failures = 0
|
|
246
|
-
# Paging re-serves stored output. It costs a turn but never a subprocess, and no byte of
|
|
247
|
-
# any observation is ever unreachable — which is the whole point of paginating instead of
|
|
248
|
-
# clipping.
|
|
249
|
-
if action["action"] == "page":
|
|
250
|
-
try:
|
|
251
|
-
want_step = int(action.get("step", 0))
|
|
252
|
-
want_page = int(action.get("page", 1))
|
|
253
|
-
except (TypeError, ValueError):
|
|
254
|
-
want_step, want_page = 0, 1
|
|
255
|
-
messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
|
|
256
|
-
messages.append({"role": "user", "content": observations.message(want_step, want_page)})
|
|
257
|
-
if not refit():
|
|
258
|
-
break
|
|
259
|
-
continue
|
|
260
|
-
if action["action"] == "finish":
|
|
261
|
-
answer = str(action.get("answer", "")).strip()
|
|
262
|
-
break
|
|
263
|
-
started = time.perf_counter()
|
|
264
|
-
command_text = str(action.get("command", ""))
|
|
265
|
-
# An exact repeat costs a turn out of ten AND a second copy of an observation the model has
|
|
266
|
-
# already seen. Answer it from the prior step instead of re-running the command.
|
|
267
|
-
signature = call_signature({"command": command_text})
|
|
268
|
-
if signature in seen_calls:
|
|
269
|
-
observation = REPEAT_NOTICE
|
|
270
|
-
status = "repeat"
|
|
271
|
-
else:
|
|
272
|
-
try:
|
|
273
|
-
observation = run_shell(workspace, command_text, runtime.tool_timeout_seconds, shell)
|
|
274
|
-
status = "ok"
|
|
275
|
-
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
276
|
-
observation = f"tool error: {type(error).__name__}: {error}"
|
|
277
|
-
status = "error"
|
|
278
|
-
seen_calls[signature] = len(calls) + 1
|
|
279
|
-
calls.append({
|
|
280
|
-
"name": "shell",
|
|
281
|
-
"raw_name": action.get("action", ""),
|
|
282
|
-
"arguments": {"command": command_text},
|
|
283
|
-
"status": status,
|
|
284
|
-
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
285
|
-
})
|
|
286
|
-
messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
|
|
287
|
-
remaining = runtime.max_turns - len(calls)
|
|
288
|
-
step = len(calls)
|
|
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."})
|
|
292
|
-
if not refit():
|
|
293
|
-
break
|
|
294
|
-
else:
|
|
295
|
-
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."})
|
|
296
|
-
if refit():
|
|
297
|
-
try:
|
|
298
|
-
response = call_model()
|
|
299
|
-
final_action = parse_action(str(response.get("message", {}).get("content", "")))
|
|
300
|
-
if final_action.get("action") != "finish":
|
|
301
|
-
raise ValueError("forced final response was not finish")
|
|
302
|
-
answer = str(final_action.get("answer", "")).strip()
|
|
303
|
-
except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as error:
|
|
304
|
-
diagnostic, transport_error = chat_error_details(error)
|
|
305
|
-
failure = f"agent exceeded {runtime.max_turns} turns and finalization failed: {diagnostic}"
|
|
306
|
-
|
|
307
|
-
if not answer:
|
|
308
|
-
answer = failure or "agent stopped without a final answer"
|
|
309
|
-
result = {
|
|
310
|
-
"final_answer": answer,
|
|
311
|
-
"tool_calls": calls,
|
|
312
|
-
"usage": usage,
|
|
313
|
-
"provider_metrics": metrics,
|
|
314
|
-
"model_metadata": {
|
|
315
|
-
"adapter": "bash_shell_agent_v1",
|
|
316
|
-
"sandbox": shell.kind,
|
|
317
|
-
"temperature": chat_options["temperature"],
|
|
318
|
-
"seed": chat_options["seed"],
|
|
319
|
-
"num_ctx": chat_options["num_ctx"],
|
|
320
|
-
"num_predict": chat_options["num_predict"],
|
|
321
|
-
"max_turns": runtime.max_turns,
|
|
322
|
-
"runtime_config": runtime.metadata(),
|
|
323
|
-
"context_management": context_manager.metadata(),
|
|
324
|
-
"context_compactions": context_manager.compactions,
|
|
325
|
-
"transport": "managed" if managed_endpoint else "direct_ollama",
|
|
326
|
-
"execution_preference": execution_preference,
|
|
327
|
-
"min_placement_evidence": min_placement_evidence,
|
|
328
|
-
"execution_receipts": execution_receipts,
|
|
329
|
-
**({"transport_error": transport_error} if transport_error else {}),
|
|
330
|
-
"cache_token_metrics": cache_usage.metadata(),
|
|
331
|
-
"calibration_model_identity": calibration_model_identity,
|
|
332
|
-
},
|
|
333
|
-
}
|
|
334
|
-
observations.write_result(result_path, result)
|
|
335
|
-
print(answer)
|
|
336
|
-
return 1 if failure else 0
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def main() -> int:
|
|
340
|
-
with ExitStack() as resources:
|
|
341
|
-
return _main(resources)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if __name__ == "__main__":
|
|
345
|
-
raise SystemExit(main())
|