Vendored
+17
-5
@@ -70,9 +70,13 @@ function loadedCtxSize() {
|
||||
return (loaded && loaded.ctxSize) || 8192;
|
||||
}
|
||||
|
||||
function toolResultCap() {
|
||||
function toolResultCap(budget) {
|
||||
const ctx = loadedCtxSize();
|
||||
return Math.min(8000, Math.max(1200, Math.floor(ctx * 0.35)));
|
||||
const voice = !!(budget && budget.voice);
|
||||
const max = voice ? 2000 : 8000;
|
||||
const ratio = voice ? 0.1 : 0.35;
|
||||
const min = voice ? 600 : 1200;
|
||||
return Math.min(max, Math.max(min, Math.floor(ctx * ratio)));
|
||||
}
|
||||
|
||||
function emitCompactDone(emit, session, jobId, toolDefs, ctxSize, beforeUsage, method) {
|
||||
@@ -440,6 +444,7 @@ async function runTurn(ctx) {
|
||||
const budget = toolBudget.fromPayload(payload, origin);
|
||||
let lastText = '';
|
||||
let goalNudges = 0;
|
||||
let toolNudges = 0;
|
||||
|
||||
async function runOneTool(item, turn) {
|
||||
const name = item.name;
|
||||
@@ -535,7 +540,7 @@ async function runTurn(ctx) {
|
||||
if (out && out.type === 'goal_blocked') {
|
||||
session.goal.status = 'blocked';
|
||||
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
|
||||
const rendered = truncate.renderToolResult(out, toolResultCap());
|
||||
const rendered = truncate.renderToolResult(out, toolResultCap(budget));
|
||||
pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId });
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered.slice(0, 4000) });
|
||||
return { reason: 'goal_blocked', text: out.blocked_reason || lastText, turns: turn + 1 };
|
||||
@@ -545,7 +550,7 @@ async function runTurn(ctx) {
|
||||
const skipVerify = payload && payload.verify === false;
|
||||
const verdict = skipVerify ? { achieved: true, gaps: [] } : await verifyGoal(session, tracker, lastText);
|
||||
if (verdict.achieved) {
|
||||
const rendered = truncate.renderToolResult({ ok: true, achieved: true }, toolResultCap());
|
||||
const rendered = truncate.renderToolResult({ ok: true, achieved: true }, toolResultCap(budget));
|
||||
pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId });
|
||||
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: rendered });
|
||||
emitUpdate(emit, session.id, jobId, { type: 'goal_update', goal: goalMod.snapshot(session.goal) });
|
||||
@@ -563,7 +568,7 @@ async function runTurn(ctx) {
|
||||
} catch (err) {
|
||||
out = { error: err.message };
|
||||
}
|
||||
const rendered = truncate.renderToolResult(out, toolResultCap());
|
||||
const rendered = truncate.renderToolResult(out, toolResultCap(budget));
|
||||
pushHistory(session, { role: 'tool', name, content: rendered, tool_call_id: toolCallId });
|
||||
emitUpdate(
|
||||
emit,
|
||||
@@ -679,6 +684,8 @@ async function runTurn(ctx) {
|
||||
tools: toolDefs,
|
||||
toolDialect: catalog.toolDialectFor(session.model),
|
||||
desktopVision: payload && payload.desktopVision === false ? false : undefined,
|
||||
timeoutMs: budget.completeTimeoutMs,
|
||||
idleMs: budget.completeIdleMs,
|
||||
},
|
||||
(ev) => {
|
||||
if (ev.type === 'contentDelta') {
|
||||
@@ -743,6 +750,11 @@ async function runTurn(ctx) {
|
||||
pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) });
|
||||
continue;
|
||||
}
|
||||
if (toolNudges < 2 && toolBudget.shouldNudgeToolCall([result && result.text, result && result.thinking].filter(Boolean).join('\n'), budget)) {
|
||||
toolNudges += 1;
|
||||
pushHistory(session, { role: 'user', content: toolBudget.continueToolMessage() });
|
||||
continue;
|
||||
}
|
||||
return endTurn(emit, session, jobId, tracker, {
|
||||
type: 'end',
|
||||
reason: 'stop',
|
||||
|
||||
Vendored
+2
-1
@@ -1,7 +1,8 @@
|
||||
/** Permission + shell policy with no Bare imports (unit-testable on Node). */
|
||||
|
||||
const WRITE_TOOLS = new Set(['search_replace', 'write_file', 'run_terminal_cmd', 'use_tool']);
|
||||
const ASK_TOOLS = new Set(['run_terminal_cmd', 'web_fetch', 'web_search', 'use_tool']);
|
||||
const ASK_TOOLS = new Set(['run_terminal_cmd', 'use_tool']);
|
||||
// web_fetch and web_search are public reads; they do not prompt.
|
||||
const SHELL_ALLOW = new Set([
|
||||
'git', 'rg', 'grep', 'ls', 'cat', 'head', 'tail', 'pwd', 'echo', 'node', 'npm', 'npx',
|
||||
'python3', 'python', 'cargo', 'go', 'make', 'bare', 'wc', 'sort', 'uniq', 'find', 'sed', 'awk',
|
||||
|
||||
+18
@@ -19,6 +19,8 @@ function fromPayload(payload, origin) {
|
||||
maxTurns: num(payload.maxTurns, voice ? 6 : 24),
|
||||
maxShellCalls: unlimitedShell ? 0 : num(payload.maxShellCalls, voice ? 1 : 0),
|
||||
maxToolRounds: num(payload.maxToolRounds, voice ? 4 : 0),
|
||||
completeTimeoutMs: voice ? 45000 : 0,
|
||||
completeIdleMs: voice ? 10000 : 0,
|
||||
shellCalls: 0,
|
||||
toolRounds: 0,
|
||||
answerOnly: false,
|
||||
@@ -57,6 +59,20 @@ function answerNowMessage() {
|
||||
return 'You have tool results. Reply to the user in one to three sentences. Do not call more tools.';
|
||||
}
|
||||
|
||||
const UNFINISHED_TOOL =
|
||||
/\b(let me|i(?:'m| am) going to|i(?:'ll| will)|i should|need to)\b[\s\S]{0,160}\b(fetch|search|look(?:ing)? up|check|open|read|run|call|use)\b/i;
|
||||
|
||||
function shouldNudgeToolCall(text, budget) {
|
||||
if (!budget || !budget.voice || budget.answerOnly) return false;
|
||||
const blob = String(text || '');
|
||||
if (UNFINISHED_TOOL.test(blob)) return true;
|
||||
return /\b(search(?:ing)? again|fetch (?:a |the |that )|look(?:ing)? that up)\b/i.test(blob);
|
||||
}
|
||||
|
||||
function continueToolMessage() {
|
||||
return 'Thinking is not an answer. Call the tool now with a concrete query or URL, then speak the result. Do not describe the next step.';
|
||||
}
|
||||
|
||||
function lastToolText(history, maxChars) {
|
||||
const list = Array.isArray(history) ? history : [];
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
@@ -78,5 +94,7 @@ module.exports = {
|
||||
forceAnswer,
|
||||
skipShellMessage,
|
||||
answerNowMessage,
|
||||
shouldNudgeToolCall,
|
||||
continueToolMessage,
|
||||
lastToolText,
|
||||
};
|
||||
|
||||
Vendored
+143
-20
@@ -239,28 +239,137 @@ function defs(opts) {
|
||||
return toolSet.filterBuiltinSchemas(SCHEMAS, opts);
|
||||
}
|
||||
|
||||
async function webSearch(query) {
|
||||
const net = require('../lib/net.js');
|
||||
const url = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query);
|
||||
net.assertPublicHttpUrl(url);
|
||||
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
|
||||
const text = await res.text();
|
||||
const hits = [];
|
||||
const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) && hits.length < 8) {
|
||||
hits.push({ url: m[1], title: m[2].replace(/<[^>]+>/g, '').trim() });
|
||||
}
|
||||
return hits;
|
||||
const WEB_TIMEOUT_MS = 12000;
|
||||
const BROWSER_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
function abortError(timeoutMs) {
|
||||
const err = new Error('timed out after ' + timeoutMs + 'ms');
|
||||
err.name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
async function webFetch(url) {
|
||||
function fetchWithTimeout(url, opts, timeoutMs) {
|
||||
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
|
||||
const headers = Object.assign({ 'user-agent': BROWSER_UA }, (opts && opts.headers) || {});
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
let timer;
|
||||
const init = Object.assign({}, opts || {}, { headers });
|
||||
if (controller) init.signal = controller.signal;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
if (controller) controller.abort();
|
||||
} catch (_) {}
|
||||
reject(abortError(ms));
|
||||
}, ms);
|
||||
});
|
||||
const pending = fetch(url, init);
|
||||
pending.catch(() => {});
|
||||
return Promise.race([pending, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
function readBodyWithTimeout(res, timeoutMs) {
|
||||
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
|
||||
if (!res || typeof res.text !== 'function') return Promise.resolve('');
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(abortError(ms)), ms);
|
||||
});
|
||||
const pending = res.text();
|
||||
pending.catch(() => {});
|
||||
return Promise.race([pending, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
function decodeSearchUrl(href) {
|
||||
let raw = String(href || '').replace(/&/g, '&').trim();
|
||||
if (!raw) return raw;
|
||||
if (raw.startsWith('//')) raw = 'https:' + raw;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
const host = u.hostname.replace(/^www\./, '');
|
||||
if (host === 'duckduckgo.com') {
|
||||
const uddg = u.searchParams.get('uddg');
|
||||
if (uddg) {
|
||||
let dest = String(uddg);
|
||||
try {
|
||||
dest = decodeURIComponent(dest);
|
||||
} catch (_) {}
|
||||
dest = dest.replace(/&/g, '&');
|
||||
if (dest.startsWith('//')) dest = 'https:' + dest;
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
return u.toString();
|
||||
} catch (_) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function webSearch(query, timeoutMs) {
|
||||
const net = require('../lib/net.js');
|
||||
net.assertHttpUrl(url);
|
||||
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
|
||||
let text = await res.text();
|
||||
text = truncate.truncateWithMarker(text, 80000);
|
||||
return { status: res.status, url: String(res.url || url), text };
|
||||
const url = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query);
|
||||
try {
|
||||
net.assertPublicHttpUrl(url);
|
||||
} catch (err) {
|
||||
return { error: String(err && err.message || err), url };
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {}, timeoutMs);
|
||||
if (res.status >= 400) {
|
||||
return { error: 'HTTP ' + res.status, url: String(res.url || url), status: res.status };
|
||||
}
|
||||
const text = await readBodyWithTimeout(res, timeoutMs);
|
||||
const hits = [];
|
||||
const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) && hits.length < 8) {
|
||||
hits.push({ url: decodeSearchUrl(m[1]), title: m[2].replace(/<[^>]+>/g, '').trim() });
|
||||
}
|
||||
return hits;
|
||||
} catch (err) {
|
||||
return { error: String(err && err.message || err), url };
|
||||
}
|
||||
}
|
||||
|
||||
function htmlToText(html) {
|
||||
const raw = String(html || '');
|
||||
if (!/<(?:html|body|div|p|script|head)\b/i.test(raw) && !/<!DOCTYPE/i.test(raw)) return raw;
|
||||
return raw
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function webFetch(url, timeoutMs) {
|
||||
const net = require('../lib/net.js');
|
||||
try {
|
||||
net.assertHttpUrl(url);
|
||||
} catch (err) {
|
||||
return { error: String(err && err.message || err), url: String(url || '') };
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {}, timeoutMs);
|
||||
let text = htmlToText(await readBodyWithTimeout(res, timeoutMs));
|
||||
text = truncate.truncateWithMarker(text, 12000);
|
||||
const href = String(res.url || url);
|
||||
if (res.status >= 400) {
|
||||
return { error: 'HTTP ' + res.status, url: href, status: res.status, text };
|
||||
}
|
||||
return { status: res.status, url: href, text };
|
||||
} catch (err) {
|
||||
return { error: String(err && err.message || err), url: String(url) };
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(ctx, name, args) {
|
||||
@@ -425,4 +534,18 @@ function ensureParent(abs) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell, formatShellResult };
|
||||
module.exports = {
|
||||
defs,
|
||||
execute,
|
||||
SCHEMAS,
|
||||
HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS,
|
||||
runShell,
|
||||
formatShellResult,
|
||||
webFetch,
|
||||
webSearch,
|
||||
decodeSearchUrl,
|
||||
htmlToText,
|
||||
fetchWithTimeout,
|
||||
WEB_TIMEOUT_MS,
|
||||
BROWSER_UA,
|
||||
};
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/** Wall-clock + idle abort for QVAC completion streams. No Bare imports. */
|
||||
|
||||
function attachCompleteWatch(opts) {
|
||||
opts = opts || {};
|
||||
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 0;
|
||||
const idleMs = Number(opts.idleMs) > 0 ? Number(opts.idleMs) : 0;
|
||||
const abort = opts.abort;
|
||||
let timedOut = false;
|
||||
let wall = null;
|
||||
let idle = null;
|
||||
|
||||
function clear() {
|
||||
if (wall) clearTimeout(wall);
|
||||
if (idle) clearTimeout(idle);
|
||||
wall = null;
|
||||
idle = null;
|
||||
}
|
||||
|
||||
function fire() {
|
||||
if (timedOut) return;
|
||||
timedOut = true;
|
||||
clear();
|
||||
try {
|
||||
if (typeof abort === 'function') abort();
|
||||
} catch (_) {}
|
||||
if (typeof opts.onTimeout === 'function') opts.onTimeout();
|
||||
}
|
||||
|
||||
if (timeoutMs) wall = setTimeout(fire, timeoutMs);
|
||||
|
||||
function bump() {
|
||||
if (timedOut || !(idleMs > 0)) return;
|
||||
if (idle) clearTimeout(idle);
|
||||
idle = setTimeout(fire, idleMs);
|
||||
}
|
||||
|
||||
return {
|
||||
bump,
|
||||
clear,
|
||||
timedOut: () => timedOut,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { attachCompleteWatch };
|
||||
Vendored
+44
-6
@@ -9,6 +9,7 @@ const catalog = require('./catalog.js');
|
||||
const device = require('./device.js');
|
||||
const events = require('./events.js');
|
||||
const paths = require('./paths.js');
|
||||
const completeWatch = require('./complete-watch.js');
|
||||
|
||||
let sdk = null;
|
||||
let initError = null;
|
||||
@@ -377,11 +378,29 @@ async function complete(opts, onEvent) {
|
||||
let text = '';
|
||||
let thinking = '';
|
||||
const toolCalls = [];
|
||||
try {
|
||||
const abortRun = () => {
|
||||
try {
|
||||
if (run && typeof run.abort === 'function') run.abort();
|
||||
else if (sdk && typeof sdk.abortCompletion === 'function' && requestId) sdk.abortCompletion({ requestId });
|
||||
} catch (_) {}
|
||||
};
|
||||
let settleTimeout;
|
||||
const timedOutGate = new Promise((resolve) => {
|
||||
settleTimeout = () => resolve('timeout');
|
||||
});
|
||||
const watch = completeWatch.attachCompleteWatch({
|
||||
timeoutMs: opts && opts.timeoutMs,
|
||||
idleMs: opts && opts.idleMs,
|
||||
abort: abortRun,
|
||||
onTimeout: settleTimeout,
|
||||
});
|
||||
const consume = (async () => {
|
||||
if (run.events && typeof run.events[Symbol.asyncIterator] === 'function') {
|
||||
for await (const ev of run.events) {
|
||||
if (watch.timedOut()) return;
|
||||
const n = events.normalizeCompletionEvent(ev);
|
||||
if (!n) continue;
|
||||
watch.bump();
|
||||
if (n.type === 'contentDelta') {
|
||||
text += n.delta;
|
||||
if (onEvent) onEvent(n);
|
||||
@@ -397,18 +416,22 @@ async function complete(opts, onEvent) {
|
||||
}
|
||||
} else if (run.tokenStream) {
|
||||
for await (const token of run.tokenStream) {
|
||||
if (watch.timedOut()) return;
|
||||
watch.bump();
|
||||
text += token;
|
||||
if (onEvent) onEvent({ type: 'contentDelta', delta: token });
|
||||
}
|
||||
if (run.toolCallStream) {
|
||||
for await (const evt of run.toolCallStream) {
|
||||
if (watch.timedOut()) return;
|
||||
watch.bump();
|
||||
const call = evt.call || evt;
|
||||
toolCalls.push(call);
|
||||
if (onEvent) onEvent({ type: 'toolCall', call });
|
||||
}
|
||||
}
|
||||
}
|
||||
let stats = null;
|
||||
if (watch.timedOut()) return;
|
||||
try {
|
||||
if (run.final) {
|
||||
const fin = await run.final;
|
||||
@@ -419,14 +442,29 @@ async function complete(opts, onEvent) {
|
||||
toolCalls.length = 0;
|
||||
for (const c of fin.toolCalls) toolCalls.push(c);
|
||||
}
|
||||
stats = fin.stats || null;
|
||||
}
|
||||
} else if (run.stats) {
|
||||
stats = await run.stats;
|
||||
}
|
||||
} catch (_) {}
|
||||
return { text, thinking, toolCalls, stats, requestId, stopReason: 'stop' };
|
||||
})();
|
||||
consume.catch(() => {});
|
||||
try {
|
||||
await Promise.race([consume, timedOutGate]);
|
||||
let stats = null;
|
||||
if (!watch.timedOut()) {
|
||||
try {
|
||||
if (run.stats) stats = await run.stats;
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
text,
|
||||
thinking,
|
||||
toolCalls,
|
||||
stats,
|
||||
requestId,
|
||||
stopReason: watch.timedOut() ? 'timeout' : 'stop',
|
||||
};
|
||||
} finally {
|
||||
watch.clear();
|
||||
if (requestId) activeRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+49
-1
@@ -20,6 +20,7 @@ const policy = require('../agent/policy.js');
|
||||
const toolBudget = require('../agent/tool-budget.js');
|
||||
const paths = require('../lib/paths.js');
|
||||
const device = require('../lib/device.js');
|
||||
const tools = require('../agent/tools.js');
|
||||
|
||||
function testCatalog() {
|
||||
assert.strictEqual(catalog.resolveModelConstant('qwen3.5-4b'), 'QWEN3_5_4B_MULTIMODAL_Q4_K_M');
|
||||
@@ -261,4 +262,51 @@ testTruncateAndPerm();
|
||||
testPaths();
|
||||
testQvacWorkerDeps();
|
||||
testDevicePrefersGpu();
|
||||
console.log('ok');
|
||||
testWebFetchTimeout()
|
||||
.then(() => {
|
||||
console.log('ok');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function testWebFetchTimeout() {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = () => new Promise(() => {});
|
||||
const started = Date.now();
|
||||
try {
|
||||
const hung = await tools.webFetch('https://example.com/ip', 40);
|
||||
assert.ok(hung.error);
|
||||
assert.ok(/timed out/i.test(hung.error));
|
||||
assert.strictEqual(hung.url, 'https://example.com/ip');
|
||||
assert.ok(Date.now() - started < 2000);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
|
||||
globalThis.fetch = async (url) => ({
|
||||
status: 200,
|
||||
url: String(url),
|
||||
text: async () => '203.0.113.8',
|
||||
});
|
||||
try {
|
||||
const ok = await tools.webFetch('https://ifconfig.me/ip', 200);
|
||||
assert.strictEqual(ok.status, 200);
|
||||
assert.strictEqual(ok.url, 'https://ifconfig.me/ip');
|
||||
assert.strictEqual(ok.text, '203.0.113.8');
|
||||
assert.ok(!ok.error);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
|
||||
globalThis.fetch = async () => ({ status: 503, url: 'https://example.com', text: async () => 'down' });
|
||||
try {
|
||||
const failed = await tools.webFetch('https://example.com/status', 200);
|
||||
assert.ok(failed.error);
|
||||
assert.strictEqual(failed.status, 503);
|
||||
assert.strictEqual(failed.url, 'https://example.com');
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user