@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
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
// src/research/octocode-agent.ts
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
// src/research/adapter.ts
|
|
5
|
+
function isPositiveInt(value) {
|
|
6
|
+
return (typeof value === "boolean" || typeof value === "number" && Number.isInteger(value)) && Number(value) >= 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/research/agent-loop.ts
|
|
10
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
11
|
+
import { performance } from "node:perf_hooks";
|
|
12
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
13
|
+
|
|
14
|
+
// src/research/canonical-json.ts
|
|
15
|
+
function canonicalJson(value, options = {}) {
|
|
16
|
+
const { sortKeys = true, ensureAscii = true, separators: [item, colon] = [", ", ": "], floatKeys } = options;
|
|
17
|
+
const quote = (text) => {
|
|
18
|
+
const json = JSON.stringify(text);
|
|
19
|
+
return ensureAscii ? json.replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`) : json;
|
|
20
|
+
};
|
|
21
|
+
const render = (node, float) => {
|
|
22
|
+
if (node === null) return "null";
|
|
23
|
+
if (typeof node === "string") return quote(node);
|
|
24
|
+
if (typeof node === "boolean") return String(node);
|
|
25
|
+
if (typeof node === "number") return numberText(node, float);
|
|
26
|
+
if (Array.isArray(node)) return `[${node.map((entry) => render(entry, false)).join(item)}]`;
|
|
27
|
+
if (typeof node === "object") {
|
|
28
|
+
const record = node;
|
|
29
|
+
const keys = Object.keys(record).filter((key) => record[key] !== void 0);
|
|
30
|
+
if (sortKeys) keys.sort();
|
|
31
|
+
return `{${keys.map((key) => `${quote(key)}${colon}${render(record[key], floatKeys?.has(key) ?? false)}`).join(item)}}`;
|
|
32
|
+
}
|
|
33
|
+
throw new TypeError(`cannot serialize a ${typeof node} as JSON`);
|
|
34
|
+
};
|
|
35
|
+
return render(value, false);
|
|
36
|
+
}
|
|
37
|
+
function numberText(value, float) {
|
|
38
|
+
if (!Number.isFinite(value)) throw new RangeError(`cannot serialize ${value} as JSON`);
|
|
39
|
+
if (Number.isInteger(value) && !float) return Math.abs(value) < 1e21 ? String(value) : BigInt(value).toString();
|
|
40
|
+
const [mantissa, power] = value.toExponential().split("e");
|
|
41
|
+
const exponent = Number(power);
|
|
42
|
+
if (value !== 0 && (exponent < -4 || exponent >= 16)) {
|
|
43
|
+
return `${mantissa}e${exponent < 0 ? "-" : "+"}${String(Math.abs(exponent)).padStart(2, "0")}`;
|
|
44
|
+
}
|
|
45
|
+
const text = String(value);
|
|
46
|
+
return Number.isInteger(value) ? `${text}.0` : text;
|
|
47
|
+
}
|
|
48
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
|
|
50
|
+
// src/research/errors.ts
|
|
51
|
+
var ValidationError = class extends Error {
|
|
52
|
+
name = "ValidationError";
|
|
53
|
+
};
|
|
54
|
+
function isSystemError(error) {
|
|
55
|
+
return error instanceof Error && typeof error.errno === "number";
|
|
56
|
+
}
|
|
57
|
+
var ToolTimeoutError = class extends Error {
|
|
58
|
+
name = "ToolTimeoutError";
|
|
59
|
+
};
|
|
60
|
+
function isToolError(error) {
|
|
61
|
+
return error instanceof ValidationError || error instanceof ToolTimeoutError || error instanceof SyntaxError || isSystemError(error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/research/actions.ts
|
|
65
|
+
function objectAt(text, start) {
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let inString = false;
|
|
68
|
+
for (let index = start; index < text.length; index += 1) {
|
|
69
|
+
const char = text[index];
|
|
70
|
+
if (inString) {
|
|
71
|
+
if (char === "\\") index += 1;
|
|
72
|
+
else if (char === '"') inString = false;
|
|
73
|
+
} else if (char === '"') {
|
|
74
|
+
inString = true;
|
|
75
|
+
} else if (char === "{" || char === "[") {
|
|
76
|
+
depth += 1;
|
|
77
|
+
} else if (char === "}" || char === "]") {
|
|
78
|
+
depth -= 1;
|
|
79
|
+
if (depth === 0) return JSON.parse(text.slice(start, index + 1));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new SyntaxError("unterminated JSON object");
|
|
83
|
+
}
|
|
84
|
+
function parseJsonAction(content) {
|
|
85
|
+
let text = content.trim();
|
|
86
|
+
if (text.startsWith("```")) text = text.split(/\r\n|\r|\n/).slice(1, -1).join("\n").trim();
|
|
87
|
+
let value;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(text);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
92
|
+
const start = text.indexOf("{");
|
|
93
|
+
if (start < 0) throw new ValidationError("model did not return a JSON action");
|
|
94
|
+
try {
|
|
95
|
+
value = objectAt(text, start);
|
|
96
|
+
} catch (inner) {
|
|
97
|
+
if (!(inner instanceof SyntaxError)) throw inner;
|
|
98
|
+
throw new ValidationError("model did not return a valid JSON action");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!isRecord(value) || typeof value.action !== "string") throw new ValidationError("model action must be a JSON object with an action");
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function callSignature(payload) {
|
|
105
|
+
return canonicalJson(payload, { ensureAscii: false });
|
|
106
|
+
}
|
|
107
|
+
var REPEAT_NOTICE = "(identical call already made this run \u2014 its result is above and was not re-executed. Use a different call, or finish now with the evidence already collected.)";
|
|
108
|
+
var PARSE_REPAIR_NOTICE = "Your last reply was not a valid JSON action, so it was discarded. Reply with EXACTLY one JSON object and nothing else \u2014 no prose before or after, no markdown fence. Use the action shapes given in your instructions. If you already have enough evidence, send the finish action now.";
|
|
109
|
+
|
|
110
|
+
// src/research/context-manager.ts
|
|
111
|
+
import { createHash } from "node:crypto";
|
|
112
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
113
|
+
import path from "node:path";
|
|
114
|
+
|
|
115
|
+
// src/research/context-policy.ts
|
|
116
|
+
var CHARS_PER_TOKEN = 4;
|
|
117
|
+
var SAFETY_MARGIN_TOKENS = 256;
|
|
118
|
+
var IMAGE_TOKEN_ESTIMATE = 1024;
|
|
119
|
+
var DEFAULT_KEEP_RECENT = 2;
|
|
120
|
+
var COMPACT_PREVIEW_CHARS = 180;
|
|
121
|
+
var COMPACT_RETAIN_RATIO = 0.8;
|
|
122
|
+
var CLIP_HEAD_RATIO = 2 / 3;
|
|
123
|
+
var OBSERVATION_PAGE_CHARS = 3e3;
|
|
124
|
+
var COMPACTION_TARGET_RATIO = 0.9;
|
|
125
|
+
var MAX_PARSE_REPAIRS = 2;
|
|
126
|
+
var INTEGER = /^[+-]?[0-9]+$/;
|
|
127
|
+
var DECIMAL = /^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
|
|
128
|
+
function envInt(env, name, defaultValue, minimum = 0) {
|
|
129
|
+
const raw = env[name];
|
|
130
|
+
if (raw === void 0 || raw === "") return defaultValue;
|
|
131
|
+
if (!INTEGER.test(raw.trim())) throw new ValidationError(`${name} must be an integer, got ${JSON.stringify(raw)}`);
|
|
132
|
+
const value = Number(raw.trim());
|
|
133
|
+
if (value < minimum) throw new ValidationError(`${name} must be >= ${minimum}, got ${value}`);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
function envFloat(env, name, defaultValue, minimum = 0) {
|
|
137
|
+
const raw = env[name];
|
|
138
|
+
if (raw === void 0 || raw === "") return defaultValue;
|
|
139
|
+
const value = Number(raw.trim());
|
|
140
|
+
if (!DECIMAL.test(raw.trim())) throw new ValidationError(`${name} must be a number, got ${JSON.stringify(raw)}`);
|
|
141
|
+
if (!Number.isFinite(value) || value < minimum) {
|
|
142
|
+
throw new ValidationError(`${name} must be finite and >= ${minimum}, got ${JSON.stringify(raw)}`);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
function envBool(env, name, defaultValue) {
|
|
147
|
+
const raw = env[name];
|
|
148
|
+
if (raw === void 0 || raw === "") return defaultValue;
|
|
149
|
+
const normalized = raw.trim().toLowerCase();
|
|
150
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
151
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
152
|
+
throw new ValidationError(`${name} must be true/false, got ${JSON.stringify(raw)}`);
|
|
153
|
+
}
|
|
154
|
+
function contextBudget(numCtx, numPredict, safetyMarginTokens = SAFETY_MARGIN_TOKENS) {
|
|
155
|
+
return Math.max(numCtx - numPredict - safetyMarginTokens, 0);
|
|
156
|
+
}
|
|
157
|
+
var ContextPolicy = class _ContextPolicy {
|
|
158
|
+
fields;
|
|
159
|
+
/**
|
|
160
|
+
* Fields configured from the environment. They are floats, so canonical JSON (calibration keys)
|
|
161
|
+
* renders them as floats: `FREELLAMA_AGENT_CHARS_PER_TOKEN=8` gives `8.0`, the default gives `4`.
|
|
162
|
+
*/
|
|
163
|
+
floatKeys;
|
|
164
|
+
constructor(fields = {}, floatKeys = []) {
|
|
165
|
+
this.fields = {
|
|
166
|
+
chars_per_token: fields.chars_per_token ?? CHARS_PER_TOKEN,
|
|
167
|
+
safety_margin_tokens: fields.safety_margin_tokens ?? SAFETY_MARGIN_TOKENS,
|
|
168
|
+
image_token_estimate: fields.image_token_estimate ?? IMAGE_TOKEN_ESTIMATE,
|
|
169
|
+
keep_recent: fields.keep_recent ?? DEFAULT_KEEP_RECENT,
|
|
170
|
+
compact_preview_chars: fields.compact_preview_chars ?? COMPACT_PREVIEW_CHARS,
|
|
171
|
+
compact_retain_ratio: fields.compact_retain_ratio ?? COMPACT_RETAIN_RATIO,
|
|
172
|
+
clip_head_ratio: fields.clip_head_ratio ?? CLIP_HEAD_RATIO,
|
|
173
|
+
observation_page_chars: fields.observation_page_chars ?? OBSERVATION_PAGE_CHARS,
|
|
174
|
+
pinned_overflow: fields.pinned_overflow ?? "error"
|
|
175
|
+
};
|
|
176
|
+
this.floatKeys = new Set(floatKeys);
|
|
177
|
+
const f = this.fields;
|
|
178
|
+
if (!Number.isFinite(f.chars_per_token) || f.chars_per_token <= 0) throw new ValidationError("chars_per_token must be finite and > 0");
|
|
179
|
+
if (f.safety_margin_tokens < 0 || f.image_token_estimate < 0 || f.keep_recent < 0) {
|
|
180
|
+
throw new ValidationError("context token estimates and keep_recent must be >= 0");
|
|
181
|
+
}
|
|
182
|
+
if (f.compact_preview_chars <= 0 || f.observation_page_chars <= 0) throw new ValidationError("preview and page sizes must be > 0");
|
|
183
|
+
if (!(f.compact_retain_ratio > 0 && f.compact_retain_ratio < 1)) throw new ValidationError("compact_retain_ratio must be between 0 and 1");
|
|
184
|
+
if (!(f.clip_head_ratio > 0 && f.clip_head_ratio < 1)) throw new ValidationError("clip_head_ratio must be between 0 and 1");
|
|
185
|
+
if (f.pinned_overflow !== "error" && f.pinned_overflow !== "clip") throw new ValidationError("pinned_overflow must be 'error' or 'clip'");
|
|
186
|
+
}
|
|
187
|
+
get chars_per_token() {
|
|
188
|
+
return this.fields.chars_per_token;
|
|
189
|
+
}
|
|
190
|
+
get safety_margin_tokens() {
|
|
191
|
+
return this.fields.safety_margin_tokens;
|
|
192
|
+
}
|
|
193
|
+
get image_token_estimate() {
|
|
194
|
+
return this.fields.image_token_estimate;
|
|
195
|
+
}
|
|
196
|
+
get keep_recent() {
|
|
197
|
+
return this.fields.keep_recent;
|
|
198
|
+
}
|
|
199
|
+
get compact_preview_chars() {
|
|
200
|
+
return this.fields.compact_preview_chars;
|
|
201
|
+
}
|
|
202
|
+
get compact_retain_ratio() {
|
|
203
|
+
return this.fields.compact_retain_ratio;
|
|
204
|
+
}
|
|
205
|
+
get clip_head_ratio() {
|
|
206
|
+
return this.fields.clip_head_ratio;
|
|
207
|
+
}
|
|
208
|
+
get observation_page_chars() {
|
|
209
|
+
return this.fields.observation_page_chars;
|
|
210
|
+
}
|
|
211
|
+
get pinned_overflow() {
|
|
212
|
+
return this.fields.pinned_overflow;
|
|
213
|
+
}
|
|
214
|
+
static fromEnv(env = process.env) {
|
|
215
|
+
const floats = [
|
|
216
|
+
["chars_per_token", "FREELLAMA_AGENT_CHARS_PER_TOKEN"],
|
|
217
|
+
["compact_retain_ratio", "FREELLAMA_AGENT_COMPACT_RETAIN_RATIO"],
|
|
218
|
+
["clip_head_ratio", "FREELLAMA_AGENT_CLIP_HEAD_RATIO"]
|
|
219
|
+
];
|
|
220
|
+
return new _ContextPolicy({
|
|
221
|
+
chars_per_token: envFloat(env, "FREELLAMA_AGENT_CHARS_PER_TOKEN", CHARS_PER_TOKEN, 1e-6),
|
|
222
|
+
safety_margin_tokens: envInt(env, "FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS", SAFETY_MARGIN_TOKENS),
|
|
223
|
+
image_token_estimate: envInt(env, "FREELLAMA_AGENT_IMAGE_TOKEN_ESTIMATE", IMAGE_TOKEN_ESTIMATE),
|
|
224
|
+
keep_recent: envInt(env, "FREELLAMA_AGENT_KEEP_RECENT", DEFAULT_KEEP_RECENT),
|
|
225
|
+
compact_preview_chars: envInt(env, "FREELLAMA_AGENT_COMPACT_PREVIEW_CHARS", COMPACT_PREVIEW_CHARS, 1),
|
|
226
|
+
compact_retain_ratio: envFloat(env, "FREELLAMA_AGENT_COMPACT_RETAIN_RATIO", COMPACT_RETAIN_RATIO, 1e-6),
|
|
227
|
+
clip_head_ratio: envFloat(env, "FREELLAMA_AGENT_CLIP_HEAD_RATIO", CLIP_HEAD_RATIO, 1e-6),
|
|
228
|
+
observation_page_chars: envInt(env, "FREELLAMA_AGENT_OBSERVATION_PAGE_CHARS", OBSERVATION_PAGE_CHARS, 1),
|
|
229
|
+
pinned_overflow: (env.FREELLAMA_AGENT_PINNED_OVERFLOW ?? "error").trim().toLowerCase()
|
|
230
|
+
}, floats.filter(([, name]) => env[name] !== void 0 && env[name] !== "").map(([key]) => key));
|
|
231
|
+
}
|
|
232
|
+
metadata() {
|
|
233
|
+
return { ...this.fields };
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
var AgentRuntimeConfig = class _AgentRuntimeConfig {
|
|
237
|
+
constructor(fields) {
|
|
238
|
+
this.fields = fields;
|
|
239
|
+
}
|
|
240
|
+
fields;
|
|
241
|
+
get max_turns() {
|
|
242
|
+
return this.fields.max_turns;
|
|
243
|
+
}
|
|
244
|
+
get num_ctx() {
|
|
245
|
+
return this.fields.num_ctx;
|
|
246
|
+
}
|
|
247
|
+
get num_predict() {
|
|
248
|
+
return this.fields.num_predict;
|
|
249
|
+
}
|
|
250
|
+
get temperature() {
|
|
251
|
+
return this.fields.temperature;
|
|
252
|
+
}
|
|
253
|
+
get seed() {
|
|
254
|
+
return this.fields.seed;
|
|
255
|
+
}
|
|
256
|
+
get request_timeout_seconds() {
|
|
257
|
+
return this.fields.request_timeout_seconds;
|
|
258
|
+
}
|
|
259
|
+
get retry_attempts() {
|
|
260
|
+
return this.fields.retry_attempts;
|
|
261
|
+
}
|
|
262
|
+
get retry_backoff_seconds() {
|
|
263
|
+
return this.fields.retry_backoff_seconds;
|
|
264
|
+
}
|
|
265
|
+
get tool_timeout_seconds() {
|
|
266
|
+
return this.fields.tool_timeout_seconds;
|
|
267
|
+
}
|
|
268
|
+
get keep_alive() {
|
|
269
|
+
return this.fields.keep_alive;
|
|
270
|
+
}
|
|
271
|
+
get think() {
|
|
272
|
+
return this.fields.think;
|
|
273
|
+
}
|
|
274
|
+
get max_parse_repairs() {
|
|
275
|
+
return this.fields.max_parse_repairs;
|
|
276
|
+
}
|
|
277
|
+
get parse_repair_echo_chars() {
|
|
278
|
+
return this.fields.parse_repair_echo_chars;
|
|
279
|
+
}
|
|
280
|
+
get context() {
|
|
281
|
+
return this.fields.context;
|
|
282
|
+
}
|
|
283
|
+
static fromEnv(options) {
|
|
284
|
+
const env = options.env ?? process.env;
|
|
285
|
+
const context = ContextPolicy.fromEnv(env);
|
|
286
|
+
const config = new _AgentRuntimeConfig({
|
|
287
|
+
max_turns: envInt(env, "FREELLAMA_AGENT_MAX_TURNS", 10, 1),
|
|
288
|
+
num_ctx: envInt(env, "FREELLAMA_AGENT_NUM_CTX", 8192, 1),
|
|
289
|
+
num_predict: envInt(env, "FREELLAMA_AGENT_NUM_PREDICT", 512, 1),
|
|
290
|
+
temperature: envFloat(env, "FREELLAMA_AGENT_TEMPERATURE", 0),
|
|
291
|
+
seed: envInt(env, "FREELLAMA_AGENT_SEED", 42),
|
|
292
|
+
request_timeout_seconds: envFloat(env, "FREELLAMA_AGENT_REQUEST_TIMEOUT_SECONDS", 600, 1e-3),
|
|
293
|
+
retry_attempts: envInt(env, "FREELLAMA_AGENT_RETRY_ATTEMPTS", 2, 1),
|
|
294
|
+
retry_backoff_seconds: envFloat(env, "FREELLAMA_AGENT_RETRY_BACKOFF_SECONDS", 5),
|
|
295
|
+
tool_timeout_seconds: envFloat(env, "FREELLAMA_AGENT_TOOL_TIMEOUT_SECONDS", options.defaultToolTimeoutSeconds, 1e-3),
|
|
296
|
+
keep_alive: (env.FREELLAMA_AGENT_KEEP_ALIVE ?? "5m").trim(),
|
|
297
|
+
think: envBool(env, "FREELLAMA_AGENT_THINK", false),
|
|
298
|
+
max_parse_repairs: envInt(env, "FREELLAMA_AGENT_MAX_PARSE_REPAIRS", MAX_PARSE_REPAIRS, 0),
|
|
299
|
+
parse_repair_echo_chars: envInt(env, "FREELLAMA_AGENT_PARSE_REPAIR_ECHO_CHARS", 500, 1),
|
|
300
|
+
context
|
|
301
|
+
});
|
|
302
|
+
if (contextBudget(config.num_ctx, config.num_predict, context.safety_margin_tokens) <= 0) {
|
|
303
|
+
throw new ValidationError("FREELLAMA_AGENT_NUM_CTX must exceed FREELLAMA_AGENT_NUM_PREDICT plus FREELLAMA_AGENT_SAFETY_MARGIN_TOKENS");
|
|
304
|
+
}
|
|
305
|
+
if (!config.keep_alive) throw new ValidationError("FREELLAMA_AGENT_KEEP_ALIVE must not be empty");
|
|
306
|
+
return config;
|
|
307
|
+
}
|
|
308
|
+
metadata() {
|
|
309
|
+
const { context: _context, ...rest } = this.fields;
|
|
310
|
+
return { ...rest };
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// src/research/text.ts
|
|
315
|
+
var SPACE_CLASS = "\\p{White_Space}\\x1c-\\x1f";
|
|
316
|
+
var SPACE = new RegExp(`^[${SPACE_CLASS}]$`, "u");
|
|
317
|
+
var EDGE_SPACE = new RegExp(`^[${SPACE_CLASS}]+|[${SPACE_CLASS}]+$`, "gu");
|
|
318
|
+
var isSpace = (char) => char !== void 0 && SPACE.test(char);
|
|
319
|
+
var trimSpace = (text) => text.replace(EDGE_SPACE, "");
|
|
320
|
+
var ASTRAL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
|
|
321
|
+
function cpLength(text) {
|
|
322
|
+
if (!ASTRAL.test(text)) return text.length;
|
|
323
|
+
let count = 0;
|
|
324
|
+
for (const _ of text) count += 1;
|
|
325
|
+
return count;
|
|
326
|
+
}
|
|
327
|
+
function cpAdvance(text, start, count) {
|
|
328
|
+
let index = start;
|
|
329
|
+
for (let step = 0; step < count && index < text.length; step += 1) {
|
|
330
|
+
const code = text.charCodeAt(index);
|
|
331
|
+
const pair = code >= 55296 && code <= 56319 && index + 1 < text.length && text.charCodeAt(index + 1) >= 56320 && text.charCodeAt(index + 1) <= 57343;
|
|
332
|
+
index += pair ? 2 : 1;
|
|
333
|
+
}
|
|
334
|
+
return index;
|
|
335
|
+
}
|
|
336
|
+
function cpSlice(text, start, end) {
|
|
337
|
+
if (!ASTRAL.test(text)) return text.slice(start, end);
|
|
338
|
+
const from = cpAdvance(text, 0, start);
|
|
339
|
+
if (end === void 0) return text.slice(from);
|
|
340
|
+
if (end <= start) return "";
|
|
341
|
+
return text.slice(from, cpAdvance(text, from, end - start));
|
|
342
|
+
}
|
|
343
|
+
function cpTail(text, count) {
|
|
344
|
+
const length = cpLength(text);
|
|
345
|
+
return count >= length ? text : cpSlice(text, length - count);
|
|
346
|
+
}
|
|
347
|
+
var UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
348
|
+
function decodeUtf8(bytes, source) {
|
|
349
|
+
try {
|
|
350
|
+
return UTF8.decode(bytes);
|
|
351
|
+
} catch {
|
|
352
|
+
throw new ValidationError(`${source} is not valid UTF-8`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function normalizeNewlines(text) {
|
|
356
|
+
return text.replace(/\r\n?/g, "\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/research/fit.ts
|
|
360
|
+
var COMPACT_MARKER = "[earlier observation compacted to save context";
|
|
361
|
+
var estimateText = (value) => canonicalJson(value, { sortKeys: false, ensureAscii: false });
|
|
362
|
+
function estimateTokens(text, charsPerToken = CHARS_PER_TOKEN) {
|
|
363
|
+
return Math.floor(cpLength(text) / charsPerToken) + 1;
|
|
364
|
+
}
|
|
365
|
+
function messagesTokens(messages, policy = new ContextPolicy()) {
|
|
366
|
+
let total = 0;
|
|
367
|
+
for (const message of messages) {
|
|
368
|
+
const { content = "", images = [], ...metadata } = message;
|
|
369
|
+
total += estimateTokens(typeof content === "string" ? content : estimateText(content), policy.chars_per_token);
|
|
370
|
+
if (Array.isArray(images)) total += images.length * policy.image_token_estimate;
|
|
371
|
+
total += estimateTokens(estimateText(metadata), policy.chars_per_token);
|
|
372
|
+
}
|
|
373
|
+
return total;
|
|
374
|
+
}
|
|
375
|
+
var ContextBudgeter = class {
|
|
376
|
+
policy;
|
|
377
|
+
scale;
|
|
378
|
+
calibration_samples;
|
|
379
|
+
constructor(policy = new ContextPolicy(), options = {}) {
|
|
380
|
+
this.policy = policy;
|
|
381
|
+
this.scale = Math.max(options.initialScale ?? 1, 1);
|
|
382
|
+
this.calibration_samples = Math.max(Math.trunc(options.calibrationSamples ?? 0), 0);
|
|
383
|
+
}
|
|
384
|
+
estimate = (messages) => Math.ceil(messagesTokens(messages, this.policy) * this.scale);
|
|
385
|
+
observe(messages, promptEvalCount) {
|
|
386
|
+
if (typeof promptEvalCount !== "number" || !Number.isFinite(promptEvalCount)) return false;
|
|
387
|
+
const actual = Math.trunc(promptEvalCount);
|
|
388
|
+
if (actual <= 0) return false;
|
|
389
|
+
const baseline = Math.max(messagesTokens(messages, this.policy), 1);
|
|
390
|
+
const previous = this.scale;
|
|
391
|
+
this.scale = Math.max(this.scale, actual / baseline);
|
|
392
|
+
this.calibration_samples += 1;
|
|
393
|
+
return this.scale > previous;
|
|
394
|
+
}
|
|
395
|
+
metadata() {
|
|
396
|
+
return {
|
|
397
|
+
token_counting: this.calibration_samples ? "model_calibrated_estimate" : "configured_estimate",
|
|
398
|
+
estimate_scale: Math.round(this.scale * 1e6) / 1e6,
|
|
399
|
+
calibration_samples: this.calibration_samples
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
function clip(text, limit, headRatio = CLIP_HEAD_RATIO) {
|
|
404
|
+
if (!(headRatio > 0 && headRatio < 1)) throw new ValidationError("head_ratio must be between 0 and 1");
|
|
405
|
+
const length = cpLength(text);
|
|
406
|
+
if (limit <= 0 || length <= limit) return text;
|
|
407
|
+
const marker = (dropped2) => `
|
|
408
|
+
\u2026 [${dropped2} characters elided] \u2026
|
|
409
|
+
`;
|
|
410
|
+
const usable = Math.max(limit - cpLength(marker(length)), 0);
|
|
411
|
+
if (usable === 0) return cpSlice(text, 0, limit);
|
|
412
|
+
const headChars = Math.trunc(usable * headRatio);
|
|
413
|
+
const tailChars = usable - headChars;
|
|
414
|
+
const dropped = length - headChars - tailChars;
|
|
415
|
+
const tail = tailChars ? cpTail(text, tailChars) : "";
|
|
416
|
+
return cpSlice(text, 0, headChars) + marker(dropped) + tail;
|
|
417
|
+
}
|
|
418
|
+
var STEP_HEADER = /^Observation \(step ([0-9]+)\):\n/;
|
|
419
|
+
var LOOP_REMINDER = /\n\n(?:Commands|Tool calls) remaining: [0-9]+\. Finish now if the task is answerable; do not repeat prior (?:commands|calls)\.(?=\n?$)/g;
|
|
420
|
+
var PAGE_FOOTER = /\n\n\[page ([0-9]+) of ([0-9]+) — [0-9]+ characters total, nothing discarded\. /;
|
|
421
|
+
var decimal = (digits) => BigInt(digits).toString();
|
|
422
|
+
function compact(content, policy) {
|
|
423
|
+
if (content.startsWith(COMPACT_MARKER)) return content;
|
|
424
|
+
const stepMatch = STEP_HEADER.exec(content);
|
|
425
|
+
let recovery = "";
|
|
426
|
+
let body = content;
|
|
427
|
+
if (stepMatch) {
|
|
428
|
+
const step = decimal(stepMatch[1]);
|
|
429
|
+
body = content.slice(stepMatch[0].length);
|
|
430
|
+
const newline = body.indexOf("\n");
|
|
431
|
+
const ledgerLine = newline < 0 ? body : body.slice(0, newline);
|
|
432
|
+
const ledger = ledgerLine.startsWith("[evidence ledger: ") && ledgerLine.endsWith("]") ? ledgerLine : "";
|
|
433
|
+
if (ledger && newline >= 0) body = body.slice(newline + 1);
|
|
434
|
+
body = body.replace(LOOP_REMINDER, "");
|
|
435
|
+
const footer = PAGE_FOOTER.exec(body);
|
|
436
|
+
const page = footer ? decimal(footer[1]) : "1";
|
|
437
|
+
const total = footer ? decimal(footer[2]) : "1";
|
|
438
|
+
if (footer) body = body.slice(0, footer.index);
|
|
439
|
+
recovery = `; step ${step}, page ${page} of ${total}; retrieve stored output: {"action":"page","step":${step},"page":${page}}`;
|
|
440
|
+
if (ledger) recovery += `; ${ledger}`;
|
|
441
|
+
}
|
|
442
|
+
const preview = clip(body, policy.compact_preview_chars, policy.clip_head_ratio);
|
|
443
|
+
return `${COMPACT_MARKER}${recovery}] ${preview}`;
|
|
444
|
+
}
|
|
445
|
+
var textContent = (message) => typeof message?.content === "string" ? message.content : null;
|
|
446
|
+
var contentLength = (message) => cpLength(textContent(message) ?? "");
|
|
447
|
+
function widestIndex(indexes, messages) {
|
|
448
|
+
let best;
|
|
449
|
+
let bestLength = -1;
|
|
450
|
+
for (const index of indexes) {
|
|
451
|
+
const length = contentLength(messages[index]);
|
|
452
|
+
if (length > bestLength) {
|
|
453
|
+
best = index;
|
|
454
|
+
bestLength = length;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return best;
|
|
458
|
+
}
|
|
459
|
+
function textCandidates(messages, from, to, minimum) {
|
|
460
|
+
const result = [];
|
|
461
|
+
for (let index = from; index < to; index += 1) {
|
|
462
|
+
if (textContent(messages[index]) !== null && contentLength(messages[index]) > minimum) result.push(index);
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
function fitToContext(messages, options) {
|
|
467
|
+
const policy = options.policy ?? new ContextPolicy();
|
|
468
|
+
const keepRecent = options.keepRecent ?? policy.keep_recent;
|
|
469
|
+
const budget = contextBudget(options.numCtx, options.numPredict, policy.safety_margin_tokens);
|
|
470
|
+
const count = options.tokenCounter ?? ((value) => messagesTokens(value, policy));
|
|
471
|
+
if (count(messages) <= budget) return [messages, false];
|
|
472
|
+
const pinned = Math.min(2, messages.length);
|
|
473
|
+
const trimmed = messages.map((message) => ({ ...message }));
|
|
474
|
+
const observations = [];
|
|
475
|
+
for (let index = pinned; index < trimmed.length; index += 1) {
|
|
476
|
+
const role = trimmed[index].role;
|
|
477
|
+
if ((role === "user" || role === "tool") && textContent(trimmed[index]) !== null) observations.push(index);
|
|
478
|
+
}
|
|
479
|
+
const protectedIndexes = new Set(keepRecent ? observations.slice(-keepRecent) : []);
|
|
480
|
+
let compacted = false;
|
|
481
|
+
const target = Math.floor(budget * COMPACTION_TARGET_RATIO);
|
|
482
|
+
for (const index of observations) {
|
|
483
|
+
if (count(trimmed) <= target) break;
|
|
484
|
+
if (protectedIndexes.has(index)) continue;
|
|
485
|
+
const content = textContent(trimmed[index]);
|
|
486
|
+
const replacement = compact(content, policy);
|
|
487
|
+
if (cpLength(replacement) < cpLength(content)) {
|
|
488
|
+
trimmed[index].content = replacement;
|
|
489
|
+
compacted = true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const shrinkWidest = (candidates) => {
|
|
493
|
+
const widest = widestIndex(candidates, trimmed);
|
|
494
|
+
if (widest === void 0) return false;
|
|
495
|
+
const content = textContent(trimmed[widest]) ?? "";
|
|
496
|
+
const keep = Math.max(Math.trunc(cpLength(content) * policy.compact_retain_ratio), policy.compact_preview_chars);
|
|
497
|
+
if (keep >= cpLength(content)) return false;
|
|
498
|
+
trimmed[widest].content = clip(content, keep, policy.clip_head_ratio);
|
|
499
|
+
compacted = true;
|
|
500
|
+
return true;
|
|
501
|
+
};
|
|
502
|
+
for (let guard = 0; count(trimmed) > budget && guard < 200; guard += 1) {
|
|
503
|
+
if (!shrinkWidest(observations)) break;
|
|
504
|
+
}
|
|
505
|
+
for (const message of trimmed.slice(pinned, Math.max(trimmed.length - 2, pinned))) {
|
|
506
|
+
const thinking = message.thinking;
|
|
507
|
+
if (count(trimmed) <= budget) break;
|
|
508
|
+
if (typeof thinking === "string" && cpLength(thinking) > policy.compact_preview_chars) {
|
|
509
|
+
message.thinking = compact(thinking, policy);
|
|
510
|
+
compacted = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (let guard = 0; count(trimmed) > budget && guard < 200; guard += 1) {
|
|
514
|
+
const candidates = textCandidates(trimmed, pinned, trimmed.length, policy.compact_preview_chars);
|
|
515
|
+
if (candidates.length === 0 || !shrinkWidest(candidates)) break;
|
|
516
|
+
}
|
|
517
|
+
for (let guard = 0; policy.pinned_overflow === "clip" && count(trimmed) > budget && guard < 200; guard += 1) {
|
|
518
|
+
const candidates = textCandidates(trimmed, 0, pinned, policy.compact_preview_chars);
|
|
519
|
+
if (candidates.length === 0 || !shrinkWidest(candidates)) break;
|
|
520
|
+
}
|
|
521
|
+
if (count(trimmed) > budget) {
|
|
522
|
+
throw new ValidationError(
|
|
523
|
+
`pinned system/question content and minimum history exceed context budget: ${count(trimmed)} > ${budget} estimated tokens. Increase FREELLAMA_AGENT_NUM_CTX, reduce the task/instructions, or explicitly set FREELLAMA_AGENT_PINNED_OVERFLOW=clip.`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return [trimmed, compacted];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/research/context-manager.ts
|
|
530
|
+
function timeNs() {
|
|
531
|
+
const subMillis = process.hrtime.bigint() % 1000000n;
|
|
532
|
+
return (BigInt(Date.now()) * 1000000n + subMillis).toString();
|
|
533
|
+
}
|
|
534
|
+
var AgentContextManager = class {
|
|
535
|
+
runtime;
|
|
536
|
+
model;
|
|
537
|
+
calibrationDir;
|
|
538
|
+
calibrationIdentity;
|
|
539
|
+
budgeter;
|
|
540
|
+
calibrationSource;
|
|
541
|
+
compactions = 0;
|
|
542
|
+
constructor(runtime, options = {}) {
|
|
543
|
+
this.runtime = runtime;
|
|
544
|
+
this.model = options.model ?? "";
|
|
545
|
+
this.calibrationDir = options.calibrationDir ?? null;
|
|
546
|
+
this.calibrationIdentity = options.calibrationIdentity ?? "";
|
|
547
|
+
const loaded = this.loadCalibration();
|
|
548
|
+
this.budgeter = new ContextBudgeter(runtime.context, { initialScale: loaded?.scale ?? 1, calibrationSamples: loaded?.samples ?? 0 });
|
|
549
|
+
this.calibrationSource = loaded ? "persistent_model_cache" : "current_process";
|
|
550
|
+
}
|
|
551
|
+
modelKey() {
|
|
552
|
+
return createHash("sha256").update(this.calibrationContractJson(), "utf8").digest("hex").slice(0, 24);
|
|
553
|
+
}
|
|
554
|
+
calibrationContract() {
|
|
555
|
+
return {
|
|
556
|
+
schema_version: 2,
|
|
557
|
+
model: this.model,
|
|
558
|
+
identity: this.calibrationIdentity,
|
|
559
|
+
estimator_version: 1,
|
|
560
|
+
chars_per_token: this.runtime.context.chars_per_token,
|
|
561
|
+
image_token_estimate: this.runtime.context.image_token_estimate
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/** The canonical JSON the model key hashes. */
|
|
565
|
+
calibrationContractJson() {
|
|
566
|
+
return canonicalJson(this.calibrationContract(), { floatKeys: this.runtime.context.floatKeys });
|
|
567
|
+
}
|
|
568
|
+
/** One persisted calibration record: the contract plus the float `scale` and integer `samples`. */
|
|
569
|
+
calibrationRecordJson(scale, samples) {
|
|
570
|
+
const floatKeys = /* @__PURE__ */ new Set([...this.runtime.context.floatKeys, "scale"]);
|
|
571
|
+
return `${canonicalJson({ ...this.calibrationContract(), scale, samples }, { floatKeys })}
|
|
572
|
+
`;
|
|
573
|
+
}
|
|
574
|
+
/** Record files `<key>-*.json`; the key is hexadecimal, so only the literal affixes matter. */
|
|
575
|
+
records(directory, key) {
|
|
576
|
+
const prefix = `${key}-`;
|
|
577
|
+
return readdirSync(directory).filter((name) => name.startsWith(prefix) && name.endsWith(".json") && name.length >= prefix.length + 5);
|
|
578
|
+
}
|
|
579
|
+
loadCalibration() {
|
|
580
|
+
if (!this.model || this.calibrationDir === null || !existsSync(this.calibrationDir)) return null;
|
|
581
|
+
let bestScale = 1;
|
|
582
|
+
let samples = 0;
|
|
583
|
+
const contract = this.calibrationContract();
|
|
584
|
+
for (const name of this.records(this.calibrationDir, this.modelKey())) {
|
|
585
|
+
let record;
|
|
586
|
+
try {
|
|
587
|
+
record = JSON.parse(readFileSync(path.join(this.calibrationDir, name), "utf8"));
|
|
588
|
+
} catch {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (!isRecord(record)) continue;
|
|
592
|
+
if (Object.entries(contract).some(([key, value]) => record[key] !== value)) continue;
|
|
593
|
+
const scale = record.scale ?? 1;
|
|
594
|
+
const recordSamples = record.samples ?? 0;
|
|
595
|
+
if (typeof scale !== "number" || typeof recordSamples !== "number") continue;
|
|
596
|
+
if (!Number.isFinite(scale) || scale < 1 || !(Math.trunc(recordSamples) > 0)) continue;
|
|
597
|
+
bestScale = Math.max(bestScale, scale);
|
|
598
|
+
samples = Math.max(samples, Math.trunc(recordSamples));
|
|
599
|
+
}
|
|
600
|
+
return samples ? { scale: bestScale, samples } : null;
|
|
601
|
+
}
|
|
602
|
+
persistCalibration() {
|
|
603
|
+
if (!this.model || this.calibrationDir === null) return;
|
|
604
|
+
mkdirSync(this.calibrationDir, { recursive: true });
|
|
605
|
+
const key = this.modelKey();
|
|
606
|
+
const stem = `${key}-${timeNs()}-${process.pid}`;
|
|
607
|
+
const temporary = path.join(this.calibrationDir, `${stem}.tmp`);
|
|
608
|
+
writeFileSync(temporary, this.calibrationRecordJson(this.budgeter.scale, this.budgeter.calibration_samples), "utf8");
|
|
609
|
+
renameSync(temporary, path.join(this.calibrationDir, `${stem}.json`));
|
|
610
|
+
const records = this.records(this.calibrationDir, key).sort();
|
|
611
|
+
for (const stale of records.slice(0, Math.max(records.length - 32, 0))) {
|
|
612
|
+
try {
|
|
613
|
+
unlinkSync(path.join(this.calibrationDir, stale));
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (error.code !== "ENOENT") throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
fit(messages) {
|
|
620
|
+
const [fitted, compacted] = fitToContext(messages, {
|
|
621
|
+
numCtx: this.runtime.num_ctx,
|
|
622
|
+
numPredict: this.runtime.num_predict,
|
|
623
|
+
policy: this.runtime.context,
|
|
624
|
+
tokenCounter: this.budgeter.estimate
|
|
625
|
+
});
|
|
626
|
+
this.compactions += compacted ? 1 : 0;
|
|
627
|
+
return fitted;
|
|
628
|
+
}
|
|
629
|
+
observe(messages, promptEvalCount) {
|
|
630
|
+
if (this.budgeter.observe(messages, promptEvalCount)) this.persistCalibration();
|
|
631
|
+
}
|
|
632
|
+
metadata() {
|
|
633
|
+
return {
|
|
634
|
+
...this.runtime.context.metadata(),
|
|
635
|
+
...this.budgeter.metadata(),
|
|
636
|
+
calibration_source: this.calibrationSource,
|
|
637
|
+
calibration_scope: this.calibrationIdentity ? "provided_identity_and_estimator" : "model_tag_and_estimator",
|
|
638
|
+
compaction_target_ratio: COMPACTION_TARGET_RATIO,
|
|
639
|
+
estimated_input_budget: contextBudget(this.runtime.num_ctx, this.runtime.num_predict, this.runtime.context.safety_margin_tokens),
|
|
640
|
+
compactions: this.compactions
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
function writeFailureResult(resultPath, answer) {
|
|
645
|
+
mkdirSync(path.dirname(resultPath), { recursive: true });
|
|
646
|
+
writeFileSync(resultPath, `${JSON.stringify({ final_answer: answer, tool_calls: [], usage: {} }, null, 2)}
|
|
647
|
+
`, "utf8");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/research/observations.ts
|
|
651
|
+
import { randomBytes } from "node:crypto";
|
|
652
|
+
import { closeSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync, openSync, readFileSync as readFileSync2, readSync, renameSync as renameSync2, rmSync, unlinkSync as unlinkSync2, writeSync } from "node:fs";
|
|
653
|
+
import { tmpdir } from "node:os";
|
|
654
|
+
import path2 from "node:path";
|
|
655
|
+
function pageFooter(step, page, total, totalChars) {
|
|
656
|
+
if (total <= 1) return "";
|
|
657
|
+
const head = `
|
|
658
|
+
|
|
659
|
+
[page ${page} of ${total} \u2014 ${totalChars} characters total, nothing discarded. `;
|
|
660
|
+
if (page >= total) return `${head}End of stored output.]`;
|
|
661
|
+
return `${head}For the next page send exactly: {"action":"page","step":${step},"page":${page + 1}} \u2014 that re-reads stored output and does NOT re-run the command.]`;
|
|
662
|
+
}
|
|
663
|
+
function encodeStrict(text) {
|
|
664
|
+
if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text)) {
|
|
665
|
+
throw new ValidationError("observation contains a lone surrogate and cannot be encoded as UTF-8");
|
|
666
|
+
}
|
|
667
|
+
return Buffer.from(text, "utf8");
|
|
668
|
+
}
|
|
669
|
+
function writeAll(fd, data) {
|
|
670
|
+
let offset = 0;
|
|
671
|
+
while (offset < data.length) offset += writeSync(fd, data, offset, data.length - offset);
|
|
672
|
+
}
|
|
673
|
+
function readAt(file, position, length) {
|
|
674
|
+
const fd = openSync(file, "r");
|
|
675
|
+
try {
|
|
676
|
+
const buffer = Buffer.alloc(length);
|
|
677
|
+
let read = 0;
|
|
678
|
+
while (read < length) {
|
|
679
|
+
const count = readSync(fd, buffer, read, length - read, position + read);
|
|
680
|
+
if (count === 0) break;
|
|
681
|
+
read += count;
|
|
682
|
+
}
|
|
683
|
+
return buffer.subarray(0, read);
|
|
684
|
+
} finally {
|
|
685
|
+
closeSync(fd);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
var utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
689
|
+
var ObservationStore = class {
|
|
690
|
+
directory;
|
|
691
|
+
pageSize;
|
|
692
|
+
byStep = /* @__PURE__ */ new Map();
|
|
693
|
+
constructor(pageSize = OBSERVATION_PAGE_CHARS) {
|
|
694
|
+
if (pageSize <= 0) throw new ValidationError("observation page size must be > 0");
|
|
695
|
+
this.directory = mkdtempSync(path2.join(path2.resolve(tmpdir()), "freellama-observations-"));
|
|
696
|
+
this.pageSize = pageSize;
|
|
697
|
+
}
|
|
698
|
+
close() {
|
|
699
|
+
this.byStep.clear();
|
|
700
|
+
rmSync(this.directory, { recursive: true, force: true });
|
|
701
|
+
}
|
|
702
|
+
put(step, text, metadata = {}) {
|
|
703
|
+
if (step < 1) throw new ValidationError("observation step must be positive");
|
|
704
|
+
const file = path2.join(this.directory, `${step}.utf8`);
|
|
705
|
+
const indexPath = path2.join(this.directory, `${step}.index`);
|
|
706
|
+
let start = 0;
|
|
707
|
+
let offset = 0;
|
|
708
|
+
let pages = 0;
|
|
709
|
+
let characters = 0;
|
|
710
|
+
const output = openSync(file, "w");
|
|
711
|
+
const index = openSync(indexPath, "w");
|
|
712
|
+
try {
|
|
713
|
+
while (start < text.length) {
|
|
714
|
+
let end = cpAdvance(text, start, this.pageSize);
|
|
715
|
+
if (end < text.length) {
|
|
716
|
+
const newline = text.lastIndexOf("\n", end - 1);
|
|
717
|
+
if (newline >= start) end = newline + 1;
|
|
718
|
+
}
|
|
719
|
+
const slice = text.slice(start, end);
|
|
720
|
+
characters += cpLength(slice);
|
|
721
|
+
const encoded = encodeStrict(slice);
|
|
722
|
+
writeAll(output, encoded);
|
|
723
|
+
const nextOffset = offset + encoded.length;
|
|
724
|
+
const record = Buffer.alloc(16);
|
|
725
|
+
record.writeBigUInt64BE(BigInt(offset), 0);
|
|
726
|
+
record.writeBigUInt64BE(BigInt(nextOffset), 8);
|
|
727
|
+
writeAll(index, record);
|
|
728
|
+
offset = nextOffset;
|
|
729
|
+
pages += 1;
|
|
730
|
+
start = end;
|
|
731
|
+
}
|
|
732
|
+
if (!pages) {
|
|
733
|
+
writeAll(index, Buffer.alloc(16));
|
|
734
|
+
pages = 1;
|
|
735
|
+
}
|
|
736
|
+
} finally {
|
|
737
|
+
closeSync(output);
|
|
738
|
+
closeSync(index);
|
|
739
|
+
}
|
|
740
|
+
const bounded = {};
|
|
741
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
742
|
+
if (key === "action" || key === "status" || key === "source") bounded[key] = clip(String(value), 240);
|
|
743
|
+
}
|
|
744
|
+
this.byStep.set(step, { path: file, indexPath, characters, pages, metadata: bounded });
|
|
745
|
+
}
|
|
746
|
+
/** Explicit full materialization for consumers that need it; adapters use view/writeResult. */
|
|
747
|
+
get(step) {
|
|
748
|
+
const record = this.byStep.get(step);
|
|
749
|
+
return record ? utf8.decode(readFileSync2(record.path)) : null;
|
|
750
|
+
}
|
|
751
|
+
pageCount(step) {
|
|
752
|
+
const record = this.byStep.get(step);
|
|
753
|
+
if (!record) throw new Error(`no stored observation for step ${step}`);
|
|
754
|
+
return record.pages;
|
|
755
|
+
}
|
|
756
|
+
/** Return `[body, footer]` for one page of a stored observation. */
|
|
757
|
+
view(step, page = 1) {
|
|
758
|
+
const record = this.byStep.get(step);
|
|
759
|
+
if (!record) {
|
|
760
|
+
const known = [...this.byStep.keys()].sort((a, b) => a - b).join(", ") || "none yet";
|
|
761
|
+
return [`no stored output for step ${step} (known steps: ${known})`, ""];
|
|
762
|
+
}
|
|
763
|
+
const chosen = Math.max(1, Math.min(page, record.pages));
|
|
764
|
+
const entry = readAt(record.indexPath, (chosen - 1) * 16, 16);
|
|
765
|
+
const start = Number(entry.readBigUInt64BE(0));
|
|
766
|
+
const end = Number(entry.readBigUInt64BE(8));
|
|
767
|
+
const chunk = utf8.decode(readAt(record.path, start, end - start));
|
|
768
|
+
return [chunk, pageFooter(step, chosen, record.pages, record.characters)];
|
|
769
|
+
}
|
|
770
|
+
message(step, page = 1) {
|
|
771
|
+
const [body, footer] = this.view(step, page);
|
|
772
|
+
const record = this.byStep.get(step);
|
|
773
|
+
if (!record) return `Observation (step ${step}):
|
|
774
|
+
${body}`;
|
|
775
|
+
const chosen = Math.max(1, Math.min(page, record.pages));
|
|
776
|
+
const ledger = { ...record.metadata, step, page: chosen, total_pages: record.pages, trust: "untrusted_tool_output" };
|
|
777
|
+
return `Observation (step ${step}):
|
|
778
|
+
[evidence ledger: ${JSON.stringify(ledger)}]
|
|
779
|
+
${body}${footer}`;
|
|
780
|
+
}
|
|
781
|
+
/** Decode a stored UTF-8 file in 64 KiB reads, never splitting a code point across chunks. */
|
|
782
|
+
streamStored(file, consume) {
|
|
783
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
784
|
+
const fd = openSync(file, "r");
|
|
785
|
+
try {
|
|
786
|
+
const buffer = Buffer.alloc(65536);
|
|
787
|
+
for (; ; ) {
|
|
788
|
+
const count = readSync(fd, buffer, 0, buffer.length, null);
|
|
789
|
+
if (count === 0) break;
|
|
790
|
+
const chunk = decoder.decode(buffer.subarray(0, count), { stream: true });
|
|
791
|
+
if (chunk) consume(chunk);
|
|
792
|
+
}
|
|
793
|
+
const rest = decoder.decode();
|
|
794
|
+
if (rest) consume(rest);
|
|
795
|
+
} finally {
|
|
796
|
+
closeSync(fd);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/** Atomically write the public JSON result, restoring full call results from disk. */
|
|
800
|
+
writeResult(resultPath, result) {
|
|
801
|
+
const directory = path2.dirname(resultPath);
|
|
802
|
+
mkdirSync2(directory, { recursive: true });
|
|
803
|
+
const temporary = path2.join(directory, `.${path2.basename(resultPath)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
804
|
+
let fd = null;
|
|
805
|
+
try {
|
|
806
|
+
fd = openSync(temporary, "wx", 384);
|
|
807
|
+
const out = fd;
|
|
808
|
+
const emit = (text) => writeAll(out, Buffer.from(text, "utf8"));
|
|
809
|
+
emit("{");
|
|
810
|
+
Object.entries(result).forEach(([key, value], fieldIndex) => {
|
|
811
|
+
if (fieldIndex) emit(",");
|
|
812
|
+
emit(`${JSON.stringify(key)}:`);
|
|
813
|
+
if (key !== "tool_calls") {
|
|
814
|
+
emit(JSON.stringify(value) ?? "null");
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
emit("[");
|
|
818
|
+
value.forEach((call, callIndex) => {
|
|
819
|
+
const step = callIndex + 1;
|
|
820
|
+
if (step > 1) emit(",");
|
|
821
|
+
const metadata = {};
|
|
822
|
+
for (const [name, item] of Object.entries(call)) if (name !== "result") metadata[name] = item;
|
|
823
|
+
const encoded = JSON.stringify(metadata);
|
|
824
|
+
emit(encoded.slice(0, -1) + (Object.keys(metadata).length ? "," : "") + '"result":"');
|
|
825
|
+
const record = this.byStep.get(step);
|
|
826
|
+
if (!record) throw new Error(`no stored observation for step ${step}`);
|
|
827
|
+
this.streamStored(record.path, (chunk) => emit(JSON.stringify(chunk).slice(1, -1)));
|
|
828
|
+
emit('"}');
|
|
829
|
+
});
|
|
830
|
+
emit("]");
|
|
831
|
+
});
|
|
832
|
+
emit("}\n");
|
|
833
|
+
closeSync(fd);
|
|
834
|
+
fd = null;
|
|
835
|
+
renameSync2(temporary, resultPath);
|
|
836
|
+
} finally {
|
|
837
|
+
if (fd !== null) closeSync(fd);
|
|
838
|
+
if (existsSync2(temporary)) unlinkSync2(temporary);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// src/research/transport.ts
|
|
844
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
845
|
+
|
|
846
|
+
// src/research/transport-http.ts
|
|
847
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
848
|
+
import http from "node:http";
|
|
849
|
+
import https from "node:https";
|
|
850
|
+
var RECEIPT_BODY_LIMIT = 64 * 1024;
|
|
851
|
+
var HTTPError = class extends Error {
|
|
852
|
+
constructor(code, reason, body = Buffer.alloc(0)) {
|
|
853
|
+
super(`HTTP Error ${code}: ${reason}`);
|
|
854
|
+
this.code = code;
|
|
855
|
+
this.reason = reason;
|
|
856
|
+
this.body = body;
|
|
857
|
+
}
|
|
858
|
+
code;
|
|
859
|
+
reason;
|
|
860
|
+
body;
|
|
861
|
+
name = "HTTPError";
|
|
862
|
+
};
|
|
863
|
+
var URLError = class extends Error {
|
|
864
|
+
constructor(reason) {
|
|
865
|
+
super(`request not sent: ${reason.message}`);
|
|
866
|
+
this.reason = reason;
|
|
867
|
+
}
|
|
868
|
+
reason;
|
|
869
|
+
name = "URLError";
|
|
870
|
+
};
|
|
871
|
+
var RequestTimeoutError = class extends Error {
|
|
872
|
+
name = "RequestTimeoutError";
|
|
873
|
+
constructor(message = "timed out") {
|
|
874
|
+
super(message);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
function requestHeaders(env = process.env) {
|
|
878
|
+
const headers = { "content-type": "application/json" };
|
|
879
|
+
const tokenFile = trimSpace(env.FREELLAMA_AUTH_TOKEN_FILE ?? "");
|
|
880
|
+
if (!tokenFile) return headers;
|
|
881
|
+
const token = trimSpace(decodeUtf8(readFileSync3(tokenFile), "FREELLAMA_AUTH_TOKEN_FILE"));
|
|
882
|
+
if (cpLength(token) < 32 || [...token].some((character) => isSpace(character))) {
|
|
883
|
+
throw new ValidationError("FREELLAMA_AUTH_TOKEN_FILE must contain one token of at least 32 bytes");
|
|
884
|
+
}
|
|
885
|
+
headers.authorization = `Bearer ${token}`;
|
|
886
|
+
return headers;
|
|
887
|
+
}
|
|
888
|
+
function httpRequest(url, options) {
|
|
889
|
+
return new Promise((resolve, reject) => {
|
|
890
|
+
let parsed;
|
|
891
|
+
try {
|
|
892
|
+
parsed = new URL(url);
|
|
893
|
+
} catch {
|
|
894
|
+
reject(new ValidationError(`unknown url type: ${JSON.stringify(url)}`));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const client = parsed.protocol === "https:" ? https : parsed.protocol === "http:" ? http : null;
|
|
898
|
+
if (client === null) {
|
|
899
|
+
reject(new URLError(new Error(`unknown url type: ${parsed.protocol.replace(/:$/, "")}`)));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const headers = { ...options.headers, connection: "close" };
|
|
903
|
+
if (options.body) headers["content-length"] = String(options.body.length);
|
|
904
|
+
let sent = false;
|
|
905
|
+
let settled = false;
|
|
906
|
+
const fail = (error) => {
|
|
907
|
+
if (settled) return;
|
|
908
|
+
settled = true;
|
|
909
|
+
request.destroy();
|
|
910
|
+
reject(error);
|
|
911
|
+
};
|
|
912
|
+
const request = client.request(parsed, { method: options.method, headers });
|
|
913
|
+
request.setTimeout(Math.max(options.timeoutSeconds * 1e3, 1), () => {
|
|
914
|
+
fail(sent ? new RequestTimeoutError() : new URLError(new RequestTimeoutError()));
|
|
915
|
+
});
|
|
916
|
+
request.on("finish", () => {
|
|
917
|
+
sent = true;
|
|
918
|
+
});
|
|
919
|
+
request.on("error", (error) => fail(sent ? error : new URLError(error)));
|
|
920
|
+
request.on("response", (response) => {
|
|
921
|
+
const chunks = [];
|
|
922
|
+
let size = 0;
|
|
923
|
+
const status = response.statusCode ?? 0;
|
|
924
|
+
const ok = status >= 200 && status < 300;
|
|
925
|
+
const limit = ok ? options.limit : RECEIPT_BODY_LIMIT + 1;
|
|
926
|
+
const finish = () => {
|
|
927
|
+
if (settled) return;
|
|
928
|
+
settled = true;
|
|
929
|
+
const body = Buffer.concat(chunks);
|
|
930
|
+
request.destroy();
|
|
931
|
+
if (ok) resolve(body);
|
|
932
|
+
else reject(new HTTPError(status, response.statusMessage ?? "", body));
|
|
933
|
+
};
|
|
934
|
+
response.on("data", (chunk) => {
|
|
935
|
+
if (limit !== void 0 && size >= limit) return;
|
|
936
|
+
const piece = limit !== void 0 ? chunk.subarray(0, limit - size) : chunk;
|
|
937
|
+
chunks.push(piece);
|
|
938
|
+
size += piece.length;
|
|
939
|
+
if (limit !== void 0 && size >= limit) finish();
|
|
940
|
+
});
|
|
941
|
+
response.on("error", (error) => fail(error));
|
|
942
|
+
response.on("end", finish);
|
|
943
|
+
});
|
|
944
|
+
request.end(options.body);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
function parseJsonBody(body, source) {
|
|
948
|
+
return JSON.parse(decodeUtf8(body, source));
|
|
949
|
+
}
|
|
950
|
+
async function requestJson(url, payload, timeoutSeconds) {
|
|
951
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8");
|
|
952
|
+
const response = await httpRequest(url, { method: "POST", headers: requestHeaders(), body, timeoutSeconds });
|
|
953
|
+
return parseJsonBody(response, "chat response");
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// src/research/transport.ts
|
|
957
|
+
var MANAGED_SUFFIX = "/_freellama/v1/tasks";
|
|
958
|
+
var IDENTITY_LIMIT = 1024 * 1024;
|
|
959
|
+
var trimSlashes = (text) => text.replace(/\/+$/, "");
|
|
960
|
+
async function readIdentityJson(url) {
|
|
961
|
+
const payload = await httpRequest(url, { method: "GET", headers: requestHeaders(), timeoutSeconds: 5, limit: IDENTITY_LIMIT + 1 });
|
|
962
|
+
if (payload.length > IDENTITY_LIMIT) throw new ValidationError("model identity catalog exceeds size limit");
|
|
963
|
+
return parseJsonBody(payload, "model identity catalog");
|
|
964
|
+
}
|
|
965
|
+
var DIGEST = /^(?:sha256:)?[0-9a-fA-F]{64}$/;
|
|
966
|
+
var sha256 = (data) => createHash2("sha256").update(data, "utf8").digest("hex");
|
|
967
|
+
async function resolveModelIdentity(endpointInput, model, systemPrompt, options = {}) {
|
|
968
|
+
const endpoint = trimSlashes(endpointInput);
|
|
969
|
+
const url = endpoint.endsWith(MANAGED_SUFFIX) ? endpoint.slice(0, -MANAGED_SUFFIX.length) + "/_freellama/v1/models" : endpoint + "/api/tags";
|
|
970
|
+
const unknown = { identity: "", verified: false, scope: "current_process_only" };
|
|
971
|
+
try {
|
|
972
|
+
const payload = await (options.readJson ?? readIdentityJson)(url);
|
|
973
|
+
const entries = isRecord(payload) ? payload.models ?? [] : [];
|
|
974
|
+
if (!Array.isArray(entries)) return unknown;
|
|
975
|
+
const names = /* @__PURE__ */ new Set([model]);
|
|
976
|
+
const lastSegment = model.slice(model.lastIndexOf("/") + 1);
|
|
977
|
+
if (!lastSegment.includes(":")) names.add(model + ":latest");
|
|
978
|
+
let entry = null;
|
|
979
|
+
for (const item of entries) {
|
|
980
|
+
if (!isRecord(item)) continue;
|
|
981
|
+
const name = Object.hasOwn(item, "name") ? item.name : item.model;
|
|
982
|
+
if (typeof name === "object" && name !== null) return unknown;
|
|
983
|
+
if (typeof name === "string" && names.has(name)) {
|
|
984
|
+
entry = item;
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
const digestValue = entry?.digest;
|
|
989
|
+
if (typeof digestValue !== "string" || !DIGEST.test(digestValue)) return unknown;
|
|
990
|
+
const digest = (digestValue.startsWith("sha256:") ? digestValue.slice("sha256:".length) : digestValue).toLowerCase();
|
|
991
|
+
const contract = { endpoint, model, digest, system_hash: sha256(systemPrompt) };
|
|
992
|
+
return {
|
|
993
|
+
identity: sha256(canonicalJson(contract)),
|
|
994
|
+
verified: true,
|
|
995
|
+
scope: "manifest_digest_and_system_prompt",
|
|
996
|
+
digest
|
|
997
|
+
};
|
|
998
|
+
} catch (error) {
|
|
999
|
+
if (isTransportError(error) || isSystemError(error) || error instanceof ValidationError || error instanceof SyntaxError || error instanceof TypeError) {
|
|
1000
|
+
return unknown;
|
|
1001
|
+
}
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function isTransportError(error) {
|
|
1006
|
+
return error instanceof HTTPError || error instanceof URLError || error instanceof RequestTimeoutError;
|
|
1007
|
+
}
|
|
1008
|
+
function retryableChatError(error) {
|
|
1009
|
+
if (error instanceof HTTPError) return error.code === 503;
|
|
1010
|
+
return error instanceof URLError && error.reason.code === "ECONNREFUSED";
|
|
1011
|
+
}
|
|
1012
|
+
var RECEIPT_FIELDS = /* @__PURE__ */ new Set([
|
|
1013
|
+
"error",
|
|
1014
|
+
"code",
|
|
1015
|
+
"reason",
|
|
1016
|
+
"retry_after_seconds",
|
|
1017
|
+
"resource_admission",
|
|
1018
|
+
"lifecycle",
|
|
1019
|
+
"placement",
|
|
1020
|
+
"execution",
|
|
1021
|
+
"admission",
|
|
1022
|
+
"route",
|
|
1023
|
+
"decision",
|
|
1024
|
+
"quality",
|
|
1025
|
+
"feedback"
|
|
1026
|
+
]);
|
|
1027
|
+
var PAYLOAD_FIELDS = /* @__PURE__ */ new Set([
|
|
1028
|
+
"prompt",
|
|
1029
|
+
"system",
|
|
1030
|
+
"system_prompt",
|
|
1031
|
+
"messages",
|
|
1032
|
+
"content",
|
|
1033
|
+
"input",
|
|
1034
|
+
"images",
|
|
1035
|
+
"tools",
|
|
1036
|
+
"request",
|
|
1037
|
+
"request_options",
|
|
1038
|
+
"headers",
|
|
1039
|
+
"authorization",
|
|
1040
|
+
"token",
|
|
1041
|
+
"api_key",
|
|
1042
|
+
"auth_token"
|
|
1043
|
+
]);
|
|
1044
|
+
function receiptValue(value) {
|
|
1045
|
+
if (isRecord(value)) {
|
|
1046
|
+
const result = {};
|
|
1047
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1048
|
+
if (!PAYLOAD_FIELDS.has(key.toLowerCase())) result[key] = receiptValue(item);
|
|
1049
|
+
}
|
|
1050
|
+
return result;
|
|
1051
|
+
}
|
|
1052
|
+
if (Array.isArray(value)) return value.map(receiptValue);
|
|
1053
|
+
return value;
|
|
1054
|
+
}
|
|
1055
|
+
function errorDiagnostic(error) {
|
|
1056
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
|
1057
|
+
return String(error);
|
|
1058
|
+
}
|
|
1059
|
+
function chatErrorDetails(error) {
|
|
1060
|
+
let diagnostic = errorDiagnostic(error);
|
|
1061
|
+
if (!(error instanceof HTTPError)) return [diagnostic, null];
|
|
1062
|
+
const details = { status: error.code };
|
|
1063
|
+
try {
|
|
1064
|
+
const body = error.body.subarray(0, RECEIPT_BODY_LIMIT + 1);
|
|
1065
|
+
if (body.length > RECEIPT_BODY_LIMIT) return [diagnostic, details];
|
|
1066
|
+
const payload = parseJsonBody(body, "refusal body");
|
|
1067
|
+
if (!isRecord(payload) || typeof payload.error !== "string") return [diagnostic, details];
|
|
1068
|
+
const receipt = {};
|
|
1069
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
1070
|
+
if (RECEIPT_FIELDS.has(key)) receipt[key] = receiptValue(value);
|
|
1071
|
+
}
|
|
1072
|
+
details.receipt = receipt;
|
|
1073
|
+
diagnostic = `HTTP ${error.code}: ${receipt.error}`;
|
|
1074
|
+
} catch (failure) {
|
|
1075
|
+
if (!(failure instanceof SyntaxError || failure instanceof ValidationError || failure instanceof RangeError)) throw failure;
|
|
1076
|
+
}
|
|
1077
|
+
return [diagnostic, details];
|
|
1078
|
+
}
|
|
1079
|
+
var PromptCacheUsage = class {
|
|
1080
|
+
calls = 0;
|
|
1081
|
+
reportedCalls = 0;
|
|
1082
|
+
reportedTokens = 0;
|
|
1083
|
+
reportedPromptTokens = 0;
|
|
1084
|
+
observe(response) {
|
|
1085
|
+
this.calls += 1;
|
|
1086
|
+
const cached = response.prompt_eval_cached_count;
|
|
1087
|
+
const prompt = response.prompt_eval_count;
|
|
1088
|
+
if (Number.isInteger(cached) && Number.isInteger(prompt) && cached >= 0 && cached <= prompt) {
|
|
1089
|
+
this.reportedCalls += 1;
|
|
1090
|
+
this.reportedTokens += cached;
|
|
1091
|
+
this.reportedPromptTokens += prompt;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
get tokens() {
|
|
1095
|
+
return this.calls > 0 && this.calls === this.reportedCalls ? this.reportedTokens : null;
|
|
1096
|
+
}
|
|
1097
|
+
metadata() {
|
|
1098
|
+
const tokens = this.tokens;
|
|
1099
|
+
return {
|
|
1100
|
+
status: tokens !== null ? "reported" : this.reportedCalls ? "partially_reported" : "not_reported",
|
|
1101
|
+
calls: this.calls,
|
|
1102
|
+
reported_calls: this.reportedCalls,
|
|
1103
|
+
reported_tokens: this.reportedCalls ? this.reportedTokens : null,
|
|
1104
|
+
hit_ratio: tokens !== null && this.reportedPromptTokens ? this.reportedTokens / this.reportedPromptTokens : null
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
function chatRequest(endpointInput, model, messages, options, think, keepAlive, executionPreference = "auto", minPlacementEvidence = "configured") {
|
|
1109
|
+
const endpoint = trimSlashes(endpointInput);
|
|
1110
|
+
if (endpoint.endsWith(MANAGED_SUFFIX)) {
|
|
1111
|
+
const managedOptions = {};
|
|
1112
|
+
for (const [key, value] of Object.entries(options)) {
|
|
1113
|
+
if (key !== "num_ctx") managedOptions[key] = value;
|
|
1114
|
+
}
|
|
1115
|
+
if (!Object.hasOwn(options, "num_ctx")) throw new ValidationError("managed chat request requires num_ctx");
|
|
1116
|
+
return [endpoint, {
|
|
1117
|
+
task: "coding",
|
|
1118
|
+
objective: "fastest",
|
|
1119
|
+
model,
|
|
1120
|
+
context_tokens: options.num_ctx,
|
|
1121
|
+
execution_preference: executionPreference,
|
|
1122
|
+
min_placement_evidence: minPlacementEvidence,
|
|
1123
|
+
messages,
|
|
1124
|
+
keep_alive: keepAlive,
|
|
1125
|
+
request_options: { format: "json", think, options: managedOptions }
|
|
1126
|
+
}];
|
|
1127
|
+
}
|
|
1128
|
+
return [`${endpoint}/api/chat`, {
|
|
1129
|
+
model,
|
|
1130
|
+
messages,
|
|
1131
|
+
stream: false,
|
|
1132
|
+
truncate: false,
|
|
1133
|
+
shift: false,
|
|
1134
|
+
format: "json",
|
|
1135
|
+
think,
|
|
1136
|
+
keep_alive: keepAlive,
|
|
1137
|
+
options
|
|
1138
|
+
}];
|
|
1139
|
+
}
|
|
1140
|
+
function unwrapChatResponse(payload, executionReceipts) {
|
|
1141
|
+
if (!isRecord(payload)) throw new TypeError("chat response must be a JSON object");
|
|
1142
|
+
const { response } = payload;
|
|
1143
|
+
if (!isRecord(response)) return payload;
|
|
1144
|
+
const route = payload.route ?? {};
|
|
1145
|
+
if (!isRecord(route)) throw new TypeError("managed chat response route must be a JSON object");
|
|
1146
|
+
executionReceipts.push({
|
|
1147
|
+
model: route.selected_model ?? null,
|
|
1148
|
+
execution: payload.execution ?? null,
|
|
1149
|
+
admission: payload.admission ?? null,
|
|
1150
|
+
feedback: payload.feedback ?? null
|
|
1151
|
+
});
|
|
1152
|
+
return response;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/research/workspace-path.ts
|
|
1156
|
+
import { lstatSync, readlinkSync, statSync } from "node:fs";
|
|
1157
|
+
import path3 from "node:path";
|
|
1158
|
+
var SymlinkLoopError = class extends ValidationError {
|
|
1159
|
+
name = "SymlinkLoopError";
|
|
1160
|
+
};
|
|
1161
|
+
var hasNul = (text) => text.includes("\0");
|
|
1162
|
+
function cleanPath(text) {
|
|
1163
|
+
const parts = text.split("/").filter((part) => part !== "" && part !== ".");
|
|
1164
|
+
if (text.startsWith("/")) return `/${parts.join("/")}`;
|
|
1165
|
+
return parts.join("/") || ".";
|
|
1166
|
+
}
|
|
1167
|
+
function joinPath(base, child) {
|
|
1168
|
+
return cleanPath(child.startsWith("/") ? child : `${base}/${child}`);
|
|
1169
|
+
}
|
|
1170
|
+
function escapesRoot(resolved, root) {
|
|
1171
|
+
const a = cleanPath(resolved);
|
|
1172
|
+
const b = cleanPath(root);
|
|
1173
|
+
return a !== b && !a.startsWith(b === "/" ? "/" : `${b}/`);
|
|
1174
|
+
}
|
|
1175
|
+
function readlink(target) {
|
|
1176
|
+
if (hasNul(target)) throw new ValidationError("embedded null byte");
|
|
1177
|
+
try {
|
|
1178
|
+
return readlinkSync(target);
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function resolvePath(target, cwd = process.cwd()) {
|
|
1184
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1185
|
+
const walk = (start, rest) => {
|
|
1186
|
+
let current = rest.startsWith("/") ? "" : start;
|
|
1187
|
+
for (const name of rest.split("/")) {
|
|
1188
|
+
if (!name || name === ".") continue;
|
|
1189
|
+
if (name === "..") {
|
|
1190
|
+
current = current.slice(0, Math.max(current.lastIndexOf("/"), 0));
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
const next = `${current}/${name}`;
|
|
1194
|
+
if (seen.has(next)) {
|
|
1195
|
+
const cached = seen.get(next);
|
|
1196
|
+
if (cached === null || cached === void 0) throw new SymlinkLoopError(`symlink loop at ${JSON.stringify(next)}`);
|
|
1197
|
+
current = cached;
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const link = readlink(next);
|
|
1201
|
+
if (link === null) {
|
|
1202
|
+
current = next;
|
|
1203
|
+
} else {
|
|
1204
|
+
seen.set(next, null);
|
|
1205
|
+
current = walk(current, link);
|
|
1206
|
+
seen.set(next, current);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return current;
|
|
1210
|
+
};
|
|
1211
|
+
const text = cleanPath(target);
|
|
1212
|
+
return path3.posix.normalize(walk(text.startsWith("/") ? "" : cwd, text) || "/");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// src/research/agent-loop.ts
|
|
1216
|
+
var isValidationError = (error) => error instanceof ValidationError;
|
|
1217
|
+
function requireEnv(env, name) {
|
|
1218
|
+
const value = env[name];
|
|
1219
|
+
if (value === void 0) throw new Error(`${name} is not set`);
|
|
1220
|
+
return value;
|
|
1221
|
+
}
|
|
1222
|
+
var readText = (file) => normalizeNewlines(decodeUtf8(readFileSync4(file), file));
|
|
1223
|
+
var pathOf = (value) => value === "" ? "." : value;
|
|
1224
|
+
var numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : typeof value === "boolean" ? Number(value) : fallback;
|
|
1225
|
+
var echo = (action) => canonicalJson(action, { sortKeys: false, ensureAscii: false });
|
|
1226
|
+
async function runAdapter(spec, hooks = {}) {
|
|
1227
|
+
const env = hooks.env ?? process.env;
|
|
1228
|
+
let resultPath = null;
|
|
1229
|
+
try {
|
|
1230
|
+
const model = env.FREELLAMA_TARGET_MODEL || requireEnv(env, "FREELLAMA_BENCH_MODEL");
|
|
1231
|
+
const workspace = resolvePath(pathOf(requireEnv(env, "FREELLAMA_BENCH_WORKSPACE")));
|
|
1232
|
+
const prompt = readText(pathOf(requireEnv(env, "FREELLAMA_BENCH_PROMPT")));
|
|
1233
|
+
resultPath = pathOf(requireEnv(env, "FREELLAMA_AGENT_RESULT"));
|
|
1234
|
+
return await runLoop(spec, hooks, env, { model, workspace, prompt, resultPath });
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
const answer = `unexpected adapter error: ${errorDiagnostic(error)}`;
|
|
1237
|
+
process.stderr.write(`${error instanceof Error ? error.stack ?? answer : answer}
|
|
1238
|
+
`);
|
|
1239
|
+
if (resultPath !== null) {
|
|
1240
|
+
try {
|
|
1241
|
+
writeFailureResult(resultPath, answer);
|
|
1242
|
+
} catch {
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return 1;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function runLoop(spec, hooks, env, inputs) {
|
|
1249
|
+
const { model, workspace, prompt, resultPath } = inputs;
|
|
1250
|
+
const print = hooks.print ?? ((line) => process.stdout.write(`${line}
|
|
1251
|
+
`));
|
|
1252
|
+
const sendJson = hooks.requestJson ?? requestJson;
|
|
1253
|
+
const endpoint = (env.FREELLAMA_OLLAMA_ENDPOINT ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
|
|
1254
|
+
const managedEndpoint = (env.FREELLAMA_AGENT_MANAGED_ENDPOINT ?? "").replace(/\/+$/, "");
|
|
1255
|
+
const executionPreference = env.FREELLAMA_AGENT_EXECUTION_PREFERENCE ?? "auto";
|
|
1256
|
+
const minPlacementEvidence = env.FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE ?? "configured";
|
|
1257
|
+
let runtime;
|
|
1258
|
+
try {
|
|
1259
|
+
runtime = AgentRuntimeConfig.fromEnv({ defaultToolTimeoutSeconds: spec.defaultToolTimeoutSeconds, env });
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
if (!isValidationError(error)) throw error;
|
|
1262
|
+
const answer2 = `invalid research adapter configuration: ${error.message}`;
|
|
1263
|
+
writeFailureResult(resultPath, answer2);
|
|
1264
|
+
print(answer2);
|
|
1265
|
+
return 1;
|
|
1266
|
+
}
|
|
1267
|
+
let messages = [
|
|
1268
|
+
{ role: "system", content: spec.systemPrompt(workspace) },
|
|
1269
|
+
{ role: "user", content: prompt }
|
|
1270
|
+
];
|
|
1271
|
+
const calls = [];
|
|
1272
|
+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: null, cache_write_tokens: null };
|
|
1273
|
+
const cacheUsage = new PromptCacheUsage();
|
|
1274
|
+
const metrics = { load_ms: 0, prompt_eval_ms: 0, eval_ms: 0 };
|
|
1275
|
+
const executionReceipts = [];
|
|
1276
|
+
let answer = "";
|
|
1277
|
+
let failure = null;
|
|
1278
|
+
let transportError = null;
|
|
1279
|
+
const calibrationDir = trimSpace(env.FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR ?? "");
|
|
1280
|
+
const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(managedEndpoint || endpoint, model, messages[0].content) : { verified: false, scope: "persistence_disabled", identity: "" };
|
|
1281
|
+
const contextManager = new AgentContextManager(runtime, {
|
|
1282
|
+
model,
|
|
1283
|
+
calibrationIdentity: calibrationModelIdentity.identity,
|
|
1284
|
+
calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null
|
|
1285
|
+
});
|
|
1286
|
+
try {
|
|
1287
|
+
messages = contextManager.fit(messages);
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
if (!isValidationError(error)) throw error;
|
|
1290
|
+
answer = `research task does not fit the configured context safely: ${error.message}`;
|
|
1291
|
+
writeFailureResult(resultPath, answer);
|
|
1292
|
+
print(answer);
|
|
1293
|
+
return 1;
|
|
1294
|
+
}
|
|
1295
|
+
const chatOptions = {
|
|
1296
|
+
temperature: runtime.temperature,
|
|
1297
|
+
seed: runtime.seed,
|
|
1298
|
+
num_ctx: runtime.num_ctx,
|
|
1299
|
+
num_predict: runtime.num_predict
|
|
1300
|
+
};
|
|
1301
|
+
const callModel = async () => {
|
|
1302
|
+
let lastError = null;
|
|
1303
|
+
let response = null;
|
|
1304
|
+
for (let attempt = 0; attempt < runtime.retry_attempts; attempt += 1) {
|
|
1305
|
+
try {
|
|
1306
|
+
const transportEndpoint = managedEndpoint ? `${managedEndpoint}/_freellama/v1/tasks` : endpoint;
|
|
1307
|
+
const [url, payload] = chatRequest(
|
|
1308
|
+
transportEndpoint,
|
|
1309
|
+
model,
|
|
1310
|
+
messages,
|
|
1311
|
+
chatOptions,
|
|
1312
|
+
runtime.think,
|
|
1313
|
+
runtime.keep_alive,
|
|
1314
|
+
executionPreference,
|
|
1315
|
+
minPlacementEvidence
|
|
1316
|
+
);
|
|
1317
|
+
response = unwrapChatResponse(await sendJson(url, payload, runtime.request_timeout_seconds), executionReceipts);
|
|
1318
|
+
break;
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
if (!isTransportError(error) || !retryableChatError(error)) throw error;
|
|
1321
|
+
lastError = error;
|
|
1322
|
+
if (attempt + 1 < runtime.retry_attempts) await sleep(runtime.retry_backoff_seconds * 1e3);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (response === null) throw lastError;
|
|
1326
|
+
usage.input_tokens = usage.input_tokens + Math.trunc(numberOr(response.prompt_eval_count, 0));
|
|
1327
|
+
cacheUsage.observe(response);
|
|
1328
|
+
usage.cache_read_tokens = cacheUsage.tokens;
|
|
1329
|
+
usage.output_tokens = usage.output_tokens + Math.trunc(numberOr(response.eval_count, 0));
|
|
1330
|
+
metrics.load_ms += numberOr(response.load_duration, 0) / 1e6;
|
|
1331
|
+
metrics.prompt_eval_ms += numberOr(response.prompt_eval_duration, 0) / 1e6;
|
|
1332
|
+
metrics.eval_ms += numberOr(response.eval_duration, 0) / 1e6;
|
|
1333
|
+
contextManager.observe(messages, response.prompt_eval_count);
|
|
1334
|
+
return response;
|
|
1335
|
+
};
|
|
1336
|
+
const refit = () => {
|
|
1337
|
+
try {
|
|
1338
|
+
messages = contextManager.fit(messages);
|
|
1339
|
+
return true;
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
if (!isValidationError(error)) throw error;
|
|
1342
|
+
failure = `research context cannot be fitted safely: ${error.message}`;
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
const content = (response) => {
|
|
1347
|
+
const message = response.message ?? {};
|
|
1348
|
+
if (!isRecord(message)) throw new TypeError("chat response message must be a JSON object");
|
|
1349
|
+
const text = message.content ?? "";
|
|
1350
|
+
return typeof text === "string" ? text : JSON.stringify(text);
|
|
1351
|
+
};
|
|
1352
|
+
const seenCalls = /* @__PURE__ */ new Map();
|
|
1353
|
+
const pageSize = runtime.context.observation_page_chars;
|
|
1354
|
+
const observations = hooks.observationStore ? hooks.observationStore(pageSize) : new ObservationStore(pageSize);
|
|
1355
|
+
let session;
|
|
1356
|
+
try {
|
|
1357
|
+
session = spec.open ? await spec.open(workspace) : void 0;
|
|
1358
|
+
let parseFailures = 0;
|
|
1359
|
+
let exhausted = true;
|
|
1360
|
+
for (let turn = 0; turn < runtime.max_turns; turn += 1) {
|
|
1361
|
+
let response;
|
|
1362
|
+
try {
|
|
1363
|
+
response = await callModel();
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
if (!isTransportError(error)) throw error;
|
|
1366
|
+
let diagnostic;
|
|
1367
|
+
[diagnostic, transportError] = chatErrorDetails(error);
|
|
1368
|
+
failure = `agent response failed: ${diagnostic}`;
|
|
1369
|
+
exhausted = false;
|
|
1370
|
+
break;
|
|
1371
|
+
}
|
|
1372
|
+
const raw = content(response);
|
|
1373
|
+
let action;
|
|
1374
|
+
try {
|
|
1375
|
+
action = spec.parseAction(raw);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
if (!isValidationError(error)) throw error;
|
|
1378
|
+
parseFailures += 1;
|
|
1379
|
+
if (parseFailures > runtime.max_parse_repairs) {
|
|
1380
|
+
failure = `agent gave ${parseFailures} unparseable replies: ${errorDiagnostic(error)}`;
|
|
1381
|
+
exhausted = false;
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
messages.push({ role: "assistant", content: cpSlice(raw, 0, runtime.parse_repair_echo_chars) });
|
|
1385
|
+
messages.push({ role: "user", content: PARSE_REPAIR_NOTICE });
|
|
1386
|
+
if (!refit()) {
|
|
1387
|
+
exhausted = false;
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
parseFailures = 0;
|
|
1393
|
+
if (action.action === "page") {
|
|
1394
|
+
const wantStep = Math.trunc(numberOr(action.step, 0));
|
|
1395
|
+
const wantPage = Math.trunc(numberOr(action.page, 1));
|
|
1396
|
+
messages.push({ role: "assistant", content: echo(action) });
|
|
1397
|
+
messages.push({ role: "user", content: observations.message(wantStep, wantPage) });
|
|
1398
|
+
if (!refit()) {
|
|
1399
|
+
exhausted = false;
|
|
1400
|
+
break;
|
|
1401
|
+
}
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (action.action === "finish") {
|
|
1405
|
+
answer = trimSpace(String(action.answer ?? ""));
|
|
1406
|
+
exhausted = false;
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
const started = performance.now();
|
|
1410
|
+
const call = session.toolCall(action, workspace, runtime);
|
|
1411
|
+
const signature = callSignature(call.signature);
|
|
1412
|
+
let observation;
|
|
1413
|
+
let status;
|
|
1414
|
+
if (seenCalls.has(signature)) {
|
|
1415
|
+
observation = REPEAT_NOTICE;
|
|
1416
|
+
status = "repeat";
|
|
1417
|
+
} else {
|
|
1418
|
+
try {
|
|
1419
|
+
observation = hooks.runTool ? await hooks.runTool(call.record.arguments) : await call.run();
|
|
1420
|
+
status = "ok";
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
if (!isToolError(error)) throw error;
|
|
1423
|
+
observation = `tool error: ${error.message}`;
|
|
1424
|
+
status = "error";
|
|
1425
|
+
}
|
|
1426
|
+
seenCalls.set(signature, calls.length + 1);
|
|
1427
|
+
}
|
|
1428
|
+
calls.push({
|
|
1429
|
+
...call.record,
|
|
1430
|
+
status,
|
|
1431
|
+
duration_ms: Math.round((performance.now() - started) * 1e3) / 1e3
|
|
1432
|
+
});
|
|
1433
|
+
messages.push({ role: "assistant", content: echo(action) });
|
|
1434
|
+
const remaining = runtime.max_turns - calls.length;
|
|
1435
|
+
const step = calls.length;
|
|
1436
|
+
observations.put(step, observation, call.ledger(status));
|
|
1437
|
+
messages.push({ role: "user", content: observations.message(step) + spec.remaining(remaining) });
|
|
1438
|
+
if (!refit()) {
|
|
1439
|
+
exhausted = false;
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (exhausted) {
|
|
1444
|
+
messages.push({ role: "user", content: spec.exhausted });
|
|
1445
|
+
if (refit()) {
|
|
1446
|
+
try {
|
|
1447
|
+
const response = await callModel();
|
|
1448
|
+
const finalAction = spec.parseAction(content(response));
|
|
1449
|
+
if (finalAction.action !== "finish") throw new ValidationError("forced final response was not finish");
|
|
1450
|
+
answer = trimSpace(String(finalAction.answer ?? ""));
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (!isTransportError(error) && !isValidationError(error)) throw error;
|
|
1453
|
+
let diagnostic;
|
|
1454
|
+
[diagnostic, transportError] = chatErrorDetails(error);
|
|
1455
|
+
failure = `agent exceeded ${runtime.max_turns} turns and finalization failed: ${diagnostic}`;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (!answer) answer = failure ?? "agent stopped without a final answer";
|
|
1460
|
+
const result = {
|
|
1461
|
+
final_answer: answer,
|
|
1462
|
+
tool_calls: calls,
|
|
1463
|
+
usage,
|
|
1464
|
+
provider_metrics: {
|
|
1465
|
+
load_ms: metrics.load_ms,
|
|
1466
|
+
prompt_eval_ms: metrics.prompt_eval_ms,
|
|
1467
|
+
eval_ms: metrics.eval_ms
|
|
1468
|
+
},
|
|
1469
|
+
model_metadata: {
|
|
1470
|
+
...spec.adapterMetadata(session),
|
|
1471
|
+
temperature: chatOptions.temperature,
|
|
1472
|
+
seed: chatOptions.seed,
|
|
1473
|
+
num_ctx: chatOptions.num_ctx,
|
|
1474
|
+
num_predict: chatOptions.num_predict,
|
|
1475
|
+
max_turns: runtime.max_turns,
|
|
1476
|
+
runtime_config: runtime.metadata(),
|
|
1477
|
+
context_management: contextManager.metadata(),
|
|
1478
|
+
context_compactions: contextManager.compactions,
|
|
1479
|
+
transport: managedEndpoint ? "managed" : "direct_ollama",
|
|
1480
|
+
execution_preference: executionPreference,
|
|
1481
|
+
min_placement_evidence: minPlacementEvidence,
|
|
1482
|
+
execution_receipts: executionReceipts,
|
|
1483
|
+
...transportError ? { transport_error: transportError } : {},
|
|
1484
|
+
cache_token_metrics: cacheUsage.metadata(),
|
|
1485
|
+
calibration_model_identity: calibrationModelIdentity
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
observations.writeResult(resultPath, result);
|
|
1489
|
+
print(answer);
|
|
1490
|
+
return failure ? 1 : 0;
|
|
1491
|
+
} finally {
|
|
1492
|
+
session?.close?.();
|
|
1493
|
+
observations.close();
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// src/research/proc.ts
|
|
1498
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
1499
|
+
import { constants } from "node:os";
|
|
1500
|
+
function decodeText(bytes, source = "command output") {
|
|
1501
|
+
return normalizeNewlines(decodeUtf8(bytes, source));
|
|
1502
|
+
}
|
|
1503
|
+
function runProcess(argv, options) {
|
|
1504
|
+
const timeoutMs = Math.max(options.timeoutSeconds * 1e3, 0);
|
|
1505
|
+
return new Promise((resolve, reject) => {
|
|
1506
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
1507
|
+
cwd: options.cwd,
|
|
1508
|
+
env: options.env,
|
|
1509
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1510
|
+
});
|
|
1511
|
+
const stdout = [];
|
|
1512
|
+
const stderr = [];
|
|
1513
|
+
let settled = false;
|
|
1514
|
+
const finish = (action) => {
|
|
1515
|
+
if (settled) return;
|
|
1516
|
+
settled = true;
|
|
1517
|
+
clearTimeout(timer);
|
|
1518
|
+
action();
|
|
1519
|
+
};
|
|
1520
|
+
const timer = setTimeout(() => {
|
|
1521
|
+
child.kill("SIGKILL");
|
|
1522
|
+
const fail = () => finish(() => reject(new ToolTimeoutError(
|
|
1523
|
+
`command ${JSON.stringify(argv.at(-1))} timed out after ${options.timeoutSeconds} seconds`
|
|
1524
|
+
)));
|
|
1525
|
+
if (child.exitCode !== null || child.signalCode !== null) fail();
|
|
1526
|
+
else child.once("exit", fail);
|
|
1527
|
+
}, timeoutMs);
|
|
1528
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1529
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1530
|
+
child.once("error", (error) => finish(() => reject(error)));
|
|
1531
|
+
child.once("close", (code, signal) => finish(() => {
|
|
1532
|
+
try {
|
|
1533
|
+
resolve({
|
|
1534
|
+
stdout: decodeText(Buffer.concat(stdout)),
|
|
1535
|
+
stderr: decodeText(Buffer.concat(stderr)),
|
|
1536
|
+
returncode: code ?? -(signal ? constants.signals[signal] ?? 1 : 1)
|
|
1537
|
+
});
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
reject(error);
|
|
1540
|
+
}
|
|
1541
|
+
}));
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/research/prompts.ts
|
|
1546
|
+
function octocodeSystemPrompt(workspace) {
|
|
1547
|
+
return "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.\n\nTools (call as {"action":"octocode","tool":"<name>","queries":{...}}):\n\nlocalViewStructure - browse a directory tree, no content loaded; cheapest first orientation step.\n path (string, required, absolute), maxDepth (int), recursive (bool), filesOnly (bool),\n directoriesOnly (bool), pattern (glob/substring filter).\n\nlocalFindFiles - find files/dirs by name, glob, regex, or type; returns paths only, not content.\n path (string, required, absolute), names (array of globs), pathPattern (glob over full path),\n regex (basename regex), entryType ("f"|"d"), excludeDir (array, e.g. ["node_modules",".git"]).\n\nlocalSearchCode - search file contents for text/regex; returns file+line matches. Your main tool.\n path (string, required, absolute), keywords (string; literal or regex search term),\n mode ("paginated"|"discovery"|"detailed"), include/exclude (glob arrays),\n caseInsensitive (bool), maxFiles (int).\n Do NOT pass mode "structural" with keywords \u2014 structural (AST) search takes `pattern`/`rule`\n instead and hard-errors on keywords, costing you a turn for nothing.\n\nlocalGetFileContent - read one file or a line range/matched slice of it.\n path (string, required, absolute), fullContent (bool; small files only), startLine +\n endLine (ints, both required together), matchString (anchor text/regex), minify\n ("none"|"standard"|"symbols" \u2014 "symbols" gives a cheap outline first).\n\nlspGetSemantics - LSP semantic queries: definitions, references, callers/callees, symbol outline.\n uri (string, required, absolute path), type ("definition"|"references"|"callers"|\n "callees"|"documentSymbols"|"hover"), symbolName (exact name), lineHint (int from a prior\n search result \u2014 never guess it).\n An empty result is ambiguous: indexing or unavailable analysis can produce no symbols.\n Retry once or use localSearchCode; an empty LSP result alone does not prove absence.\n\nRead another page of an earlier step: {"action":"page","step":2,"page":2}\nFull tool output is stored in pages; you see page 1 and the total. Context fitting can shorten\nan observation in the conversation. Request its stored page to recover the evidence without\nre-running the tool. Claim "not found" only after checking the relevant search scope and pages.\n\nFinish with: {"action":"finish","answer":"concise final answer with repository-relative evidence"}\n\nThese are the complete action schemas. Use only `octocode`, `page`, or `finish`; every shown field\nis required with the shown type, and `tool` must be one of the five names above. An invalid shape\nis rejected and costs one bounded repair turn.\n\nSCOPE YOUR SEARCHES. Pass excludeDir/exclude on every search in a real workspace \u2014\n["node_modules","target",".venv","dist","build",".git","vendor","__pycache__",".octocode"] \u2014 or\nvendored and generated files will bury the answer. For implementation questions, prefer src/,\npackages/*/src/ and lib/ before fixtures, mocks, or examples. If the question targets tests or examples,\ninspect those files. Name the file that supplies the answer. Absence of a match is weak evidence: widen the pattern before concluding\nsomething does not exist.\nFor a production default, find its declaration before relying on test occurrences. Declarations look like\n`default=`, `unwrap_or(`, `const `, `static `, or a settings schema initializer.\nFor a test-specific default or fixture, inspect its test declaration.\n\nAll paths you pass must resolve inside the workspace; relative paths are resolved against the\nworkspace root automatically. Orient cheap (localViewStructure) before reading in\nfull. Be decisive: most tasks need 2-6 tool calls. Never edit or write files \u2014 you only have\nread-only tools. Call finish as soon as the requested facts are established.';
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// src/research/octocode-agent.ts
|
|
1551
|
+
var OCTOCODE_TOOLS = /* @__PURE__ */ new Set([
|
|
1552
|
+
"localViewStructure",
|
|
1553
|
+
"localFindFiles",
|
|
1554
|
+
"localSearchCode",
|
|
1555
|
+
"localGetFileContent",
|
|
1556
|
+
"lspGetSemantics"
|
|
1557
|
+
]);
|
|
1558
|
+
var PATH_KEYS = ["path", "uri"];
|
|
1559
|
+
var OCTOCODE_PACKAGE = process.env.FREELLAMA_AGENT_OCTOCODE_PACKAGE ?? "octocode@19.1.0";
|
|
1560
|
+
var SORTED_TOOLS = [...OCTOCODE_TOOLS].sort().join(", ");
|
|
1561
|
+
function safeResolve(root, value) {
|
|
1562
|
+
const candidate = resolvePath(joinPath(root, value));
|
|
1563
|
+
if (escapesRoot(candidate, root)) throw new ValidationError(`path escapes benchmark workspace: ${value}`);
|
|
1564
|
+
if (candidate.split("/").includes(".git")) throw new ValidationError("git metadata is outside the task surface");
|
|
1565
|
+
return candidate;
|
|
1566
|
+
}
|
|
1567
|
+
function parseAction(content) {
|
|
1568
|
+
const value = parseJsonAction(content);
|
|
1569
|
+
const action = value.action;
|
|
1570
|
+
if (action === "octocode") {
|
|
1571
|
+
const tool = value.tool;
|
|
1572
|
+
if (typeof tool !== "string" || !OCTOCODE_TOOLS.has(tool)) {
|
|
1573
|
+
throw new ValidationError(`octocode action requires one supported tool: ${SORTED_TOOLS}`);
|
|
1574
|
+
}
|
|
1575
|
+
if (!isRecord(value.queries)) throw new ValidationError("octocode action requires a queries object");
|
|
1576
|
+
}
|
|
1577
|
+
if (action === "page" && (!isPositiveInt(value.step) || !isPositiveInt(value.page))) {
|
|
1578
|
+
throw new ValidationError("page action requires positive integer step and page");
|
|
1579
|
+
}
|
|
1580
|
+
if (action === "finish" && typeof value.answer !== "string") {
|
|
1581
|
+
throw new ValidationError("finish action requires a string answer");
|
|
1582
|
+
}
|
|
1583
|
+
if (action !== "octocode" && action !== "page" && action !== "finish") {
|
|
1584
|
+
throw new ValidationError(`unsupported action for octocode adapter: ${String(action)}`);
|
|
1585
|
+
}
|
|
1586
|
+
return value;
|
|
1587
|
+
}
|
|
1588
|
+
async function runOctocode(root, toolName, queries, timeoutSeconds) {
|
|
1589
|
+
if (!OCTOCODE_TOOLS.has(toolName)) throw new ValidationError(`unsupported octocode tool: ${toolName}`);
|
|
1590
|
+
const resolved = { ...queries };
|
|
1591
|
+
for (const key of PATH_KEYS) {
|
|
1592
|
+
const value = resolved[key];
|
|
1593
|
+
if (typeof value === "string") resolved[key] = safeResolve(root, value);
|
|
1594
|
+
}
|
|
1595
|
+
const command = ["npx", "--yes", OCTOCODE_PACKAGE, "tools", toolName, "--queries", JSON.stringify(resolved), "--compact"];
|
|
1596
|
+
const result = await runProcess(command, { cwd: root, timeoutSeconds });
|
|
1597
|
+
let output = result.stdout + (result.returncode !== 0 ? `
|
|
1598
|
+
STDERR:
|
|
1599
|
+
${result.stderr}` : "");
|
|
1600
|
+
if (!trimSpace(output)) output = "(empty result)";
|
|
1601
|
+
return output;
|
|
1602
|
+
}
|
|
1603
|
+
var OCTOCODE_ADAPTER = {
|
|
1604
|
+
defaultToolTimeoutSeconds: 45,
|
|
1605
|
+
systemPrompt: (workspace) => octocodeSystemPrompt(workspace),
|
|
1606
|
+
parseAction,
|
|
1607
|
+
open: () => ({
|
|
1608
|
+
toolCall: (action, workspace, runtime) => {
|
|
1609
|
+
const toolName = String(action.tool ?? "");
|
|
1610
|
+
const queries = isRecord(action.queries) ? action.queries : {};
|
|
1611
|
+
return {
|
|
1612
|
+
signature: { tool: toolName, queries },
|
|
1613
|
+
run: () => runOctocode(workspace, toolName, queries, runtime.tool_timeout_seconds),
|
|
1614
|
+
record: {
|
|
1615
|
+
name: toolName ? `mcp.octocode.${toolName}` : "mcp.octocode.unknown",
|
|
1616
|
+
raw_name: action.action ?? "",
|
|
1617
|
+
arguments: { tool: toolName, queries }
|
|
1618
|
+
},
|
|
1619
|
+
ledger: (status) => ({ action: toolName, status, source: canonicalJson(queries, { sortKeys: false, ensureAscii: false }) })
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}),
|
|
1623
|
+
remaining: (count) => `
|
|
1624
|
+
|
|
1625
|
+
Tool calls remaining: ${count}. Finish now if the task is answerable; do not repeat prior calls.`,
|
|
1626
|
+
exhausted: "Tool budget is exhausted. Return exactly a finish JSON action now using the evidence collected. Do not request another tool.",
|
|
1627
|
+
adapterMetadata: () => ({ adapter: "octocode_cli_agent_v1" })
|
|
1628
|
+
};
|
|
1629
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1630
|
+
process.exitCode = await runAdapter(OCTOCODE_ADAPTER);
|
|
1631
|
+
}
|
|
1632
|
+
export {
|
|
1633
|
+
OCTOCODE_ADAPTER,
|
|
1634
|
+
OCTOCODE_PACKAGE,
|
|
1635
|
+
OCTOCODE_TOOLS,
|
|
1636
|
+
parseAction,
|
|
1637
|
+
runOctocode,
|
|
1638
|
+
safeResolve
|
|
1639
|
+
};
|