101 lines
3.0 KiB
JavaScript
101 lines
3.0 KiB
JavaScript
/**
|
|
* Voice/coding turn budgets. No Bare imports.
|
|
*
|
|
* Coding agents may chain many shells. A spoken GNOME assistant should run one
|
|
* command, then answer — otherwise a 4B model loops hostnamectl/uname/free.
|
|
*/
|
|
|
|
function num(value, fallback) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
}
|
|
|
|
function fromPayload(payload, origin) {
|
|
payload = payload || {};
|
|
const voice = payload.voice === true || origin === 'jarvis-qvac';
|
|
const unlimitedShell = payload.maxShellCalls === 0 || payload.maxShellCalls === false;
|
|
return {
|
|
voice,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function capped(limit) {
|
|
return limit > 0;
|
|
}
|
|
|
|
function shouldSkipShell(budget) {
|
|
return !!(budget && capped(budget.maxShellCalls) && budget.shellCalls >= budget.maxShellCalls);
|
|
}
|
|
|
|
function markShell(budget) {
|
|
if (!budget) return;
|
|
budget.shellCalls += 1;
|
|
if (shouldSkipShell(budget)) budget.answerOnly = true;
|
|
}
|
|
|
|
function markToolRound(budget) {
|
|
if (!budget) return;
|
|
budget.toolRounds += 1;
|
|
if (capped(budget.maxToolRounds) && budget.toolRounds >= budget.maxToolRounds) budget.answerOnly = true;
|
|
}
|
|
|
|
function forceAnswer(budget) {
|
|
if (budget) budget.answerOnly = true;
|
|
}
|
|
|
|
function skipShellMessage() {
|
|
return 'A terminal command already ran this turn. Answer the user from that output. Do not run another command.';
|
|
}
|
|
|
|
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--) {
|
|
if (list[i] && list[i].role === 'tool' && list[i].content) {
|
|
const text = String(list[i].content).replace(/\s+/g, ' ').trim();
|
|
if (!text) continue;
|
|
const max = maxChars > 0 ? maxChars : 400;
|
|
return text.length > max ? text.slice(0, max) + '…' : text;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
module.exports = {
|
|
fromPayload,
|
|
shouldSkipShell,
|
|
markShell,
|
|
markToolRound,
|
|
forceAnswer,
|
|
skipShellMessage,
|
|
answerNowMessage,
|
|
shouldNudgeToolCall,
|
|
continueToolMessage,
|
|
lastToolText,
|
|
};
|