@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,2435 @@
|
|
|
1
|
+
// src/research/bash-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
|
+
var IGNORED = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR", "EBADF", "ELOOP"]);
|
|
1176
|
+
function probe(read, fallback) {
|
|
1177
|
+
try {
|
|
1178
|
+
return read();
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
if (IGNORED.has(error.code ?? "")) return fallback;
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
function exists(target) {
|
|
1185
|
+
return !hasNul(target) && probe(() => (statSync(target), true), false);
|
|
1186
|
+
}
|
|
1187
|
+
function isSymlink(target) {
|
|
1188
|
+
return !hasNul(target) && probe(() => lstatSync(target).isSymbolicLink(), false);
|
|
1189
|
+
}
|
|
1190
|
+
function lexists(target) {
|
|
1191
|
+
if (hasNul(target)) return false;
|
|
1192
|
+
try {
|
|
1193
|
+
lstatSync(target);
|
|
1194
|
+
return true;
|
|
1195
|
+
} catch {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function isDir(target) {
|
|
1200
|
+
if (hasNul(target)) return false;
|
|
1201
|
+
try {
|
|
1202
|
+
return statSync(target).isDirectory();
|
|
1203
|
+
} catch {
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
function readlink(target) {
|
|
1208
|
+
if (hasNul(target)) throw new ValidationError("embedded null byte");
|
|
1209
|
+
try {
|
|
1210
|
+
return readlinkSync(target);
|
|
1211
|
+
} catch {
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
function resolvePath(target, cwd = process.cwd()) {
|
|
1216
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1217
|
+
const walk = (start, rest) => {
|
|
1218
|
+
let current = rest.startsWith("/") ? "" : start;
|
|
1219
|
+
for (const name of rest.split("/")) {
|
|
1220
|
+
if (!name || name === ".") continue;
|
|
1221
|
+
if (name === "..") {
|
|
1222
|
+
current = current.slice(0, Math.max(current.lastIndexOf("/"), 0));
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const next = `${current}/${name}`;
|
|
1226
|
+
if (seen.has(next)) {
|
|
1227
|
+
const cached = seen.get(next);
|
|
1228
|
+
if (cached === null || cached === void 0) throw new SymlinkLoopError(`symlink loop at ${JSON.stringify(next)}`);
|
|
1229
|
+
current = cached;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
const link = readlink(next);
|
|
1233
|
+
if (link === null) {
|
|
1234
|
+
current = next;
|
|
1235
|
+
} else {
|
|
1236
|
+
seen.set(next, null);
|
|
1237
|
+
current = walk(current, link);
|
|
1238
|
+
seen.set(next, current);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return current;
|
|
1242
|
+
};
|
|
1243
|
+
const text = cleanPath(target);
|
|
1244
|
+
return path3.posix.normalize(walk(text.startsWith("/") ? "" : cwd, text) || "/");
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/research/agent-loop.ts
|
|
1248
|
+
var isValidationError = (error) => error instanceof ValidationError;
|
|
1249
|
+
function requireEnv(env, name) {
|
|
1250
|
+
const value = env[name];
|
|
1251
|
+
if (value === void 0) throw new Error(`${name} is not set`);
|
|
1252
|
+
return value;
|
|
1253
|
+
}
|
|
1254
|
+
var readText = (file) => normalizeNewlines(decodeUtf8(readFileSync4(file), file));
|
|
1255
|
+
var pathOf = (value) => value === "" ? "." : value;
|
|
1256
|
+
var numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : typeof value === "boolean" ? Number(value) : fallback;
|
|
1257
|
+
var echo = (action) => canonicalJson(action, { sortKeys: false, ensureAscii: false });
|
|
1258
|
+
async function runAdapter(spec, hooks = {}) {
|
|
1259
|
+
const env = hooks.env ?? process.env;
|
|
1260
|
+
let resultPath = null;
|
|
1261
|
+
try {
|
|
1262
|
+
const model = env.FREELLAMA_TARGET_MODEL || requireEnv(env, "FREELLAMA_BENCH_MODEL");
|
|
1263
|
+
const workspace = resolvePath(pathOf(requireEnv(env, "FREELLAMA_BENCH_WORKSPACE")));
|
|
1264
|
+
const prompt = readText(pathOf(requireEnv(env, "FREELLAMA_BENCH_PROMPT")));
|
|
1265
|
+
resultPath = pathOf(requireEnv(env, "FREELLAMA_AGENT_RESULT"));
|
|
1266
|
+
return await runLoop(spec, hooks, env, { model, workspace, prompt, resultPath });
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
const answer = `unexpected adapter error: ${errorDiagnostic(error)}`;
|
|
1269
|
+
process.stderr.write(`${error instanceof Error ? error.stack ?? answer : answer}
|
|
1270
|
+
`);
|
|
1271
|
+
if (resultPath !== null) {
|
|
1272
|
+
try {
|
|
1273
|
+
writeFailureResult(resultPath, answer);
|
|
1274
|
+
} catch {
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return 1;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async function runLoop(spec, hooks, env, inputs) {
|
|
1281
|
+
const { model, workspace, prompt, resultPath } = inputs;
|
|
1282
|
+
const print = hooks.print ?? ((line) => process.stdout.write(`${line}
|
|
1283
|
+
`));
|
|
1284
|
+
const sendJson = hooks.requestJson ?? requestJson;
|
|
1285
|
+
const endpoint = (env.FREELLAMA_OLLAMA_ENDPOINT ?? "http://127.0.0.1:11434").replace(/\/+$/, "");
|
|
1286
|
+
const managedEndpoint = (env.FREELLAMA_AGENT_MANAGED_ENDPOINT ?? "").replace(/\/+$/, "");
|
|
1287
|
+
const executionPreference = env.FREELLAMA_AGENT_EXECUTION_PREFERENCE ?? "auto";
|
|
1288
|
+
const minPlacementEvidence = env.FREELLAMA_AGENT_MIN_PLACEMENT_EVIDENCE ?? "configured";
|
|
1289
|
+
let runtime;
|
|
1290
|
+
try {
|
|
1291
|
+
runtime = AgentRuntimeConfig.fromEnv({ defaultToolTimeoutSeconds: spec.defaultToolTimeoutSeconds, env });
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
if (!isValidationError(error)) throw error;
|
|
1294
|
+
const answer2 = `invalid research adapter configuration: ${error.message}`;
|
|
1295
|
+
writeFailureResult(resultPath, answer2);
|
|
1296
|
+
print(answer2);
|
|
1297
|
+
return 1;
|
|
1298
|
+
}
|
|
1299
|
+
let messages = [
|
|
1300
|
+
{ role: "system", content: spec.systemPrompt(workspace) },
|
|
1301
|
+
{ role: "user", content: prompt }
|
|
1302
|
+
];
|
|
1303
|
+
const calls = [];
|
|
1304
|
+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: null, cache_write_tokens: null };
|
|
1305
|
+
const cacheUsage = new PromptCacheUsage();
|
|
1306
|
+
const metrics = { load_ms: 0, prompt_eval_ms: 0, eval_ms: 0 };
|
|
1307
|
+
const executionReceipts = [];
|
|
1308
|
+
let answer = "";
|
|
1309
|
+
let failure = null;
|
|
1310
|
+
let transportError = null;
|
|
1311
|
+
const calibrationDir = trimSpace(env.FREELLAMA_AGENT_TOKEN_CALIBRATION_DIR ?? "");
|
|
1312
|
+
const calibrationModelIdentity = calibrationDir ? await (hooks.resolveModelIdentity ?? resolveModelIdentity)(managedEndpoint || endpoint, model, messages[0].content) : { verified: false, scope: "persistence_disabled", identity: "" };
|
|
1313
|
+
const contextManager = new AgentContextManager(runtime, {
|
|
1314
|
+
model,
|
|
1315
|
+
calibrationIdentity: calibrationModelIdentity.identity,
|
|
1316
|
+
calibrationDir: calibrationDir && calibrationModelIdentity.verified ? calibrationDir : null
|
|
1317
|
+
});
|
|
1318
|
+
try {
|
|
1319
|
+
messages = contextManager.fit(messages);
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
if (!isValidationError(error)) throw error;
|
|
1322
|
+
answer = `research task does not fit the configured context safely: ${error.message}`;
|
|
1323
|
+
writeFailureResult(resultPath, answer);
|
|
1324
|
+
print(answer);
|
|
1325
|
+
return 1;
|
|
1326
|
+
}
|
|
1327
|
+
const chatOptions = {
|
|
1328
|
+
temperature: runtime.temperature,
|
|
1329
|
+
seed: runtime.seed,
|
|
1330
|
+
num_ctx: runtime.num_ctx,
|
|
1331
|
+
num_predict: runtime.num_predict
|
|
1332
|
+
};
|
|
1333
|
+
const callModel = async () => {
|
|
1334
|
+
let lastError = null;
|
|
1335
|
+
let response = null;
|
|
1336
|
+
for (let attempt = 0; attempt < runtime.retry_attempts; attempt += 1) {
|
|
1337
|
+
try {
|
|
1338
|
+
const transportEndpoint = managedEndpoint ? `${managedEndpoint}/_freellama/v1/tasks` : endpoint;
|
|
1339
|
+
const [url, payload] = chatRequest(
|
|
1340
|
+
transportEndpoint,
|
|
1341
|
+
model,
|
|
1342
|
+
messages,
|
|
1343
|
+
chatOptions,
|
|
1344
|
+
runtime.think,
|
|
1345
|
+
runtime.keep_alive,
|
|
1346
|
+
executionPreference,
|
|
1347
|
+
minPlacementEvidence
|
|
1348
|
+
);
|
|
1349
|
+
response = unwrapChatResponse(await sendJson(url, payload, runtime.request_timeout_seconds), executionReceipts);
|
|
1350
|
+
break;
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
if (!isTransportError(error) || !retryableChatError(error)) throw error;
|
|
1353
|
+
lastError = error;
|
|
1354
|
+
if (attempt + 1 < runtime.retry_attempts) await sleep(runtime.retry_backoff_seconds * 1e3);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (response === null) throw lastError;
|
|
1358
|
+
usage.input_tokens = usage.input_tokens + Math.trunc(numberOr(response.prompt_eval_count, 0));
|
|
1359
|
+
cacheUsage.observe(response);
|
|
1360
|
+
usage.cache_read_tokens = cacheUsage.tokens;
|
|
1361
|
+
usage.output_tokens = usage.output_tokens + Math.trunc(numberOr(response.eval_count, 0));
|
|
1362
|
+
metrics.load_ms += numberOr(response.load_duration, 0) / 1e6;
|
|
1363
|
+
metrics.prompt_eval_ms += numberOr(response.prompt_eval_duration, 0) / 1e6;
|
|
1364
|
+
metrics.eval_ms += numberOr(response.eval_duration, 0) / 1e6;
|
|
1365
|
+
contextManager.observe(messages, response.prompt_eval_count);
|
|
1366
|
+
return response;
|
|
1367
|
+
};
|
|
1368
|
+
const refit = () => {
|
|
1369
|
+
try {
|
|
1370
|
+
messages = contextManager.fit(messages);
|
|
1371
|
+
return true;
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
if (!isValidationError(error)) throw error;
|
|
1374
|
+
failure = `research context cannot be fitted safely: ${error.message}`;
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
const content = (response) => {
|
|
1379
|
+
const message = response.message ?? {};
|
|
1380
|
+
if (!isRecord(message)) throw new TypeError("chat response message must be a JSON object");
|
|
1381
|
+
const text = message.content ?? "";
|
|
1382
|
+
return typeof text === "string" ? text : JSON.stringify(text);
|
|
1383
|
+
};
|
|
1384
|
+
const seenCalls = /* @__PURE__ */ new Map();
|
|
1385
|
+
const pageSize = runtime.context.observation_page_chars;
|
|
1386
|
+
const observations = hooks.observationStore ? hooks.observationStore(pageSize) : new ObservationStore(pageSize);
|
|
1387
|
+
let session;
|
|
1388
|
+
try {
|
|
1389
|
+
session = spec.open ? await spec.open(workspace) : void 0;
|
|
1390
|
+
let parseFailures = 0;
|
|
1391
|
+
let exhausted = true;
|
|
1392
|
+
for (let turn = 0; turn < runtime.max_turns; turn += 1) {
|
|
1393
|
+
let response;
|
|
1394
|
+
try {
|
|
1395
|
+
response = await callModel();
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
if (!isTransportError(error)) throw error;
|
|
1398
|
+
let diagnostic;
|
|
1399
|
+
[diagnostic, transportError] = chatErrorDetails(error);
|
|
1400
|
+
failure = `agent response failed: ${diagnostic}`;
|
|
1401
|
+
exhausted = false;
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
const raw = content(response);
|
|
1405
|
+
let action;
|
|
1406
|
+
try {
|
|
1407
|
+
action = spec.parseAction(raw);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
if (!isValidationError(error)) throw error;
|
|
1410
|
+
parseFailures += 1;
|
|
1411
|
+
if (parseFailures > runtime.max_parse_repairs) {
|
|
1412
|
+
failure = `agent gave ${parseFailures} unparseable replies: ${errorDiagnostic(error)}`;
|
|
1413
|
+
exhausted = false;
|
|
1414
|
+
break;
|
|
1415
|
+
}
|
|
1416
|
+
messages.push({ role: "assistant", content: cpSlice(raw, 0, runtime.parse_repair_echo_chars) });
|
|
1417
|
+
messages.push({ role: "user", content: PARSE_REPAIR_NOTICE });
|
|
1418
|
+
if (!refit()) {
|
|
1419
|
+
exhausted = false;
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
parseFailures = 0;
|
|
1425
|
+
if (action.action === "page") {
|
|
1426
|
+
const wantStep = Math.trunc(numberOr(action.step, 0));
|
|
1427
|
+
const wantPage = Math.trunc(numberOr(action.page, 1));
|
|
1428
|
+
messages.push({ role: "assistant", content: echo(action) });
|
|
1429
|
+
messages.push({ role: "user", content: observations.message(wantStep, wantPage) });
|
|
1430
|
+
if (!refit()) {
|
|
1431
|
+
exhausted = false;
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
if (action.action === "finish") {
|
|
1437
|
+
answer = trimSpace(String(action.answer ?? ""));
|
|
1438
|
+
exhausted = false;
|
|
1439
|
+
break;
|
|
1440
|
+
}
|
|
1441
|
+
const started = performance.now();
|
|
1442
|
+
const call = session.toolCall(action, workspace, runtime);
|
|
1443
|
+
const signature = callSignature(call.signature);
|
|
1444
|
+
let observation;
|
|
1445
|
+
let status;
|
|
1446
|
+
if (seenCalls.has(signature)) {
|
|
1447
|
+
observation = REPEAT_NOTICE;
|
|
1448
|
+
status = "repeat";
|
|
1449
|
+
} else {
|
|
1450
|
+
try {
|
|
1451
|
+
observation = hooks.runTool ? await hooks.runTool(call.record.arguments) : await call.run();
|
|
1452
|
+
status = "ok";
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
if (!isToolError(error)) throw error;
|
|
1455
|
+
observation = `tool error: ${error.message}`;
|
|
1456
|
+
status = "error";
|
|
1457
|
+
}
|
|
1458
|
+
seenCalls.set(signature, calls.length + 1);
|
|
1459
|
+
}
|
|
1460
|
+
calls.push({
|
|
1461
|
+
...call.record,
|
|
1462
|
+
status,
|
|
1463
|
+
duration_ms: Math.round((performance.now() - started) * 1e3) / 1e3
|
|
1464
|
+
});
|
|
1465
|
+
messages.push({ role: "assistant", content: echo(action) });
|
|
1466
|
+
const remaining = runtime.max_turns - calls.length;
|
|
1467
|
+
const step = calls.length;
|
|
1468
|
+
observations.put(step, observation, call.ledger(status));
|
|
1469
|
+
messages.push({ role: "user", content: observations.message(step) + spec.remaining(remaining) });
|
|
1470
|
+
if (!refit()) {
|
|
1471
|
+
exhausted = false;
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
if (exhausted) {
|
|
1476
|
+
messages.push({ role: "user", content: spec.exhausted });
|
|
1477
|
+
if (refit()) {
|
|
1478
|
+
try {
|
|
1479
|
+
const response = await callModel();
|
|
1480
|
+
const finalAction = spec.parseAction(content(response));
|
|
1481
|
+
if (finalAction.action !== "finish") throw new ValidationError("forced final response was not finish");
|
|
1482
|
+
answer = trimSpace(String(finalAction.answer ?? ""));
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
if (!isTransportError(error) && !isValidationError(error)) throw error;
|
|
1485
|
+
let diagnostic;
|
|
1486
|
+
[diagnostic, transportError] = chatErrorDetails(error);
|
|
1487
|
+
failure = `agent exceeded ${runtime.max_turns} turns and finalization failed: ${diagnostic}`;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
if (!answer) answer = failure ?? "agent stopped without a final answer";
|
|
1492
|
+
const result = {
|
|
1493
|
+
final_answer: answer,
|
|
1494
|
+
tool_calls: calls,
|
|
1495
|
+
usage,
|
|
1496
|
+
provider_metrics: {
|
|
1497
|
+
load_ms: metrics.load_ms,
|
|
1498
|
+
prompt_eval_ms: metrics.prompt_eval_ms,
|
|
1499
|
+
eval_ms: metrics.eval_ms
|
|
1500
|
+
},
|
|
1501
|
+
model_metadata: {
|
|
1502
|
+
...spec.adapterMetadata(session),
|
|
1503
|
+
temperature: chatOptions.temperature,
|
|
1504
|
+
seed: chatOptions.seed,
|
|
1505
|
+
num_ctx: chatOptions.num_ctx,
|
|
1506
|
+
num_predict: chatOptions.num_predict,
|
|
1507
|
+
max_turns: runtime.max_turns,
|
|
1508
|
+
runtime_config: runtime.metadata(),
|
|
1509
|
+
context_management: contextManager.metadata(),
|
|
1510
|
+
context_compactions: contextManager.compactions,
|
|
1511
|
+
transport: managedEndpoint ? "managed" : "direct_ollama",
|
|
1512
|
+
execution_preference: executionPreference,
|
|
1513
|
+
min_placement_evidence: minPlacementEvidence,
|
|
1514
|
+
execution_receipts: executionReceipts,
|
|
1515
|
+
...transportError ? { transport_error: transportError } : {},
|
|
1516
|
+
cache_token_metrics: cacheUsage.metadata(),
|
|
1517
|
+
calibration_model_identity: calibrationModelIdentity
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1520
|
+
observations.writeResult(resultPath, result);
|
|
1521
|
+
print(answer);
|
|
1522
|
+
return failure ? 1 : 0;
|
|
1523
|
+
} finally {
|
|
1524
|
+
session?.close?.();
|
|
1525
|
+
observations.close();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// src/research/prompts.ts
|
|
1530
|
+
var BASH_SYSTEM_PROMPT = '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:\n\n{"action":"shell","command":"one shell command, e.g. grep -n \\"class Group\\" click/src/click/core.py"}\n{"action":"page","step":2,"page":2}\n{"action":"finish","answer":"concise final answer with repository-relative evidence"}\n\nThese are the complete action schemas. Use only `shell`, `page`, or `finish`; every shown field is\nrequired with the shown type. An invalid shape is rejected and costs one bounded repair turn.\n\nFull command 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 command. Claim "not found" only after checking the relevant search scope and pages.\n\nThe shell is READ-ONLY and restricted. Available tools: ls, find, grep, rg, cat, head, tail, wc,\nsort, uniq, cut, tr, nl, sed (bounded read-only inline programs), jq, diff, xargs\n(only echo, printf, true, or false targets), tree, file, stat, and read-only git\n(log, show, grep, ls-files, diff, blame, status). There is no awk, python, network, output\nredirection, script-file loading, recursive symlink following, paths outside the workspace,\n$(...) substitution, $VARIABLES, or loops: write paths and patterns literally and\nchain tools with pipes, one pipeline per turn. A refused command costs a turn, so follow these\nrules. Be decisive: most tasks need 2-6 commands. Call finish as soon as the requested facts are\nestablished.\n\nSCOPE YOUR SEARCHES. A real workspace holds far more than its source, and an unscoped grep drowns\nthe answer in vendored and generated files. Always exclude them:\n grep -rn "PATTERN" . --exclude-dir={node_modules,target,.venv,venv,dist,build,.git,vendor,__pycache__,.octocode,site-packages}\nFor implementation questions, prefer src/, packages/*/src/, lib/ and the repo root before fixtures,\nmocks, or examples. If the question targets tests or examples, inspect those files. Name the file\nthat supplies the answer.\nIf a search returns nothing, widen the pattern before concluding the thing does not exist: absence\nof a grep hit is weak evidence, and "not found" is only a real answer once you have looked in the\nsource directories.\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.';
|
|
1531
|
+
|
|
1532
|
+
// src/research/sandbox.ts
|
|
1533
|
+
import { accessSync, constants as fsConstants, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, realpathSync, rmSync as rmSync2, symlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1534
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
1535
|
+
import path4 from "node:path";
|
|
1536
|
+
|
|
1537
|
+
// src/research/proc.ts
|
|
1538
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
1539
|
+
import { constants } from "node:os";
|
|
1540
|
+
function decodeText(bytes, source = "command output") {
|
|
1541
|
+
return normalizeNewlines(decodeUtf8(bytes, source));
|
|
1542
|
+
}
|
|
1543
|
+
function runProcess(argv, options) {
|
|
1544
|
+
const timeoutMs = Math.max(options.timeoutSeconds * 1e3, 0);
|
|
1545
|
+
return new Promise((resolve, reject) => {
|
|
1546
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
1547
|
+
cwd: options.cwd,
|
|
1548
|
+
env: options.env,
|
|
1549
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1550
|
+
});
|
|
1551
|
+
const stdout = [];
|
|
1552
|
+
const stderr = [];
|
|
1553
|
+
let settled = false;
|
|
1554
|
+
const finish = (action) => {
|
|
1555
|
+
if (settled) return;
|
|
1556
|
+
settled = true;
|
|
1557
|
+
clearTimeout(timer);
|
|
1558
|
+
action();
|
|
1559
|
+
};
|
|
1560
|
+
const timer = setTimeout(() => {
|
|
1561
|
+
child.kill("SIGKILL");
|
|
1562
|
+
const fail = () => finish(() => reject(new ToolTimeoutError(
|
|
1563
|
+
`command ${JSON.stringify(argv.at(-1))} timed out after ${options.timeoutSeconds} seconds`
|
|
1564
|
+
)));
|
|
1565
|
+
if (child.exitCode !== null || child.signalCode !== null) fail();
|
|
1566
|
+
else child.once("exit", fail);
|
|
1567
|
+
}, timeoutMs);
|
|
1568
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1569
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1570
|
+
child.once("error", (error) => finish(() => reject(error)));
|
|
1571
|
+
child.once("close", (code, signal) => finish(() => {
|
|
1572
|
+
try {
|
|
1573
|
+
resolve({
|
|
1574
|
+
stdout: decodeText(Buffer.concat(stdout)),
|
|
1575
|
+
stderr: decodeText(Buffer.concat(stderr)),
|
|
1576
|
+
returncode: code ?? -(signal ? constants.signals[signal] ?? 1 : 1)
|
|
1577
|
+
});
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
reject(error);
|
|
1580
|
+
}
|
|
1581
|
+
}));
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
function probe2(argv, env) {
|
|
1585
|
+
const result = spawnSync(argv[0], argv.slice(1), { env, stdio: ["ignore", "pipe", "pipe"], timeout: 1e4, killSignal: "SIGKILL" });
|
|
1586
|
+
return !result.error && result.status === 0;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// src/research/glob.ts
|
|
1590
|
+
import { readdirSync as readdirSync2 } from "node:fs";
|
|
1591
|
+
var GlobPatternError = class extends ValidationError {
|
|
1592
|
+
name = "GlobPatternError";
|
|
1593
|
+
};
|
|
1594
|
+
var hasGlobMagic = (text) => /[*?[]/.test(text);
|
|
1595
|
+
var isHidden = (name) => name.startsWith(".");
|
|
1596
|
+
function globPaths(pattern) {
|
|
1597
|
+
const split = pattern.lastIndexOf("/") + 1;
|
|
1598
|
+
const basename = pattern.slice(split);
|
|
1599
|
+
let dirname = pattern.slice(0, split);
|
|
1600
|
+
if (/[^/]/.test(dirname)) dirname = dirname.replace(/\/+$/, "");
|
|
1601
|
+
if (!hasGlobMagic(pattern)) return (basename ? lexists(pattern) : isDir(dirname)) ? [pattern] : [];
|
|
1602
|
+
if (!dirname) return matchNames("", basename);
|
|
1603
|
+
const directories = dirname !== pattern && hasGlobMagic(dirname) ? globDirectories(dirname) : [dirname];
|
|
1604
|
+
const results = [];
|
|
1605
|
+
for (const directory of directories) {
|
|
1606
|
+
const names = hasGlobMagic(basename) ? matchNames(directory, basename) : (basename ? lexists(join(directory, basename)) : isDir(directory)) ? [basename] : [];
|
|
1607
|
+
for (const name of names) results.push(join(directory, name));
|
|
1608
|
+
}
|
|
1609
|
+
return results;
|
|
1610
|
+
}
|
|
1611
|
+
function globDirectories(pattern) {
|
|
1612
|
+
return globPaths(pattern).filter((match) => isDir(match));
|
|
1613
|
+
}
|
|
1614
|
+
function join(directory, name) {
|
|
1615
|
+
if (name.startsWith("/")) return name;
|
|
1616
|
+
return !directory || directory.endsWith("/") ? directory + name : `${directory}/${name}`;
|
|
1617
|
+
}
|
|
1618
|
+
function matchNames(directory, pattern) {
|
|
1619
|
+
const where = directory || ".";
|
|
1620
|
+
if (where.includes("\0")) throw new ValidationError("embedded null byte");
|
|
1621
|
+
let names;
|
|
1622
|
+
try {
|
|
1623
|
+
names = readdirSync2(where);
|
|
1624
|
+
} catch {
|
|
1625
|
+
names = [];
|
|
1626
|
+
}
|
|
1627
|
+
if (!isHidden(pattern)) names = names.filter((name) => !isHidden(name));
|
|
1628
|
+
const matches = compileGlob(pattern);
|
|
1629
|
+
return names.filter(matches);
|
|
1630
|
+
}
|
|
1631
|
+
function compileGlob(pattern) {
|
|
1632
|
+
const chars = Array.from(pattern);
|
|
1633
|
+
const items = [];
|
|
1634
|
+
for (let index = 0; index < chars.length; ) {
|
|
1635
|
+
const char = chars[index++];
|
|
1636
|
+
if (char === "*") {
|
|
1637
|
+
if (items.at(-1)?.kind !== "star") items.push({ kind: "star" });
|
|
1638
|
+
} else if (char === "?") {
|
|
1639
|
+
items.push({ kind: "any" });
|
|
1640
|
+
} else if (char === "[") {
|
|
1641
|
+
const parsed = parseClass(chars, index, pattern);
|
|
1642
|
+
if (parsed) {
|
|
1643
|
+
items.push(parsed.item);
|
|
1644
|
+
index = parsed.end;
|
|
1645
|
+
} else {
|
|
1646
|
+
items.push({ kind: "char", char: char.codePointAt(0) });
|
|
1647
|
+
}
|
|
1648
|
+
} else {
|
|
1649
|
+
items.push({ kind: "char", char: char.codePointAt(0) });
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
return (name) => matchItems(items, Array.from(name, (char) => char.codePointAt(0)));
|
|
1653
|
+
}
|
|
1654
|
+
function parseClass(chars, start, pattern) {
|
|
1655
|
+
let close = start;
|
|
1656
|
+
if (chars[close] === "!") close += 1;
|
|
1657
|
+
if (chars[close] === "]") close += 1;
|
|
1658
|
+
while (close < chars.length && chars[close] !== "]") close += 1;
|
|
1659
|
+
if (close >= chars.length) return null;
|
|
1660
|
+
const body = chars.slice(start, close);
|
|
1661
|
+
const negate = body[0] === "!";
|
|
1662
|
+
const first = negate ? 1 : 0;
|
|
1663
|
+
const tokens = body.map((char) => ({ char, operator: char === "-" }));
|
|
1664
|
+
if (body.join("").includes("--")) {
|
|
1665
|
+
for (const token of tokens) token.operator = false;
|
|
1666
|
+
for (let hyphen = body.indexOf("-", first + 1); hyphen >= 0; hyphen = body.indexOf("-", hyphen + 3)) {
|
|
1667
|
+
tokens[hyphen].operator = true;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
const ranges = [];
|
|
1671
|
+
const point = (char) => char.codePointAt(0);
|
|
1672
|
+
for (let index = first; index < tokens.length; ) {
|
|
1673
|
+
const low = tokens[index++].char;
|
|
1674
|
+
if (!tokens[index]?.operator || index + 1 >= tokens.length) {
|
|
1675
|
+
ranges.push([point(low), point(low)]);
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
const high = tokens[index + 1].char;
|
|
1679
|
+
index += 2;
|
|
1680
|
+
if (point(high) < point(low)) {
|
|
1681
|
+
throw new GlobPatternError(`bad character range ${low}-${high} in glob pattern ${JSON.stringify(pattern)}`);
|
|
1682
|
+
}
|
|
1683
|
+
ranges.push([point(low), point(high)]);
|
|
1684
|
+
}
|
|
1685
|
+
return { item: { kind: "class", negate, ranges }, end: close + 1 };
|
|
1686
|
+
}
|
|
1687
|
+
function matchOne(item, char) {
|
|
1688
|
+
if (item.kind === "any") return true;
|
|
1689
|
+
if (item.kind === "char") return item.char === char;
|
|
1690
|
+
if (item.kind === "class") return item.ranges.some(([low, high]) => char >= low && char <= high) !== item.negate;
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
function matchItems(items, name) {
|
|
1694
|
+
let item = 0;
|
|
1695
|
+
let char = 0;
|
|
1696
|
+
let starItem = -1;
|
|
1697
|
+
let starChar = 0;
|
|
1698
|
+
while (char < name.length) {
|
|
1699
|
+
if (item < items.length && items[item].kind !== "star" && matchOne(items[item], name[char])) {
|
|
1700
|
+
item += 1;
|
|
1701
|
+
char += 1;
|
|
1702
|
+
} else if (item < items.length && items[item].kind === "star") {
|
|
1703
|
+
starItem = item;
|
|
1704
|
+
starChar = char;
|
|
1705
|
+
item += 1;
|
|
1706
|
+
} else if (starItem >= 0) {
|
|
1707
|
+
item = starItem + 1;
|
|
1708
|
+
starChar += 1;
|
|
1709
|
+
char = starChar;
|
|
1710
|
+
} else {
|
|
1711
|
+
return false;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
while (item < items.length && items[item].kind === "star") item += 1;
|
|
1715
|
+
return item === items.length;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// src/research/sandbox-sed.ts
|
|
1719
|
+
var DIGIT = /^[\p{Nd}\u{b2}-\u{b3}\u{b9}\u{1369}-\u{1371}\u{19da}\u{2070}\u{2074}-\u{2079}\u{2080}-\u{2089}\u{2460}-\u{2468}\u{2474}-\u{247c}\u{2488}-\u{2490}\u{24ea}\u{24f5}-\u{24fd}\u{24ff}\u{2776}-\u{277e}\u{2780}-\u{2788}\u{278a}-\u{2792}\u{10a40}-\u{10a43}\u{10e60}-\u{10e68}\u{11052}-\u{1105a}\u{1f100}-\u{1f10a}]$/u;
|
|
1720
|
+
var isDigit = (char) => char !== void 0 && DIGIT.test(char);
|
|
1721
|
+
var within = (char, set) => set.includes(char ?? "");
|
|
1722
|
+
function checkSed(args) {
|
|
1723
|
+
for (const program of sedPrograms(args)) checkSedProgram(program);
|
|
1724
|
+
}
|
|
1725
|
+
function checkSedProgram(text) {
|
|
1726
|
+
const program = Array.from(text);
|
|
1727
|
+
const length = program.length;
|
|
1728
|
+
let index = 0;
|
|
1729
|
+
const delimited = (start) => {
|
|
1730
|
+
const delimiter = program[start];
|
|
1731
|
+
let cursor = start + 1;
|
|
1732
|
+
while (cursor < length) {
|
|
1733
|
+
if (program[cursor] === "\\") cursor += 2;
|
|
1734
|
+
else if (program[cursor] === delimiter) return cursor + 1;
|
|
1735
|
+
else cursor += 1;
|
|
1736
|
+
}
|
|
1737
|
+
throw new ValidationError("command blocked: unterminated sed expression");
|
|
1738
|
+
};
|
|
1739
|
+
while (index < length) {
|
|
1740
|
+
while (index < length && (isSpace(program[index]) || within(program[index], ";{}"))) index += 1;
|
|
1741
|
+
if (index === length) return;
|
|
1742
|
+
for (let address = 0; address < 2; address += 1) {
|
|
1743
|
+
if (index < length && isDigit(program[index])) {
|
|
1744
|
+
while (index < length && isDigit(program[index])) index += 1;
|
|
1745
|
+
} else if (index < length && program[index] === "$") {
|
|
1746
|
+
index += 1;
|
|
1747
|
+
} else if (index < length && program[index] === "/") {
|
|
1748
|
+
index = delimited(index);
|
|
1749
|
+
} else if (index + 1 < length && program[index] === "\\") {
|
|
1750
|
+
index = delimited(index + 1);
|
|
1751
|
+
} else {
|
|
1752
|
+
break;
|
|
1753
|
+
}
|
|
1754
|
+
while (index < length && isSpace(program[index])) index += 1;
|
|
1755
|
+
if (address === 0 && index < length && program[index] === ",") {
|
|
1756
|
+
index += 1;
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
while (index < length && (isSpace(program[index]) || program[index] === "!")) index += 1;
|
|
1762
|
+
if (index === length) throw new ValidationError("command blocked: sed command is missing");
|
|
1763
|
+
const command = program[index];
|
|
1764
|
+
index += 1;
|
|
1765
|
+
if (command === "s" && index < length) {
|
|
1766
|
+
index = delimited(index);
|
|
1767
|
+
index = delimited(index - 1);
|
|
1768
|
+
const flagsStart = index;
|
|
1769
|
+
while (index < length && !within(program[index], ";}\n")) index += 1;
|
|
1770
|
+
const flags = Array.from(trimSpace(program.slice(flagsStart, index).join("")));
|
|
1771
|
+
if (flags.some((flag) => !within(flag, "gIp0123456789"))) {
|
|
1772
|
+
throw new ValidationError("command blocked: sed substitution flags can read, write or execute");
|
|
1773
|
+
}
|
|
1774
|
+
} else if (!within(command, "pPdDqQnNhHgGx={}")) {
|
|
1775
|
+
throw new ValidationError("command blocked: sed command can read, write or execute files");
|
|
1776
|
+
}
|
|
1777
|
+
if (!within(command, "{}") && index < length && !(isSpace(program[index]) || within(program[index], ";{}"))) {
|
|
1778
|
+
throw new ValidationError("command blocked: unsupported sed command argument");
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
function sedPrograms(args) {
|
|
1783
|
+
const programs = [];
|
|
1784
|
+
let index = 0;
|
|
1785
|
+
while (index < args.length) {
|
|
1786
|
+
const arg = args[index];
|
|
1787
|
+
if (arg === "--") {
|
|
1788
|
+
programs.push(...args.slice(index + 1));
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
if (arg === "--file" || arg.startsWith("--file=")) {
|
|
1792
|
+
throw new ValidationError("command blocked: sed script files can read, write or execute");
|
|
1793
|
+
}
|
|
1794
|
+
if (arg === "--expression") {
|
|
1795
|
+
index += 1;
|
|
1796
|
+
if (index >= args.length) throw new ValidationError("command blocked: sed expression is missing");
|
|
1797
|
+
programs.push(args[index]);
|
|
1798
|
+
} else if (arg.startsWith("--expression=")) {
|
|
1799
|
+
programs.push(arg.slice(arg.indexOf("=") + 1));
|
|
1800
|
+
} else if (arg.startsWith("-") && !arg.startsWith("--")) {
|
|
1801
|
+
const short = Array.from(arg).slice(1);
|
|
1802
|
+
for (let offset = 0; offset < short.length; offset += 1) {
|
|
1803
|
+
const option = short[offset];
|
|
1804
|
+
if (option === "f") throw new ValidationError("command blocked: sed script files can read, write or execute");
|
|
1805
|
+
if (option === "e") {
|
|
1806
|
+
let program = short.slice(offset + 1).join("");
|
|
1807
|
+
if (!program) {
|
|
1808
|
+
index += 1;
|
|
1809
|
+
if (index >= args.length) throw new ValidationError("command blocked: sed expression is missing");
|
|
1810
|
+
program = args[index];
|
|
1811
|
+
}
|
|
1812
|
+
programs.push(program);
|
|
1813
|
+
break;
|
|
1814
|
+
}
|
|
1815
|
+
if (!within(option, "nEr")) throw new ValidationError("command blocked: unsupported sed option");
|
|
1816
|
+
}
|
|
1817
|
+
} else if (!arg.startsWith("--")) {
|
|
1818
|
+
if (programs.length === 0) programs.push(arg);
|
|
1819
|
+
} else {
|
|
1820
|
+
throw new ValidationError("command blocked: unsupported sed option");
|
|
1821
|
+
}
|
|
1822
|
+
index += 1;
|
|
1823
|
+
}
|
|
1824
|
+
return programs;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/research/shlex.ts
|
|
1828
|
+
var ASCII_WORDCHARS = "abcdfeghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
|
1829
|
+
var POSIX_WORDCHARS = "\xDF\xE0\xE1\xE2\xE3\xE4\xE5\xE6\xE7\xE8\xE9\xEA\xEB\xEC\xED\xEE\xEF\xF0\xF1\xF2\xF3\xF4\xF5\xF6\xF8\xF9\xFA\xFB\xFC\xFD\xFE\xFF\xC0\xC1\xC2\xC3\xC4\xC5\xC6\xC7\xC8\xC9\xCA\xCB\xCC\xCD\xCE\xCF\xD0\xD1\xD2\xD3\xD4\xD5\xD6\xD8\xD9\xDA\xDB\xDC\xDD\xDE";
|
|
1830
|
+
function within2(char, set) {
|
|
1831
|
+
return set.includes(char);
|
|
1832
|
+
}
|
|
1833
|
+
function* shlexIter(text, options) {
|
|
1834
|
+
const posix = options.posix;
|
|
1835
|
+
const punctuation = options.punctuationChars ?? "";
|
|
1836
|
+
const whitespaceSplit = options.whitespaceSplit ?? false;
|
|
1837
|
+
const commenters = options.commenters ?? "#";
|
|
1838
|
+
let wordchars = ASCII_WORDCHARS + (posix ? POSIX_WORDCHARS : "");
|
|
1839
|
+
if (punctuation) {
|
|
1840
|
+
wordchars += "~-./*?=";
|
|
1841
|
+
wordchars = [...wordchars].filter((char) => !punctuation.includes(char)).join("");
|
|
1842
|
+
}
|
|
1843
|
+
const whitespace = " \r\n";
|
|
1844
|
+
const quotes = `'"`;
|
|
1845
|
+
const escape = "\\";
|
|
1846
|
+
const escapedquotes = '"';
|
|
1847
|
+
const eof = posix ? null : "";
|
|
1848
|
+
const chars = Array.from(text);
|
|
1849
|
+
let cursor = 0;
|
|
1850
|
+
const pushbackChars = [];
|
|
1851
|
+
const pushback = [];
|
|
1852
|
+
let state = " ";
|
|
1853
|
+
let token = "";
|
|
1854
|
+
const readChar = () => {
|
|
1855
|
+
if (punctuation && pushbackChars.length > 0) return pushbackChars.pop();
|
|
1856
|
+
if (cursor < chars.length) return chars[cursor++];
|
|
1857
|
+
return "";
|
|
1858
|
+
};
|
|
1859
|
+
const skipLine = () => {
|
|
1860
|
+
while (cursor < chars.length) {
|
|
1861
|
+
const char = chars[cursor++];
|
|
1862
|
+
if (char === "\n") break;
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
const readToken = () => {
|
|
1866
|
+
let quoted = false;
|
|
1867
|
+
let escapedstate = " ";
|
|
1868
|
+
for (; ; ) {
|
|
1869
|
+
const nextchar = readChar();
|
|
1870
|
+
if (state === null) {
|
|
1871
|
+
token = "";
|
|
1872
|
+
break;
|
|
1873
|
+
} else if (state === " ") {
|
|
1874
|
+
if (!nextchar) {
|
|
1875
|
+
state = null;
|
|
1876
|
+
break;
|
|
1877
|
+
} else if (within2(nextchar, whitespace)) {
|
|
1878
|
+
if (token || posix && quoted) break;
|
|
1879
|
+
continue;
|
|
1880
|
+
} else if (within2(nextchar, commenters)) {
|
|
1881
|
+
skipLine();
|
|
1882
|
+
} else if (posix && within2(nextchar, escape)) {
|
|
1883
|
+
escapedstate = "a";
|
|
1884
|
+
state = nextchar;
|
|
1885
|
+
} else if (within2(nextchar, wordchars)) {
|
|
1886
|
+
token = nextchar;
|
|
1887
|
+
state = "a";
|
|
1888
|
+
} else if (within2(nextchar, punctuation)) {
|
|
1889
|
+
token = nextchar;
|
|
1890
|
+
state = "c";
|
|
1891
|
+
} else if (within2(nextchar, quotes)) {
|
|
1892
|
+
if (!posix) token = nextchar;
|
|
1893
|
+
state = nextchar;
|
|
1894
|
+
} else if (whitespaceSplit) {
|
|
1895
|
+
token = nextchar;
|
|
1896
|
+
state = "a";
|
|
1897
|
+
} else {
|
|
1898
|
+
token = nextchar;
|
|
1899
|
+
if (token || posix && quoted) break;
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
} else if (within2(state, quotes)) {
|
|
1903
|
+
quoted = true;
|
|
1904
|
+
if (!nextchar) throw new ValidationError("No closing quotation");
|
|
1905
|
+
if (nextchar === state) {
|
|
1906
|
+
if (!posix) {
|
|
1907
|
+
token += nextchar;
|
|
1908
|
+
state = " ";
|
|
1909
|
+
break;
|
|
1910
|
+
}
|
|
1911
|
+
state = "a";
|
|
1912
|
+
} else if (posix && within2(nextchar, escape) && within2(state, escapedquotes)) {
|
|
1913
|
+
escapedstate = state;
|
|
1914
|
+
state = nextchar;
|
|
1915
|
+
} else {
|
|
1916
|
+
token += nextchar;
|
|
1917
|
+
}
|
|
1918
|
+
} else if (within2(state, escape)) {
|
|
1919
|
+
if (!nextchar) throw new ValidationError("No escaped character");
|
|
1920
|
+
if (within2(escapedstate, quotes) && nextchar !== state && nextchar !== escapedstate) {
|
|
1921
|
+
token += state;
|
|
1922
|
+
}
|
|
1923
|
+
token += nextchar;
|
|
1924
|
+
state = escapedstate;
|
|
1925
|
+
} else if (state === "a" || state === "c") {
|
|
1926
|
+
if (!nextchar) {
|
|
1927
|
+
state = null;
|
|
1928
|
+
break;
|
|
1929
|
+
} else if (within2(nextchar, whitespace)) {
|
|
1930
|
+
state = " ";
|
|
1931
|
+
if (token || posix && quoted) break;
|
|
1932
|
+
continue;
|
|
1933
|
+
} else if (within2(nextchar, commenters)) {
|
|
1934
|
+
skipLine();
|
|
1935
|
+
if (posix) {
|
|
1936
|
+
state = " ";
|
|
1937
|
+
if (token || posix && quoted) break;
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
} else if (state === "c") {
|
|
1941
|
+
if (within2(nextchar, punctuation)) {
|
|
1942
|
+
token += nextchar;
|
|
1943
|
+
} else {
|
|
1944
|
+
if (!within2(nextchar, whitespace)) pushbackChars.push(nextchar);
|
|
1945
|
+
state = " ";
|
|
1946
|
+
break;
|
|
1947
|
+
}
|
|
1948
|
+
} else if (posix && within2(nextchar, quotes)) {
|
|
1949
|
+
state = nextchar;
|
|
1950
|
+
} else if (posix && within2(nextchar, escape)) {
|
|
1951
|
+
escapedstate = "a";
|
|
1952
|
+
state = nextchar;
|
|
1953
|
+
} else if (within2(nextchar, wordchars) || within2(nextchar, quotes) || whitespaceSplit && !within2(nextchar, punctuation)) {
|
|
1954
|
+
token += nextchar;
|
|
1955
|
+
} else {
|
|
1956
|
+
if (punctuation) pushbackChars.push(nextchar);
|
|
1957
|
+
else pushback.unshift(nextchar);
|
|
1958
|
+
state = " ";
|
|
1959
|
+
if (token || posix && quoted) break;
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const result = token;
|
|
1965
|
+
token = "";
|
|
1966
|
+
if (posix && !quoted && result === "") return null;
|
|
1967
|
+
return result;
|
|
1968
|
+
};
|
|
1969
|
+
for (; ; ) {
|
|
1970
|
+
const next = pushback.length > 0 ? pushback.shift() : readToken();
|
|
1971
|
+
if (next === eof) return;
|
|
1972
|
+
yield next;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// src/research/sandbox-validate.ts
|
|
1977
|
+
var READ_ONLY_TOOLS = [
|
|
1978
|
+
"ls",
|
|
1979
|
+
"find",
|
|
1980
|
+
"grep",
|
|
1981
|
+
"egrep",
|
|
1982
|
+
"fgrep",
|
|
1983
|
+
"rg",
|
|
1984
|
+
"cat",
|
|
1985
|
+
"head",
|
|
1986
|
+
"tail",
|
|
1987
|
+
"wc",
|
|
1988
|
+
"sort",
|
|
1989
|
+
"uniq",
|
|
1990
|
+
"cut",
|
|
1991
|
+
"tr",
|
|
1992
|
+
"nl",
|
|
1993
|
+
"file",
|
|
1994
|
+
"stat",
|
|
1995
|
+
"du",
|
|
1996
|
+
"tree",
|
|
1997
|
+
"basename",
|
|
1998
|
+
"dirname",
|
|
1999
|
+
"realpath",
|
|
2000
|
+
"readlink",
|
|
2001
|
+
"sed",
|
|
2002
|
+
"jq",
|
|
2003
|
+
"diff",
|
|
2004
|
+
"cmp",
|
|
2005
|
+
"column",
|
|
2006
|
+
"xargs",
|
|
2007
|
+
"git",
|
|
2008
|
+
"tac",
|
|
2009
|
+
"fold",
|
|
2010
|
+
"paste",
|
|
2011
|
+
"comm",
|
|
2012
|
+
"od",
|
|
2013
|
+
"strings",
|
|
2014
|
+
"seq",
|
|
2015
|
+
"expr"
|
|
2016
|
+
];
|
|
2017
|
+
var SAFE_BUILTINS = /* @__PURE__ */ new Set(["echo", "printf", "true", "false", "test", "[", "pwd"]);
|
|
2018
|
+
var READ_ONLY_GIT = /* @__PURE__ */ new Set([
|
|
2019
|
+
"log",
|
|
2020
|
+
"show",
|
|
2021
|
+
"grep",
|
|
2022
|
+
"ls-files",
|
|
2023
|
+
"ls-tree",
|
|
2024
|
+
"diff",
|
|
2025
|
+
"blame",
|
|
2026
|
+
"status",
|
|
2027
|
+
"rev-parse",
|
|
2028
|
+
"cat-file",
|
|
2029
|
+
"shortlog",
|
|
2030
|
+
"describe",
|
|
2031
|
+
"rev-list",
|
|
2032
|
+
"name-rev",
|
|
2033
|
+
"annotate"
|
|
2034
|
+
]);
|
|
2035
|
+
var READ_ONLY_TOOL_SET = new Set(READ_ONLY_TOOLS);
|
|
2036
|
+
var FORBIDDEN_FLAGS = {
|
|
2037
|
+
find: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"],
|
|
2038
|
+
sort: ["-o", "--output", "--compress-program", "--files0-from"],
|
|
2039
|
+
wc: ["--files0-from"],
|
|
2040
|
+
grep: ["-f", "--file"],
|
|
2041
|
+
egrep: ["-f", "--file"],
|
|
2042
|
+
fgrep: ["-f", "--file"],
|
|
2043
|
+
rg: ["--pre", "--pre-glob", "--search-zip", "-z", "-f", "--file"],
|
|
2044
|
+
tree: ["-o"],
|
|
2045
|
+
file: ["-C", "--compile", "-f", "--files-from"],
|
|
2046
|
+
jq: ["-f", "--from-file", "-L", "--library-path"],
|
|
2047
|
+
git: ["-C", "-c", "--config-env", "--exec-path", "--output", "-o", "--ext-diff", "--textconv", "--open-files-in-pager", "-O"],
|
|
2048
|
+
diff: ["--to-file"]
|
|
2049
|
+
};
|
|
2050
|
+
var SED_IN_PLACE = /^(?:-[a-zA-Z]*i|--in-place)/;
|
|
2051
|
+
var S = SPACE_CLASS;
|
|
2052
|
+
var W = "\\p{L}\\p{N}_";
|
|
2053
|
+
var HIDDEN_EXPANSION = /\$\(|`|<\(|>\(|\$'|\$\{|\$[A-Za-z_0-9@*#?!$-]/;
|
|
2054
|
+
var ALLOWED_STDERR_REDIRECT = new RegExp(`(?<![${W}>&])2>[${S}]*/dev/null|(?<![${W}>&])2>&1`, "gu");
|
|
2055
|
+
var HOME_ESCAPE = new RegExp(`(?:^|[${S}="'])(?:~|\\$HOME(?![${W}])|\\$\\{HOME\\})`, "u");
|
|
2056
|
+
var ABS_PATH = new RegExp(`(?:^|[${S}="'(:,])(/[^${S}"';|&)]*)`, "gu");
|
|
2057
|
+
var DOTDOT_PATH = new RegExp(`(?:^|[${S}="'/])\\.\\.(?:/|[${S}"']|$)`, "u");
|
|
2058
|
+
var FS_ABS_PREFIX = /^\/(?:etc|usr|home|Users|var|tmp|private|opt|root|System|Library|bin|sbin|dev|proc|sys|Applications|Volumes|mnt|media|srv|run|nix|snap)(?:\/|$)/;
|
|
2059
|
+
function outsideSingleQuotes(command) {
|
|
2060
|
+
const out = [];
|
|
2061
|
+
let quoted = false;
|
|
2062
|
+
let escaped = false;
|
|
2063
|
+
for (const char of command) {
|
|
2064
|
+
if (quoted) {
|
|
2065
|
+
quoted = char !== "'";
|
|
2066
|
+
out.push(" ");
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
if (escaped) {
|
|
2070
|
+
escaped = false;
|
|
2071
|
+
out.push(" ");
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
if (char === "\\") {
|
|
2075
|
+
escaped = true;
|
|
2076
|
+
out.push(" ");
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (char === "'") {
|
|
2080
|
+
quoted = true;
|
|
2081
|
+
out.push(out.length > 0 && out[out.length - 1] === "$" ? "'" : " ");
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
out.push(char);
|
|
2085
|
+
}
|
|
2086
|
+
return out.join("");
|
|
2087
|
+
}
|
|
2088
|
+
function assertCommandConfined(root, commandText) {
|
|
2089
|
+
if (HOME_ESCAPE.test(commandText)) {
|
|
2090
|
+
throw new ValidationError("command blocked: home-directory path is outside the workspace");
|
|
2091
|
+
}
|
|
2092
|
+
if (DOTDOT_PATH.test(commandText)) {
|
|
2093
|
+
throw new ValidationError("command blocked: '..' walks outside the workspace");
|
|
2094
|
+
}
|
|
2095
|
+
const resolvedRoot = resolvePath(root);
|
|
2096
|
+
for (const match of commandText.matchAll(ABS_PATH)) {
|
|
2097
|
+
const raw = match[1];
|
|
2098
|
+
let candidates;
|
|
2099
|
+
if (hasGlobMagic(raw)) {
|
|
2100
|
+
const matches = globPaths(raw).map(cleanPath);
|
|
2101
|
+
if (matches.length === 0 && !raw.startsWith(resolvedRoot)) {
|
|
2102
|
+
throw new ValidationError(`command blocked: path pattern escapes workspace: ${raw}`);
|
|
2103
|
+
}
|
|
2104
|
+
candidates = matches;
|
|
2105
|
+
} else {
|
|
2106
|
+
const candidate = cleanPath(raw);
|
|
2107
|
+
if (FS_ABS_PREFIX.exec(raw) === null && !exists(candidate)) continue;
|
|
2108
|
+
candidates = [candidate];
|
|
2109
|
+
}
|
|
2110
|
+
for (const candidate of candidates) {
|
|
2111
|
+
if (escapesRoot(resolvePath(candidate), resolvedRoot)) {
|
|
2112
|
+
throw new ValidationError(`command blocked: path escapes workspace: ${raw}`);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
var OPERATOR_CHARS = new Set(";&|()<>");
|
|
2118
|
+
var SEPARATORS = /* @__PURE__ */ new Set(["|", "||", "&&", ";"]);
|
|
2119
|
+
function segments(commandText) {
|
|
2120
|
+
const tokens = shlexIter(commandText, { posix: true, punctuationChars: ";&|()<>", whitespaceSplit: true, commenters: "" });
|
|
2121
|
+
const result = [[]];
|
|
2122
|
+
for (const token of tokens) {
|
|
2123
|
+
if (SEPARATORS.has(token)) {
|
|
2124
|
+
result.push([]);
|
|
2125
|
+
} else if (token && [...token].every((char) => OPERATOR_CHARS.has(char))) {
|
|
2126
|
+
if (token === "<") {
|
|
2127
|
+
result[result.length - 1].push(token);
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
throw new ValidationError(
|
|
2131
|
+
`command blocked: shell operator '${token}' is not allowed (read-only research: no output redirection, background jobs or subshells)`
|
|
2132
|
+
);
|
|
2133
|
+
} else {
|
|
2134
|
+
result[result.length - 1].push(token);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
return result.filter((segment) => segment.length > 0);
|
|
2138
|
+
}
|
|
2139
|
+
var inShortCluster = (arg, letters) => arg.startsWith("-") && !arg.startsWith("--") && arg.slice(1).includes(letters);
|
|
2140
|
+
function checkTool(words) {
|
|
2141
|
+
if (words.length === 0) return;
|
|
2142
|
+
const name = words[0];
|
|
2143
|
+
const args = words.slice(1);
|
|
2144
|
+
if (name.includes("/")) throw new ValidationError(`command blocked: run tools by name, not path: ${name}`);
|
|
2145
|
+
if (SAFE_BUILTINS.has(name)) return;
|
|
2146
|
+
if (!READ_ONLY_TOOL_SET.has(name)) {
|
|
2147
|
+
throw new ValidationError(
|
|
2148
|
+
`command blocked: '${name}' is not a read-only research tool (allowed: ${READ_ONLY_TOOLS.join(", ")})`
|
|
2149
|
+
);
|
|
2150
|
+
}
|
|
2151
|
+
for (const flag of FORBIDDEN_FLAGS[name] ?? []) {
|
|
2152
|
+
if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`) || flag.length === 2 && flag.startsWith("-") && inShortCluster(arg, flag[1]))) {
|
|
2153
|
+
throw new ValidationError(`command blocked: \`${name} ${flag}\` loads external arguments, writes or executes`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (name === "sed") {
|
|
2157
|
+
for (const arg of args) {
|
|
2158
|
+
if (SED_IN_PLACE.test(arg)) throw new ValidationError("command blocked: sed -i edits files");
|
|
2159
|
+
}
|
|
2160
|
+
checkSed(args);
|
|
2161
|
+
}
|
|
2162
|
+
if (name === "git") {
|
|
2163
|
+
const allowed = [...READ_ONLY_GIT].sort().join(", ");
|
|
2164
|
+
const subcommand = args.find((arg) => !arg.startsWith("-"));
|
|
2165
|
+
if (subcommand === void 0) {
|
|
2166
|
+
throw new ValidationError(`command blocked: \`git\` needs a read-only subcommand (allowed: ${allowed})`);
|
|
2167
|
+
}
|
|
2168
|
+
if (!READ_ONLY_GIT.has(subcommand)) {
|
|
2169
|
+
throw new ValidationError(`command blocked: \`git ${subcommand}\` is not read-only (allowed: ${allowed})`);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
if (name === "xargs") {
|
|
2173
|
+
if (args.some((arg) => arg === "--arg-file" || arg.startsWith("--arg-file=") || arg.startsWith("-a") && !arg.startsWith("--"))) {
|
|
2174
|
+
throw new ValidationError("command blocked: xargs argument files cannot be confined");
|
|
2175
|
+
}
|
|
2176
|
+
const target = xargsCommand(args);
|
|
2177
|
+
if (!["echo", "printf", "true", "false"].includes(target[0])) {
|
|
2178
|
+
throw new ValidationError("command blocked: xargs filesystem arguments cannot be confined");
|
|
2179
|
+
}
|
|
2180
|
+
checkTool(target);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
var XARGS_WITH_VALUE = /* @__PURE__ */ new Set(["-n", "-I", "-d", "-P", "-L", "-s", "-E", "-a", "--arg-file", "--delimiter", "--max-args", "--max-procs"]);
|
|
2184
|
+
function xargsCommand(args) {
|
|
2185
|
+
let index = 0;
|
|
2186
|
+
while (index < args.length && args[index].startsWith("-")) index += XARGS_WITH_VALUE.has(args[index]) ? 2 : 1;
|
|
2187
|
+
const command = args.slice(index);
|
|
2188
|
+
return command.length > 0 ? command : ["echo"];
|
|
2189
|
+
}
|
|
2190
|
+
var FOLLOW_FLAGS = { rg: "L", grep: "R", egrep: "R", fgrep: "R", find: "L", tree: "l", du: "L" };
|
|
2191
|
+
var SHELL_CONSTRUCTS = /* @__PURE__ */ new Set(["for", "while", "until", "if", "case", "function", "{", "eval", "exec", "source", "."]);
|
|
2192
|
+
function validateCommand(root, commandTextInput) {
|
|
2193
|
+
if (!trimSpace(commandTextInput)) throw new ValidationError("empty shell command");
|
|
2194
|
+
if (trimSpace(commandTextInput).includes("\n")) throw new ValidationError("command blocked: send one command line per turn");
|
|
2195
|
+
const commandText = trimSpace(commandTextInput.replace(ALLOWED_STDERR_REDIRECT, " "));
|
|
2196
|
+
if (HIDDEN_EXPANSION.test(outsideSingleQuotes(commandText))) {
|
|
2197
|
+
throw new ValidationError(
|
|
2198
|
+
"command blocked: command substitution, process substitution and $-expansion are not allowed; write paths and patterns literally"
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
for (const token of shlexIter(commandText, { posix: false, whitespaceSplit: true, commenters: "" })) {
|
|
2202
|
+
if (token.startsWith("'") || token.startsWith('"') || token.startsWith("--exclude-dir=")) continue;
|
|
2203
|
+
if (token.includes("{") || token.includes("}")) {
|
|
2204
|
+
throw new ValidationError("command blocked: brace-expanded paths cannot be confined");
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
const resolvedRoot = () => resolvePath(root);
|
|
2208
|
+
for (const segment of segments(commandText)) {
|
|
2209
|
+
for (let word of segment.slice(1)) {
|
|
2210
|
+
if (word.startsWith("--exclude-dir=")) continue;
|
|
2211
|
+
if (word === "<") continue;
|
|
2212
|
+
if (word.startsWith("-")) {
|
|
2213
|
+
if (!word.includes("=")) continue;
|
|
2214
|
+
word = word.slice(word.indexOf("=") + 1);
|
|
2215
|
+
}
|
|
2216
|
+
const candidate = joinPath(root, word);
|
|
2217
|
+
const candidates = hasGlobMagic(word) ? globPaths(candidate).map(cleanPath) : [candidate];
|
|
2218
|
+
for (const item of candidates) {
|
|
2219
|
+
if ((exists(item) || isSymlink(item)) && escapesRoot(resolvePath(item), resolvedRoot())) {
|
|
2220
|
+
throw new ValidationError("command blocked: relative path escapes workspace");
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const tool = segment[0];
|
|
2225
|
+
const followFlags = Object.hasOwn(FOLLOW_FLAGS, tool) ? FOLLOW_FLAGS[tool] : void 0;
|
|
2226
|
+
if (followFlags && segment.slice(1).some((arg) => arg === "--follow" || arg === "--dereference" || arg === "--dereference-recursive" || inShortCluster(arg, followFlags))) {
|
|
2227
|
+
throw new ValidationError("command blocked: recursive symlink following cannot be confined");
|
|
2228
|
+
}
|
|
2229
|
+
if (tool.includes("=") && !tool.startsWith("=")) {
|
|
2230
|
+
throw new ValidationError("command blocked: variable assignment is not allowed");
|
|
2231
|
+
}
|
|
2232
|
+
if (SHELL_CONSTRUCTS.has(tool)) {
|
|
2233
|
+
throw new ValidationError(`command blocked: shell construct '${tool}' is not allowed; use one pipeline`);
|
|
2234
|
+
}
|
|
2235
|
+
checkTool(segment.filter((word) => word !== "<"));
|
|
2236
|
+
}
|
|
2237
|
+
assertCommandConfined(root, commandText);
|
|
2238
|
+
return commandText;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// src/research/sandbox.ts
|
|
2242
|
+
function macosProfile(workspace, scratch) {
|
|
2243
|
+
const quote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
2244
|
+
return `(version 1)
|
|
2245
|
+
(allow default)
|
|
2246
|
+
(deny network*)
|
|
2247
|
+
(deny file-write*)
|
|
2248
|
+
(allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (subpath ${quote(scratch)}))
|
|
2249
|
+
(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/root"))
|
|
2250
|
+
(allow file-read-data (subpath ${quote(workspace)}) (subpath ${quote(scratch)}))
|
|
2251
|
+
`;
|
|
2252
|
+
}
|
|
2253
|
+
function which(name, envPath = process.env.PATH) {
|
|
2254
|
+
const searchPath = envPath ?? "/bin:/usr/bin";
|
|
2255
|
+
if (!searchPath) return null;
|
|
2256
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2257
|
+
for (const directory of searchPath.split(":")) {
|
|
2258
|
+
if (seen.has(directory)) continue;
|
|
2259
|
+
seen.add(directory);
|
|
2260
|
+
const candidate = directory ? directory.endsWith("/") ? directory + name : `${directory}/${name}` : name;
|
|
2261
|
+
try {
|
|
2262
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
2263
|
+
} catch {
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
if (!isDir(candidate)) return candidate;
|
|
2267
|
+
}
|
|
2268
|
+
return null;
|
|
2269
|
+
}
|
|
2270
|
+
var KEPT_ENV = /* @__PURE__ */ new Set(["LANG", "LC_ALL", "LC_CTYPE", "TZ"]);
|
|
2271
|
+
var ReadOnlyShell = class {
|
|
2272
|
+
workspace;
|
|
2273
|
+
kind = "restricted-bash";
|
|
2274
|
+
prefix = [];
|
|
2275
|
+
env = {};
|
|
2276
|
+
bash = "/bin/bash";
|
|
2277
|
+
scratch = null;
|
|
2278
|
+
constructor(workspace) {
|
|
2279
|
+
this.workspace = workspace;
|
|
2280
|
+
}
|
|
2281
|
+
enter() {
|
|
2282
|
+
this.workspace = resolvePath(this.workspace);
|
|
2283
|
+
const scratch = mkdtempSync2(path4.join(path4.resolve(tmpdir2()), "freellama-shell-"));
|
|
2284
|
+
this.scratch = scratch;
|
|
2285
|
+
const binDir = path4.join(scratch, "bin");
|
|
2286
|
+
mkdirSync3(binDir);
|
|
2287
|
+
for (const tool of READ_ONLY_TOOLS) {
|
|
2288
|
+
const located = which(tool);
|
|
2289
|
+
if (located) symlinkSync(realpathSync(located), path4.join(binDir, tool));
|
|
2290
|
+
}
|
|
2291
|
+
this.bash = which("bash") ?? "/bin/bash";
|
|
2292
|
+
const env = {};
|
|
2293
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
2294
|
+
if (KEPT_ENV.has(key) && value !== void 0) env[key] = value;
|
|
2295
|
+
}
|
|
2296
|
+
Object.assign(env, {
|
|
2297
|
+
PATH: binDir,
|
|
2298
|
+
HOME: this.workspace,
|
|
2299
|
+
TMPDIR: scratch,
|
|
2300
|
+
TERM: "dumb",
|
|
2301
|
+
PAGER: "cat",
|
|
2302
|
+
GIT_PAGER: "cat",
|
|
2303
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
2304
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
2305
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
2306
|
+
// `git status` otherwise rewrites the index
|
|
2307
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
2308
|
+
});
|
|
2309
|
+
this.env = env;
|
|
2310
|
+
this.prefix = this.osSandbox(scratch, binDir);
|
|
2311
|
+
return this;
|
|
2312
|
+
}
|
|
2313
|
+
close() {
|
|
2314
|
+
if (this.scratch !== null) {
|
|
2315
|
+
rmSync2(this.scratch, { recursive: true, force: true });
|
|
2316
|
+
this.scratch = null;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
osSandbox(scratch, binDir) {
|
|
2320
|
+
if ((process.env.FREELLAMA_AGENT_OS_SANDBOX ?? "auto") === "off") return [];
|
|
2321
|
+
const sandboxExec = which("sandbox-exec");
|
|
2322
|
+
if (sandboxExec) {
|
|
2323
|
+
const profile = path4.join(scratch, "profile.sb");
|
|
2324
|
+
writeFileSync2(profile, macosProfile(this.workspace, scratch), "utf8");
|
|
2325
|
+
const prefix = [sandboxExec, "-f", profile];
|
|
2326
|
+
if (probe2([...prefix, this.bash, "-c", "true"], this.env)) {
|
|
2327
|
+
this.kind = "sandbox-exec+restricted-bash";
|
|
2328
|
+
return prefix;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
const bwrap = which("bwrap");
|
|
2332
|
+
if (bwrap) {
|
|
2333
|
+
const prefix = [bwrap, "--unshare-all", "--die-with-parent", "--new-session", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"];
|
|
2334
|
+
for (const systemDir of ["/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc/alternatives", "/etc/ld.so.cache", "/etc/localtime", "/nix/store"]) {
|
|
2335
|
+
prefix.push("--ro-bind-try", systemDir, systemDir);
|
|
2336
|
+
}
|
|
2337
|
+
prefix.push(
|
|
2338
|
+
"--ro-bind",
|
|
2339
|
+
this.workspace,
|
|
2340
|
+
this.workspace,
|
|
2341
|
+
"--ro-bind",
|
|
2342
|
+
binDir,
|
|
2343
|
+
binDir,
|
|
2344
|
+
"--bind",
|
|
2345
|
+
scratch,
|
|
2346
|
+
scratch,
|
|
2347
|
+
"--chdir",
|
|
2348
|
+
this.workspace,
|
|
2349
|
+
"--"
|
|
2350
|
+
);
|
|
2351
|
+
if (probe2([...prefix, this.bash, "-c", "true"], this.env)) {
|
|
2352
|
+
this.kind = "bwrap+restricted-bash";
|
|
2353
|
+
return prefix;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
return [];
|
|
2357
|
+
}
|
|
2358
|
+
async run(commandText, timeoutSeconds) {
|
|
2359
|
+
const command = validateCommand(this.workspace, commandText);
|
|
2360
|
+
const result = await runProcess(
|
|
2361
|
+
[...this.prefix, this.bash, "--restricted", "--noprofile", "--norc", "-c", command],
|
|
2362
|
+
{ cwd: this.workspace, env: this.env, timeoutSeconds }
|
|
2363
|
+
);
|
|
2364
|
+
let output = result.stdout + result.stderr;
|
|
2365
|
+
if (!trimSpace(output)) output = `(no output, exit code ${result.returncode})`;
|
|
2366
|
+
return output;
|
|
2367
|
+
}
|
|
2368
|
+
};
|
|
2369
|
+
|
|
2370
|
+
// src/research/bash-agent.ts
|
|
2371
|
+
function parseAction(content) {
|
|
2372
|
+
const value = parseJsonAction(content);
|
|
2373
|
+
const action = value.action;
|
|
2374
|
+
if (action === "shell") {
|
|
2375
|
+
const command = value.command;
|
|
2376
|
+
if (typeof command !== "string" || !trimSpace(command)) throw new ValidationError("shell action requires a non-empty string command");
|
|
2377
|
+
}
|
|
2378
|
+
if (action === "page" && (!isPositiveInt(value.step) || !isPositiveInt(value.page))) {
|
|
2379
|
+
throw new ValidationError("page action requires positive integer step and page");
|
|
2380
|
+
}
|
|
2381
|
+
if (action === "finish" && typeof value.answer !== "string") {
|
|
2382
|
+
throw new ValidationError("finish action requires a string answer");
|
|
2383
|
+
}
|
|
2384
|
+
if (action !== "shell" && action !== "page" && action !== "finish") {
|
|
2385
|
+
throw new ValidationError(`unsupported action for bash adapter: ${String(action)}`);
|
|
2386
|
+
}
|
|
2387
|
+
return value;
|
|
2388
|
+
}
|
|
2389
|
+
async function runShell(root, commandText, timeoutSeconds, shell) {
|
|
2390
|
+
if (shell) return shell.run(commandText, timeoutSeconds);
|
|
2391
|
+
const scoped = new ReadOnlyShell(root).enter();
|
|
2392
|
+
try {
|
|
2393
|
+
return await scoped.run(commandText, timeoutSeconds);
|
|
2394
|
+
} finally {
|
|
2395
|
+
scoped.close();
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
var BASH_ADAPTER = {
|
|
2399
|
+
defaultToolTimeoutSeconds: 30,
|
|
2400
|
+
systemPrompt: () => BASH_SYSTEM_PROMPT,
|
|
2401
|
+
parseAction,
|
|
2402
|
+
open: (workspace) => {
|
|
2403
|
+
const shell = new ReadOnlyShell(workspace).enter();
|
|
2404
|
+
return {
|
|
2405
|
+
shell,
|
|
2406
|
+
toolCall: (action, _workspace, runtime) => {
|
|
2407
|
+
const commandText = String(action.command ?? "");
|
|
2408
|
+
return {
|
|
2409
|
+
signature: { command: commandText },
|
|
2410
|
+
run: () => shell.run(commandText, runtime.tool_timeout_seconds),
|
|
2411
|
+
record: { name: "shell", raw_name: action.action ?? "", arguments: { command: commandText } },
|
|
2412
|
+
ledger: (status) => ({ action: "shell", status, source: commandText })
|
|
2413
|
+
};
|
|
2414
|
+
},
|
|
2415
|
+
close: () => shell.close()
|
|
2416
|
+
};
|
|
2417
|
+
},
|
|
2418
|
+
remaining: (count) => `
|
|
2419
|
+
|
|
2420
|
+
Commands remaining: ${count}. Finish now if the task is answerable; do not repeat prior commands.`,
|
|
2421
|
+
exhausted: "Command budget is exhausted. Return exactly a finish JSON action now using the evidence collected. Do not request another command.",
|
|
2422
|
+
adapterMetadata: (session) => ({
|
|
2423
|
+
adapter: "bash_shell_agent_v1",
|
|
2424
|
+
sandbox: session?.shell?.kind ?? "restricted-bash"
|
|
2425
|
+
})
|
|
2426
|
+
};
|
|
2427
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
2428
|
+
process.exitCode = await runAdapter(BASH_ADAPTER);
|
|
2429
|
+
}
|
|
2430
|
+
export {
|
|
2431
|
+
BASH_ADAPTER,
|
|
2432
|
+
assertCommandConfined,
|
|
2433
|
+
parseAction,
|
|
2434
|
+
runShell
|
|
2435
|
+
};
|