Updates
Rolling release / release (push) Failing after 1m49s

This commit is contained in:
2026-09-12 19:36:06 -04:00
parent 9ad09b593c
commit d47e9e260f
30 changed files with 917 additions and 162 deletions
@@ -384,8 +384,7 @@ export class ConversationView {
}
let row = this.transcript.get_last_child?.();
if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis') && !String(row.style_class || '').includes('jarvis-row-tool')) {
const body = String(row.text || '').replace(/^J\s+/, '');
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
if (spoken) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
this.streamingReply = false;
this.replyFinalized = true;
this.followActive();
+1 -1
View File
@@ -38,7 +38,7 @@ export class HarnessBridge extends EventEmitter {
...createPhase9GatewayTool(),
...tools,
],
builtinTools: ['read_file', 'write_file', 'search_replace', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'fetch_page', 'google_search', 'web_search', 'wiki_search', 'hn_search', 'code_search'],
builtinTools: ['read_file', 'write_file', 'search_replace', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'fetch_page', 'google_search', 'web_search', 'wiki_search', 'hn_search', 'code_search', 'todo_write', 'task', 'update_goal', 'memory_search', 'memory_get', 'memory_write'],
webFetch: true,
permissionMode,
origin: 'jarvis-qvac',
+39 -21
View File
@@ -16,6 +16,8 @@ let loadPromise = null;
let ownerCount = 0;
let operationTail = Promise.resolve();
const auxiliaryModels = new Map();
const auxiliaryLoads = new Map();
const auxiliaryOwners = new Map();
export const QVAC_MASTER = Object.freeze({
configPath: process.env.QVAC_CONFIG_PATH,
@@ -92,36 +94,51 @@ function resolveModelConfigAssets(sdk, config) {
return copy;
}
function retainAuxiliaryModel(modelId) {
auxiliaryOwners.set(modelId, (auxiliaryOwners.get(modelId) || 0) + 1);
return modelId;
}
/** Load ASR/TTS models in the same SDK worker and under the same master lock.
* These are auxiliary model IDs; they do not create another QVAC runtime. */
export async function loadAuxiliaryModel(name, modelConfig = {}, modelType = undefined) {
if (!name) throw new Error('auxiliary QVAC model name is required');
const existing = auxiliaryModels.get(String(name));
if (existing) return existing;
const sdk = await qvacSdk();
if (typeof sdk.loadModel !== 'function') throw new Error('QVAC SDK does not expose loadModel()');
const auxiliaryConfig = resolveModelConfigAssets(sdk, modelConfig);
// GPU placement belongs to the LLM model configuration. ASR and TTS have
// their own validated schemas and reject llama.cpp-only keys.
if (!modelType || modelType === 'llm') {
auxiliaryConfig.device = 'gpu';
auxiliaryConfig.gpu_layers = QVAC_MASTER.gpuLayers;
auxiliaryConfig['mmproj-use-gpu'] = true;
}
const loadOptions = {
modelSrc: resolveSdkAsset(sdk, name),
modelConfig: auxiliaryConfig,
};
if (modelType) loadOptions.modelType = modelType;
const modelId = await withQvacMaster(() => sdk.loadModel(loadOptions));
auxiliaryModels.set(String(name), modelId);
return modelId;
if (existing) return retainAuxiliaryModel(existing);
const key = String(name);
if (auxiliaryLoads.has(key)) return retainAuxiliaryModel(await auxiliaryLoads.get(key));
const pending = Promise.resolve().then(async () => {
const sdk = await qvacSdk();
if (typeof sdk.loadModel !== 'function') throw new Error('QVAC SDK does not expose loadModel()');
const auxiliaryConfig = resolveModelConfigAssets(sdk, modelConfig);
// GPU placement belongs to the LLM model configuration. ASR and TTS have
// their own validated schemas and reject llama.cpp-only keys.
if (!modelType || modelType === 'llm') {
auxiliaryConfig.device = 'gpu';
auxiliaryConfig.gpu_layers = QVAC_MASTER.gpuLayers;
auxiliaryConfig['mmproj-use-gpu'] = true;
}
const loadOptions = {
modelSrc: resolveSdkAsset(sdk, name),
modelConfig: auxiliaryConfig,
};
if (modelType) loadOptions.modelType = modelType;
const modelId = await withQvacMaster(() => sdk.loadModel(loadOptions));
auxiliaryModels.set(String(name), modelId);
return modelId;
});
auxiliaryLoads.set(key, pending);
try { return retainAuxiliaryModel(await pending); }
finally { if (auxiliaryLoads.get(key) === pending) auxiliaryLoads.delete(key); }
}
export async function unloadAuxiliaryModel(modelId) {
export async function unloadAuxiliaryModel(modelId, { force = false } = {}) {
if (!modelId) return;
const owners = auxiliaryOwners.get(modelId) || 0;
if (!force && owners > 1) { auxiliaryOwners.set(modelId, owners - 1); return; }
const sdk = await qvacSdk();
if (typeof sdk.unloadModel === 'function') await withQvacMaster(() => sdk.unloadModel({ modelId }));
auxiliaryOwners.delete(modelId);
for (const [name, id] of auxiliaryModels) if (id === modelId) auxiliaryModels.delete(name);
}
@@ -138,8 +155,9 @@ export function qvacBusy() {
export async function closeQvac() {
if (ownerCount > 0) return;
for (const modelId of auxiliaryModels.values()) await unloadAuxiliaryModel(modelId).catch(() => {});
for (const modelId of auxiliaryModels.values()) await unloadAuxiliaryModel(modelId, { force: true }).catch(() => {});
auxiliaryModels.clear();
auxiliaryOwners.clear();
loadPromise = null;
await Agent.engine.close();
}
+38 -2
View File
@@ -49,9 +49,45 @@ function speakableAddresses(text) {
.replace(/_/g, ' ');
}
export function stripMarkdownForSpeech(text) {
let s = String(text || '');
if (!s) return '';
s = s.replace(/```[\w-]*\r?\n?([\s\S]*?)```/g, (_, code) => ' ' + String(code || '').replace(/\s+/g, ' ').trim() + ' ');
s = s.replace(/`([^`]+)`/g, '$1');
s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ');
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
s = s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
s = s.replace(/^\s{0,3}>\s?/gm, '');
s = s.replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, '');
s = s.replace(/^\s*\|.*\|$/gm, (row) => row.replace(/\|/g, ' ').replace(/:?-{3,}:?/g, ' '));
s = s.replace(/^\s*(?:[-*_]){3,}\s*$/gm, '');
s = s.replace(/(\*\*|__)([\s\S]*?)\1/g, '$2');
s = s.replace(/\*([^*\n]+)\*/g, '$1');
s = s.replace(/~~([\s\S]*?)~~/g, '$1');
s = s.replace(/<\/?[A-Za-z][^>]*>/g, ' ');
return s.replace(/\n+/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
}
export function forChatDisplay(text) {
let s = String(text || '');
if (!s) return '';
s = s.replace(/```[\w-]*\r?\n?([\s\S]*?)```/g, '\n$1\n');
s = s.replace(/`([^`]+)`/g, '$1');
s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
s = s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
s = s.replace(/^\s{0,3}>\s?/gm, '');
s = s.replace(/^\s{0,3}[*+]\s+/gm, '- ');
s = s.replace(/(\*\*|__)([\s\S]*?)\1/g, '$2');
s = s.replace(/\*(?!\s)([^*\n]+?)(?<!\s)\*/g, '$1');
s = s.replace(/~~([\s\S]*?)~~/g, '$1');
s = s.replace(/\*{2}/g, '');
s = s.replace(/<\/?[A-Za-z][^>]*>/g, '');
return s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').replace(/[ \t]{2,}/g, ' ').replace(/ +$/gm, '').trim();
}
export function speakableForTts(text) {
return spellAcronyms(speakableAddresses(String(text || '')))
.replace(/[`]/g, "'")
return spellAcronyms(speakableAddresses(stripMarkdownForSpeech(text)))
.replace(/[<>]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
+1 -1
View File
@@ -34,7 +34,7 @@ export class QvacVoiceAdapter extends EventEmitter {
assertSdkVersion();
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
try {
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: this.settings.asrLanguage, no_timestamps: true, vad_params: { threshold: this.settings.vadThreshold, min_speech_duration_ms: this.settings.vadMinSpeechMs, min_silence_duration_ms: this.settings.vadSilenceMs, max_speech_duration_s: this.settings.vadMaxSpeechSeconds, speech_pad_ms: 200 } }, 'whispercpp-transcription');
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { contextParams: { use_gpu: true }, vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: this.settings.asrLanguage, no_timestamps: true, vad_params: { threshold: this.settings.vadThreshold, min_speech_duration_ms: this.settings.vadMinSpeechMs, min_silence_duration_ms: this.settings.vadSilenceMs, max_speech_duration_s: this.settings.vadMaxSpeechSeconds, speech_pad_ms: 200 } }, 'whispercpp-transcription');
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, this.ttsConfig.config, 'tts-ggml');
} catch (error) { await this.stop(); throw error; }
}
+27 -17
View File
@@ -198,6 +198,7 @@ export class VoiceLoop extends EventEmitter {
async speak(text) {
const generation = this._generation || 0;
if (this.tts && !this.status.tts && typeof this.tts.start === 'function') await this.ensureTts();
if (generation !== this._generation) return;
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) {
this._releaseSpeaking();
return;
@@ -209,32 +210,41 @@ export class VoiceLoop extends EventEmitter {
return;
}
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
let pending;
const prepare = (sentence) => Promise.resolve().then(async () => {
if (generation !== this._generation) return {};
const started = this.now();
const audio = await this.tts.speak(speakableForTts(sentence));
this.metrics.synthesis(this.now() - started, audio.samples.length * 1000 / audio.sampleRate);
return { audio };
}).catch((error) => ({ error }));
try {
if (generation !== this._generation) return;
for (const sentence of sentences) {
this.isSpeaking = true;
this.wake.pause();
pending = prepare(sentences[0]);
for (let i = 0; i < sentences.length; i++) {
const { audio, error } = await pending;
if (generation !== this._generation) return;
await this.speakSentence(sentence, generation);
if (error) throw error;
// Only one sentence ahead: overlap synthesis with playback, without
// parallel TTS requests or buffering an entire reply's audio.
pending = i + 1 < sentences.length ? prepare(sentences[i + 1]) : null;
await this.playback.play(audio.samples, audio.sampleRate);
try { this.daemon?.emit('SpeakingLevel', 0); } catch {}
}
} finally {
if (generation === this._generation) this._releaseSpeaking();
// A failed/interrupted playback may leave one synthesis in flight.
// Drain it before model teardown or the next queued utterance.
await pending;
if (generation === this._generation) {
this.cooldownUntil = this.now() + this.cooldownMs;
this._releaseSpeaking();
}
}
});
return this._speechQueue;
}
async speakSentence(text, generation) {
this.isSpeaking = true;
this.wake.pause();
try {
const audio = await this.tts.speak(speakableForTts(text));
if (generation !== this._generation) return;
await this.playback.play(audio.samples, audio.sampleRate);
try { this.daemon?.emit('SpeakingLevel', 0); } catch {}
} finally {
this.isSpeaking = false;
this.cooldownUntil = this.now() + this.cooldownMs;
this.wake.resume();
}
}
}
export { SentenceBuffer };
+7 -2
View File
@@ -1,9 +1,14 @@
export class VoiceMetrics {
constructor() { this.startedAt = Date.now(); this.wakeAccepts = 0; this.wakeRejects = 0; this.feedbackDrops = 0; this.utterances = 0; this.replies = 0; }
constructor() { this.startedAt = Date.now(); this.wakeAccepts = 0; this.wakeRejects = 0; this.feedbackDrops = 0; this.utterances = 0; this.replies = 0; this.synthesisCount = 0; this.synthesisMs = 0; this.audioMs = 0; }
wakeAccepted() { this.wakeAccepts += 1; }
wakeRejected() { this.wakeRejects += 1; }
feedbackDrop() { this.feedbackDrops += 1; }
utterance() { this.utterances += 1; }
reply() { this.replies += 1; }
snapshot() { return { uptimeMs: Date.now() - this.startedAt, wakeAccepts: this.wakeAccepts, wakeRejects: this.wakeRejects, feedbackDrops: this.feedbackDrops, utterances: this.utterances, replies: this.replies }; }
synthesis(elapsedMs, audioMs) {
this.synthesisCount += 1;
this.synthesisMs += Math.max(0, Number(elapsedMs) || 0);
this.audioMs += Math.max(0, Number(audioMs) || 0);
}
snapshot() { return { uptimeMs: Date.now() - this.startedAt, wakeAccepts: this.wakeAccepts, wakeRejects: this.wakeRejects, feedbackDrops: this.feedbackDrops, utterances: this.utterances, replies: this.replies, synthesisCount: this.synthesisCount, synthesisMs: this.synthesisMs, audioMs: this.audioMs, synthesisRealtimeFactor: this.audioMs > 0 ? this.synthesisMs / this.audioMs : null }; }
}
+5 -1
View File
@@ -63,6 +63,10 @@ cache directory, never under a system-wide writable location.
## GPU policy
`JARVIS_GPU_REQUIRED=1` is set by the service. The master requests GPU device
and maximum GPU layer offload, then rejects a result QVAC reports as CPU. Run
and maximum GPU layer offload. An explicit GPU request (or
`JARVIS_GPU_REQUIRED=1`) fails directly on a GPU load error rather than loading
a CPU copy that the master would reject. Optional `device: 'auto'` callers outside
that policy can still fall back, and runtime status labels that backend as CPU.
Run
`npm run gpu-doctor` and consult [hardware compatibility](hardware-compatibility.md)
before changing a model profile.
+26 -2
View File
@@ -10,9 +10,10 @@ flowchart LR
R --> W[Wake engine]
W -->|phrase| V[VAD + QVAC ASR]
V -->|final utterance| H[Harness bridge]
H -->|streamed reply| T[Sentence buffer]
H -->|final reply| T[Sentence queue]
T --> Q[QVAC TTS]
Q --> OUT[PipeWire playback]
OUT -. prepare next sentence concurrently .-> Q
OUT -. anti-feedback gate .-> V
```
@@ -44,7 +45,30 @@ very short fragments are discarded.
Run `npm run voice-doctor`, then follow [voice acceptance](voice-acceptance.md).
The daemon metrics cover wake accepts/rejects, feedback drops, utterances, and
replies. Raw audio is not persisted by default.
replies. Speech metrics also report `synthesisCount`, `synthesisMs`, `audioMs`,
and `synthesisRealtimeFactor` (total synthesis time / generated audio duration;
less than 1 means synthesis is faster than playback). Raw audio is not persisted
by default.
## Inference and playback concurrency
Whisper explicitly requests `contextParams.use_gpu: true`; it otherwise defaults
to CPU independently of the LLM's GPU policy. TTS honors the saved `ttsUseGpu`
setting. These engines use their own configuration fields, not llama.cpp's
`gpu_layers`. Concurrent requests to load the same auxiliary model share the
in-flight load, avoiding duplicate allocations. The shared model stays loaded
until its last auxiliary user releases it.
Speech synthesis prepares one sentence ahead while PipeWire plays the current
sentence. TTS requests and playback remain ordered, with at most one prefetched
sentence. The feedback gate stays active across sentence boundaries. Interrupts
discard prefetched audio; shutdown drains any in-flight synthesis before unloading
models. Synthesis and playback failures release the gate and allow the next reply.
Independent harness tool calls already run concurrently. Conversation inference
stays serialized to preserve history ordering and the single active chat model.
Increasing llama.cpp `parallel` also divides its configured context across slots;
it is not a free speed increase for a single conversational stream.
## Release voice readiness
+39
View File
@@ -0,0 +1,39 @@
// Run with: bash packaging/bare-launch.sh packaging/bare-run.js packaging/qwen-tool-smoke.js
// Uses a synthetic, read-only tool; no shell commands or external requests.
import { createRequire } from 'node:module';
import assert from 'node:assert';
const require = createRequire(import.meta.url);
const engine = require('../vendor/agent-harness/lib/qvac.js', { with: { imports: 'bare-node-runtime/imports' } });
const { FORMAT_REMINDER } = require('../vendor/agent-harness/lib/tool-parse.js');
process.env.QVAC_CONFIG_PATH ||= new URL('../qvac.config.json', import.meta.url).pathname;
const tools = [{ name: 'jarvis_status', description: 'Read the current diagnostic code for a named component.', parameters: { type: 'object', properties: { component: { type: 'string' } }, required: ['component'] } }];
try {
const loaded = await engine.load({ model: 'qwen3.5-0.8b', tools: true, device: 'gpu', gpu_layers: 99, mmprojUseGpu: true });
assert.equal(loaded.tools, true);
assert.equal(loaded.vision, true);
console.log(JSON.stringify({ loaded }));
const history = [
{ role: 'system', content: 'Use jarvis_status whenever a diagnostic code is requested. Never invent a code and never ask a clarifying question. After the tool returns, answer with its code.\n' + FORMAT_REMINDER },
{ role: 'user', content: 'Call jarvis_status with component set to speaker and report the diagnostic code it returns.' },
];
const options = { tools, timeoutMs: 90000, idleMs: 30000, generationParams: { predict: 256 } };
const first = await engine.complete({ ...options, history });
console.log(JSON.stringify({ first }));
assert.equal(first.stopReason, 'stop');
assert.equal(first.toolCalls.length, 1);
const call = first.toolCalls[0];
assert.equal(call.name, 'jarvis_status');
const args = typeof call.arguments === 'string' ? JSON.parse(call.arguments) : call.arguments;
assert.equal(args.component, 'speaker');
call.id ||= 'smoke_status_1';
history.push({ role: 'assistant', content: first.text || '', tool_calls: [call] });
history.push({ role: 'tool', name: call.name, tool_call_id: call.id, content: JSON.stringify({ code: 'SPEAKER-7429' }) });
const second = await engine.complete({ ...options, history });
console.log(JSON.stringify({ second }));
assert.equal(second.stopReason, 'stop');
assert.equal(second.toolCalls.length, 0);
assert.ok(/SPEAKER-7429/.test(second.text));
console.log('QWEN_TOOL_SMOKE_PASS');
} finally {
await engine.close();
}
+6 -5
View File
@@ -1,3 +1,5 @@
import { forChatDisplay } from '../daemon/transcript.js';
export function voiceSystemPrompt(name = 'Jarvis', extra = '') {
const who = String(name || 'Jarvis').trim() || 'Jarvis';
const notes = String(extra || '').replace(/\0/g, '').trim();
@@ -16,8 +18,7 @@ The acting instructions are your identity. They override SOUL.md, IDENTITY.md, a
The language model is Quantum Verse Automatic Computer, spelled Q V A C. In speech say Quantum Verse Automatic Computer, or spell it as Q V A C. Never say QVAC as one word.
Speak one to three short sentences unless the user asks for more. Every reply is read aloud. Write only words and numbers a person can say.
Reply in plain text only. Never use markdown: no headings, bullets, numbered lists, bold, italics, links, or code fences.
Speak one to three short sentences unless the user asks for more. Keep a space between every word. Chat may use short paragraphs and a short list. Never wrap words in asterisks, backticks, or other markup. Text to speech reads the words, not the markup, so never omit spaces and never write words jammed together.
Never use acronyms as a single spoken word. Spell them as separate letters, for example C P U, G P U, I P, U R L, H T T P, R A M, S S D, U S B, D N S, I S P. Prefer full words when they exist.
Never speak punctuation. Internet protocol addresses have no dots: say 192 168 0 1. Host names use the word dot. Paths use the word slash. Colons, underscores, hyphens, and at signs are the words colon, underscore, dash, and at.
@@ -26,8 +27,8 @@ Thinking is private. After thoughts, call a tool or speak the answer. Do not sto
${followFiles}
Ground desktop, file, memory, model, and network claims in a tool result. Do not invent limits the tools did not report.
This computer can reach the internet. web_search, google_search, fetch_page, web_fetch, wiki_search, hn_search, and code_search are unrestricted and do not wait for confirmation. Never say you will use a tool. Call the tool instead of announcing it. Use web_search to find pages. Default engine auto tries several backends. If it fails, call web_search again with engine set to bing, jina, wikipedia, duckduckgo, or google. After web_search, call fetch_page on one real http or https page from the hits, then speak the answer. Use web_fetch for raw pages and this computer's public I P at https://ifconfig.me/ip. Use wiki_search, hn_search, or code_search when the question is about Wikipedia, Hacker News, GitHub, npm, or M D N. Redirect links are not an answer. Do not use curl, wget, or run_terminal_cmd for websites. The shell blocks public H T T P; that is not a network outage. If a shell result says HTTP access is not allowed, call web_fetch or web_search next and answer from that result. Never say the network is unavailable unless web_fetch or web_search itself failed.
If you still need a fact after a search, call web_search or fetch_page again. To track a follow-up, call todo_write. Do not repeat a sentence. When you know the answer, speak it and stop.
This computer can reach the internet. web_search, google_search, fetch_page, web_fetch, wiki_search, hn_search, and code_search are unrestricted and do not wait for confirmation. Never say you will use a tool. Call the tool instead of announcing it. Use web_search to find pages. Default engine auto tries several backends quickly. If it returns no results, you may pin engine once to bing, jina, wikipedia, duckduckgo, or google. If a search or fetch times out or errors, say you could not reach the web and stop. Do not keep searching the same query. After web_search, call fetch_page on one real http or https page from the hits, then speak the answer. Use web_fetch for raw pages and this computer's public I P at https://ifconfig.me/ip. Use wiki_search, hn_search, or code_search when the question is about Wikipedia, Hacker News, GitHub, npm, or M D N. Redirect links are not an answer. Do not use curl, wget, or run_terminal_cmd for websites. The shell blocks public H T T P; that is not a network outage. If a shell result says HTTP access is not allowed, call web_fetch or web_search next and answer from that result. Never say the network is unavailable unless web_fetch or web_search itself failed.
File tools may read any path they accept. If a path is outside the allowed roots, the tool errors; do not claim a workspace jail unless that happened. Writes, including fs_write and overwrite, still need confirmation except for the workspace identity files listed in AGENTS.md.
@@ -66,5 +67,5 @@ export function spokenReply(reply) {
.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
.replace(/<function\s*=[^>]*>[\s\S]*?<\/function>/gi, '')
.replace(/<tool_call>[\s\S]*$/gi, '');
return parseHudSidecar(stripped).spoken;
return forChatDisplay(parseHudSidecar(stripped).spoken);
}
+30 -4
View File
@@ -222,6 +222,35 @@ test('sidecar context is reserved in the history budget', () => {
assert.equal(compaction.historyBudget(8192, [], 0) - compaction.historyBudget(8192, [], 0, 512), 512);
});
test('voice sessions keep a short spoken tail even on large model windows', () => {
assert.equal(compaction.voiceKeepTokens(4096), 1024);
assert.equal(compaction.voiceKeepTokens(16384), 1024);
assert.equal(compaction.voiceKeepTokens(32768), 1024);
assert.ok(compaction.voiceKeepTokens(2048) <= 573);
const sys = { role: 'system', content: 'You are Jarvis. '.repeat(40) };
const short = [
sys,
{ role: 'assistant', content: 'Hello!' },
{ role: 'user', content: 'What time is it?' },
];
assert.equal(compaction.shouldCompact(short, [], 32768, 0, { voice: true }), false);
const long = [
sys,
{ role: 'user', content: 'Remember nest.' },
{ role: 'assistant', content: 'details '.repeat(2000) },
{ role: 'user', content: 'And the hostname?' },
{ role: 'assistant', content: 'Still nest. ' + 'padding '.repeat(200) },
];
assert.equal(compaction.shouldCompact(long, [], 32768, 0, { voice: true }), true);
const sysTok = compaction.conversationTokens([sys]);
const budget = compaction.historyBudget(32768, [], 0, 0, { voice: true, systemTokens: sysTok });
assert.ok(budget <= sysTok + compaction.VOICE_KEEP_TOKENS);
assert.ok(budget < compaction.historyBudget(32768, [], 0));
const out = compaction.compact(long, { budgetTokens: budget, voice: true });
assert.ok(compaction.dialogTokens(out) <= compaction.VOICE_KEEP_TOKENS + 32);
assert.ok(out.some((m) => m.content === 'And the hostname?'));
});
for (const overflow of [false, true]) {
test(`turn loop persists compaction and sends one-shot reminders through retries (overflow=${overflow})`, async (t) => {
const { mkdtempSync, rmSync } = await import('node:fs');
@@ -260,13 +289,11 @@ for (const overflow of [false, true]) {
{ role: 'user', content: 'Remember the hostname.' },
{ role: 'assistant', content: 'I will remember nest.' },
]);
let summaries = 0;
let completions = 0;
const promptSizes = [];
engine.complete = async ({ history }) => {
if (history[0].content.startsWith('Reply with the four summary sections')) {
summaries++;
return { text: 'The computer is named nest. The user wants brief answers. We have checked memory and should continue answering follow-up questions about this computer.' };
throw new Error('voice turns must not spend a completion on LLM summarization');
}
completions++;
promptSizes.push(compaction.conversationTokens(history));
@@ -285,7 +312,6 @@ for (const overflow of [false, true]) {
emit: (_kind, event) => updates.push(event),
});
assert.equal(result.text, 'Its hostname is nest.');
assert.equal(summaries, 1);
assert.equal(completions, overflow ? 2 : 1);
if (overflow) assert.ok(promptSizes[1] < promptSizes[0], 'overflow recovery must actually shrink the prompt');
assert.ok(updates.some((ev) => ev.type === 'compaction' && ev.status === 'done'));
+10
View File
@@ -48,6 +48,10 @@ test('spoken replies use harness text and strip HUD sidecars', () => {
);
assert.equal(spokenReply({}), '');
assert.equal(spokenReply('[object Object]'), '');
assert.equal(
spokenReply({ text: '## Status\n- CPU is **fine**.\nUse `htop` if you want more.' }),
'Status\n- CPU is fine.\nUse htop if you want more.',
);
});
test('voice settings default TTS on and honor an explicit disable', () => {
@@ -101,6 +105,12 @@ test('harness bridge caps voice shell chaining', () => {
'wiki_search',
'hn_search',
'code_search',
'todo_write',
'task',
'update_goal',
'memory_search',
'memory_get',
'memory_write',
]);
assert.equal(bridge.options.webFetch, true);
});
+12
View File
@@ -147,6 +147,18 @@ test('empty chrome widgets start hidden', () => {
assert.equal(cu.cursor.visible, false);
});
test('final reply replaces mashed streaming tokens', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.token('Greetings,');
popup.token('young');
popup.token('Rebel.');
popup.finalizeReply('Greetings, young Rebel.');
assert.equal(popup.transcript.children.length, 1);
assert.match(popup.transcript.children[0].text, /Greetings, young Rebel/);
assert.doesNotMatch(popup.transcript.children[0].text, /Greetings,youngRebel/);
});
test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import vm from 'node:vm';
const enginePath = new URL('../vendor/agent-harness/lib/qvac.js', import.meta.url);
const source = readFileSync(enginePath, 'utf8');
const require = createRequire(enginePath);
function engineHarness(required) {
const attempts = [];
const fakeSdk = {
getSystemResources: async () => ({ gpus: [{ name: 'Test GPU', memory: 16e9 }], drivers: { vulkan: true }, vramBytes: 16e9 }),
lookupModelSrc: async () => 'test-model.gguf',
loadModel: async ({ modelConfig }) => {
attempts.push({ ...modelConfig });
if (modelConfig.device === 'gpu') throw new Error('GPU allocation failed');
return 'cpu-model';
},
};
const context = vm.createContext({ require, module: { exports: {} }, process: { env: { JARVIS_GPU_REQUIRED: required ? '1' : '0' } }, console: { error() {} }, fakeSdk });
vm.runInContext(source + '\nsdk = fakeSdk;', context);
return { engine: context.module.exports, attempts };
}
for (const [requested, required] of [['gpu', false], ['auto', true]]) {
test(`GPU-required loading does not waste a CPU reload (${requested}, required=${required})`, async () => {
const { engine, attempts } = engineHarness(required);
await assert.rejects(engine.load({ model: 'test-model', device: requested, vision: false }), /GPU allocation failed/);
assert.equal(attempts.length, 1);
assert.equal(attempts[0].gpu_layers, 99);
});
}
test('optional automatic CPU fallback reports its actual backend', async () => {
const { engine, attempts } = engineHarness(false);
const loaded = await engine.load({ model: 'test-model', device: 'auto', vision: false });
assert.deepEqual(attempts.map((config) => config.device), ['gpu', 'cpu']);
assert.equal(loaded.device, 'cpu');
assert.equal(loaded.backend, 'cpu');
assert.equal(loaded.backendId, 0);
});
+44
View File
@@ -19,3 +19,47 @@ test('concurrent master acquisition loads once and failed acquisition does not l
await acquireQvac(); assert.equal(loads, 2); releaseQvac();
} finally { await closeQvac(); Object.assign(Agent.engine, original); }
});
test('concurrent auxiliary loads share one model and failures permit retry', async () => {
const { loadAuxiliaryModel, unloadAuxiliaryModel } = await import('../daemon/qvac-master.js');
const original = Agent.engine.ensureInit;
let loads = 0, unloads = 0, fail = false;
Agent.engine.ensureInit = async () => ({
loadModel: async () => { loads++; if (fail) throw new Error('load failed'); return `aux-${loads}`; },
unloadModel: async () => { unloads++; },
});
try {
const ids = await Promise.all([loadAuxiliaryModel('shared-asr', {}, 'whispercpp-transcription'), loadAuxiliaryModel('shared-asr', {}, 'whispercpp-transcription')]);
assert.equal(loads, 1);
assert.equal(ids[0], ids[1]);
await unloadAuxiliaryModel(ids[0]);
assert.equal(unloads, 0, 'the second user still owns the shared model');
await unloadAuxiliaryModel(ids[1]);
assert.equal(unloads, 1);
fail = true;
const failures = await Promise.allSettled([loadAuxiliaryModel('retry-asr'), loadAuxiliaryModel('retry-asr')]);
assert.ok(failures.every((result) => result.status === 'rejected'));
assert.equal(loads, 2);
fail = false;
await unloadAuxiliaryModel(await loadAuxiliaryModel('retry-asr'));
assert.equal(loads, 3);
assert.equal(qvacStatus().auxiliaryModels, 0);
} finally { Agent.engine.ensureInit = original; }
});
test('Whisper requests GPU through its own context configuration', async () => {
const { QvacVoiceAdapter } = await import('../daemon/voice-adapters.js');
const original = Agent.engine.ensureInit;
let loaded;
Agent.engine.ensureInit = async () => ({
loadModel: async (options) => { loaded = options; return 'gpu-asr'; },
unloadModel: async () => {},
});
const adapter = new QvacVoiceAdapter({ role: 'asr' });
try {
await adapter.start();
assert.equal(loaded.modelType, 'whispercpp-transcription');
assert.equal(loaded.modelConfig.contextParams.use_gpu, true);
assert.equal(loaded.modelConfig.gpu_layers, undefined);
} finally { await adapter.stop(); Agent.engine.ensureInit = original; }
});
+32 -3
View File
@@ -27,6 +27,31 @@ test('voice sidecars are removed from speech and retained for the HUD', () => {
assert.equal(parsed.hud.title, 'Done');
});
test('web_search and fetch_page stop at the overall budget instead of hanging', async () => {
const require = createRequire(import.meta.url);
const tools = require('../vendor/agent-harness/agent/tools.js');
const orig = globalThis.fetch;
globalThis.fetch = () => new Promise(() => {});
try {
const started = Date.now();
const search = await tools.runWebSearch('example', { timeoutMs: 80 });
const searchMs = Date.now() - started;
assert.match(String(search.error), /timed out/i);
assert.ok(searchMs >= 40, 'search returned too fast: ' + searchMs + 'ms');
assert.ok(searchMs < 500, 'search hung for ' + searchMs + 'ms');
assert.ok(Array.isArray(search.tried) && search.tried.length >= 1);
const pageStarted = Date.now();
const page = await tools.fetchPage('https://example.com/article', 80);
const pageMs = Date.now() - pageStarted;
assert.match(String(page.error), /timed out/i);
assert.ok(pageMs >= 40, 'fetch_page returned too fast: ' + pageMs + 'ms');
assert.ok(pageMs < 500, 'fetch_page hung for ' + pageMs + 'ms');
} finally {
globalThis.fetch = orig;
}
});
test('web_fetch times out instead of hanging the turn', async () => {
const require = createRequire(import.meta.url);
const tools = require('../vendor/agent-harness/agent/tools.js');
@@ -181,8 +206,8 @@ test('public web search and fetch do not require confirmation', () => {
test('voice prompt tells the model not to chain extra terminal commands', () => {
assert.match(VOICE_SYSTEM_PROMPT, /call run_terminal_cmd once/);
assert.match(VOICE_SYSTEM_PROMPT, /Do not chain extra commands/);
assert.match(VOICE_SYSTEM_PROMPT, /Reply in plain text only/);
assert.match(VOICE_SYSTEM_PROMPT, /Never use markdown/);
assert.match(VOICE_SYSTEM_PROMPT, /Keep a space between every word/);
assert.match(VOICE_SYSTEM_PROMPT, /Never wrap words in asterisks/);
assert.match(VOICE_SYSTEM_PROMPT, /Quantum Verse Automatic Computer/);
assert.match(VOICE_SYSTEM_PROMPT, /spell it as Q V A C/);
assert.match(VOICE_SYSTEM_PROMPT, /Internet protocol addresses have no dots/);
@@ -190,7 +215,11 @@ test('voice prompt tells the model not to chain extra terminal commands', () =>
assert.match(VOICE_SYSTEM_PROMPT, /web_fetch/);
assert.match(VOICE_SYSTEM_PROMPT, /web_search, google_search, fetch_page, web_fetch, wiki_search, hn_search, and code_search are unrestricted/);
assert.match(VOICE_SYSTEM_PROMPT, /Use web_search to find pages/);
assert.match(VOICE_SYSTEM_PROMPT, /Never say you will use a tool/);
assert.match(VOICE_SYSTEM_PROMPT, /call web_search or fetch_page again/);
assert.match(VOICE_SYSTEM_PROMPT, /times out or errors/);
assert.match(VOICE_SYSTEM_PROMPT, /Do not keep searching the same query/);
assert.match(VOICE_SYSTEM_PROMPT, /call todo_write/);
assert.match(VOICE_SYSTEM_PROMPT, /Do not repeat a sentence/);
assert.match(VOICE_SYSTEM_PROMPT, /Do not stop in thoughts/);
assert.match(VOICE_SYSTEM_PROMPT, /ifconfig\.me\/ip/);
assert.match(VOICE_SYSTEM_PROMPT, /This computer can reach the internet/);
+11
View File
@@ -70,6 +70,17 @@ test('complete watch idle is reset by bump', async () => {
w.clear();
});
test('repeat-loop stops a cloned spoken tail and keeps one copy', () => {
const repeatLoop = require('../vendor/agent-harness/lib/repeat-loop.js');
const unit = 'The public address is 203 0 113 8. ';
const looping = unit.repeat(6);
assert.equal(repeatLoop.isRepeating('Short answer.'), false);
assert.equal(repeatLoop.isRepeating(looping), true);
const collapsed = repeatLoop.collapseRepeats(looping);
assert.ok(collapsed.startsWith('The public address is 203 0 113 8.'));
assert.ok(collapsed.length < looping.length / 2);
});
test('html pages are stripped to text for web_fetch', () => {
const text = tools.htmlToText('<!DOCTYPE html><html><head><title>Hi</title></head><body><p>Honey peer</p></body></html>');
assert.match(text, /Honey peer/);
+53 -2
View File
@@ -7,6 +7,38 @@ const require = createRequire(import.meta.url);
const catalog = require('../vendor/agent-harness/lib/catalog.js');
const toolParse = require('../vendor/agent-harness/lib/tool-parse.js');
function qvacTransport(history) {
return history.map((message) => ({ role: message.role, content: message.content }));
}
test('Qwen tool history replays calls through the QVAC role/content transport', () => {
const history = [
{ role: 'assistant', content: '', tool_calls: [{ id: 'c1', name: 'jarvis_status', arguments: { component: 'speaker' } }] },
{ role: 'tool', tool_call_id: 'c1', content: '{"code":"SPEAKER-7429"}' },
{ role: 'assistant', content: 'Checking.', tool_calls: [{ type: 'function', function: { name: 'lookup', arguments: '{"count":2,"enabled":false}' } }] },
];
const prepared = toolParse.prepareToolHistory(history, 'qwen35');
const xml = toolParse.qwen35ToolCallXml({ name: 'jarvis_status', arguments: { component: 'speaker' } });
assert.match(xml, /^<tool_call>\n<function=jarvis_status>\n<parameter=component>\nspeaker\n<\/parameter>\n<\/function>\n<\/tool_call>$/);
assert.match(prepared[0].content, /<function=jarvis_status>/);
assert.match(prepared[0].content, /<parameter=component>\nspeaker\n<\/parameter>/);
assert.match(prepared[2].content, /<parameter=enabled>\nfalse\n<\/parameter>/);
assert.equal(history[0].content, '');
assert.equal(prepared[1], history[1]);
assert.deepEqual(toolParse.prepareToolHistory(prepared, 'qwen35'), prepared);
assert.equal(toolParse.prepareToolHistory(history, 'hermes'), history);
const transported = qvacTransport(prepared);
assert.deepEqual(Object.keys(transported[0]).sort(), ['content', 'role']);
const recovered = toolParse.extractCalls(transported[0].content, [{ name: 'jarvis_status' }]);
assert.equal(recovered.length, 1);
assert.equal(recovered[0].name, 'jarvis_status');
assert.equal(recovered[0].arguments.component, 'speaker');
assert.equal(transported[1].role, 'tool');
assert.equal(transported[1].content, '{"code":"SPEAKER-7429"}');
assert.match(transported[2].content, /<tool_call>[\s\S]*<function=lookup>/);
});
test('Qwen3.5 0.8B and 2B use the qwen35 tool dialect and compact-tool reminder', () => {
assert.equal(profile('laptop-4gb-mm').model, 'qwen3.5-0.8b');
assert.equal(profile('laptop-8gb-mm').model, 'qwen3.5-2b');
@@ -14,13 +46,32 @@ test('Qwen3.5 0.8B and 2B use the qwen35 tool dialect and compact-tool reminder'
assert.equal(catalog.toolDialectFor(id), 'qwen35');
assert.equal(catalog.findCatalogEntry(id).tools, true);
assert.equal(catalog.isCompactToolModel(id), true);
assert.equal(catalog.findCatalogEntry(id).ctxSize, 16384);
}
assert.match(toolParse.FORMAT_REMINDER, /<function=TOOL_NAME>/);
assert.deepEqual(catalog.compactGenerationParams('qwen3.5-0.8b'), { temp: 0.55 });
assert.deepEqual(catalog.compactGenerationParams('qwen3.5-2b'), { temp: 0.55 });
assert.deepEqual(catalog.compactGenerationParams('qwen3.5-4b'), {});
assert.equal(catalog.reasoningBudgetForModel('qwen3.5-0.8b'), 128);
assert.equal(catalog.reasoningBudgetForModel('qwen3.5-2b'), 192);
assert.equal(catalog.reasoningBudgetForModel('qwen3.5-4b'), 320);
assert.equal(catalog.reasoningBudgetForModel('qwen3.5-9b'), 448);
assert.equal(catalog.reasoningBudgetForModel('gemma4-4b'), null);
const compactGen = catalog.generationParamsForModel('qwen3.5-0.8b', { predict: 512, seed: 7 });
assert.equal(compactGen.temp, 0.55);
assert.equal(compactGen.reasoning_budget, 128);
assert.equal(compactGen.repeat_penalty, 1.15);
assert.equal(compactGen.predict, 512);
assert.equal(compactGen.seed, 7);
const largeGen = catalog.generationParamsForModel('qwen3.5-4b', { predict: 512 });
assert.equal(largeGen.reasoning_budget, 320);
assert.equal(largeGen.predict, 512);
assert.equal(largeGen.repeat_penalty, 1.15);
const tiny = catalog.filterToolsForModel(
[{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
[{ name: 'web_search' }, { name: 'todo_write' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b',
);
assert.deepEqual(tiny.map((t) => t.name), ['web_search']);
assert.deepEqual(tiny.map((t) => t.name), ['web_search', 'todo_write']);
});
test('Qwen3.5 compact models recover tool calls nested in think tags', () => {
+88 -2
View File
@@ -7,19 +7,27 @@ import { pcmS16le } from '../daemon/voice-adapters.js';
import { WakeEngine } from '../daemon/wake-engine.js';
import { VadSegmenter } from '../daemon/vad.js';
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
import { SentenceBuffer, isMeaningfulTranscript, isSpeakable, speakableForTts } from '../daemon/transcript.js';
import { SentenceBuffer, isMeaningfulTranscript, isSpeakable, speakableForTts, stripMarkdownForSpeech, forChatDisplay } from '../daemon/transcript.js';
test('Phase 4 transcript filtering and sentence buffering are deterministic', () => {
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
assert.equal(isMeaningfulTranscript('hi'), false);
assert.equal(isSpeakable('OK.'), true);
assert.equal(isSpeakable('[object Object]'), false);
assert.equal(speakableForTts('Using `run_terminal_cmd`'), "Using 'run terminal cmd'");
assert.equal(speakableForTts('Using `run_terminal_cmd`'), 'Using run terminal cmd');
assert.equal(speakableForTts('Your IP is 192.168.0.1'), 'Your I P is 192 168 0 1');
assert.equal(
speakableForTts('QVAC fetched https://example.com/ip'),
'Quantum Verse Automatic Computer fetched example dot com slash I P'
);
assert.equal(
speakableForTts('## Hello\n- First item\n- **Second** item\nSee [docs](https://example.com).'),
'Hello First item Second item See docs.'
);
assert.equal(forChatDisplay('Greetings, **young rebel**.\nHow may I serve you?'), 'Greetings, young rebel.\nHow may I serve you?');
assert.equal(forChatDisplay('Greetings, **Raven**. Next line.'), 'Greetings, Raven. Next line.');
assert.equal(forChatDisplay('Use 2 * 3, not **four**.'), 'Use 2 * 3, not four.');
assert.equal(stripMarkdownForSpeech('Greetings, **young rebel**.\nHow may I serve you?'), 'Greetings, young rebel. How may I serve you?');
assert.equal(isMeaningfulTranscript('what time is it'), true);
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
buffer.push('First sentence. Second'); buffer.push(' sentence!'); buffer.flush();
@@ -273,3 +281,81 @@ test('voice start keeps the microphone hot without loading ASR or TTS', async ()
assert.equal(loop.status.tts, false);
await loop.stop();
});
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function pipelineLoop(tts, playback) {
const capture = new EventEmitter(); capture.start = () => {}; capture.stop = () => {};
const loop = new VoiceLoop({ capture, wake: new WakeEngine({ detect: () => null }), tts, playback });
loop.status.tts = true;
return loop;
}
const drainMicrotasks = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
test('speech prepares only the next sentence during playback, preserving order and feedback gating', async () => {
const spoken = [], played = [];
const gates = [deferred(), deferred(), deferred()];
const loop = pipelineLoop({ speak: async (text) => {
spoken.push(text);
return { samples: Int16Array.of(spoken.length), sampleRate: 16000 };
} }, { play: async (samples) => {
played.push(samples[0]);
await gates[played.length - 1].promise;
}, stop() {} });
const task = loop.speak('First sentence. Second sentence. Third sentence.');
await drainMicrotasks();
assert.equal(spoken.length, 2);
assert.deepEqual(played, [1]);
assert.equal(loop.isSpeaking, true);
gates[0].resolve();
await drainMicrotasks();
assert.equal(spoken.length, 3);
assert.deepEqual(played, [1, 2]);
assert.equal(loop.isSpeaking, true);
gates[1].resolve();
await drainMicrotasks();
assert.deepEqual(played, [1, 2, 3]);
gates[2].resolve();
await task;
assert.equal(loop.isSpeaking, false);
assert.equal(loop.metrics.snapshot().synthesisCount, 3);
});
test('interrupt discards prefetched audio and drains synthesis before speech completes', async () => {
const prefetch = deferred(), playing = deferred();
let synthesized = 0, played = 0, finished = false;
const loop = pipelineLoop({ speak: async () => {
if (++synthesized === 2) await prefetch.promise;
return { samples: Int16Array.of(synthesized), sampleRate: 16000 };
} }, { play: async () => { played++; await playing.promise; }, stop() { playing.resolve(); } });
const task = loop.speak('First sentence. Second sentence. Third sentence.').then(() => { finished = true; });
await drainMicrotasks();
loop.interrupt();
await drainMicrotasks();
assert.equal(finished, false);
prefetch.resolve();
await task;
assert.equal(played, 1);
assert.equal(synthesized, 2);
assert.equal(loop.isSpeaking, false);
});
for (const failure of ['synthesis', 'playback']) {
test(`speech pipeline handles ${failure} failure and accepts the next reply`, async () => {
let synthesized = 0;
const loop = pipelineLoop({ speak: async () => {
if (++synthesized === 2 && failure === 'synthesis') throw new Error('synthesis failed');
return { samples: Int16Array.of(synthesized), sampleRate: 16000 };
} }, { play: async () => { if (failure === 'playback') throw new Error('playback failed'); }, stop() {} });
await assert.rejects(loop.speak('First sentence. Second sentence.'), /failed/);
assert.equal(loop.isSpeaking, false);
loop.playback.play = async () => {};
await loop.speak('Recovery sentence.');
assert.equal(loop.isSpeaking, false);
});
}
+29 -4
View File
@@ -11,6 +11,9 @@ const CHAR_PER_TOKEN = 3;
const THRESHOLD = 0.68;
const MIN_SUMMARY = 80;
const MIN_COMPACT_MESSAGES = 4;
// Spoken sessions only need the last few exchanges. Prefill cost tracks the
// prompt, so a 32k model must not keep a 20k-token voice transcript.
const VOICE_KEEP_TOKENS = 1024;
const COMPACT_PROMPT =
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
'1. Goal\n' +
@@ -75,19 +78,38 @@ function estimateTokens(messages, tools) {
return conversationTokens(messages) + toolTokens(tools);
}
function historyBudget(ctxSize, tools, attempt, extraTokens = 0) {
function dialogTokens(messages) {
return conversationTokens((messages || []).filter((m) => m && m.role !== 'system'));
}
function voiceKeepTokens(ctxSize) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
return Math.min(VOICE_KEEP_TOKENS, Math.max(480, Math.floor(cap * 0.28)));
}
function historyBudget(ctxSize, tools, attempt, extraTokens = 0, opts = {}) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
const toolTok = toolTokens(tools);
const reserve = Math.max(384, Math.floor(cap * (0.18 + (Number(attempt) || 0) * 0.08)));
return Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve - extraTokens);
let budget = Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve - extraTokens);
if (opts && opts.voice) {
const sysTok = Math.max(0, Number(opts.systemTokens) || 0);
budget = Math.min(budget, Math.max(240, sysTok + voiceKeepTokens(cap) - extraTokens));
}
return Math.max(240, budget);
}
function shouldCompact(messages, tools, ctxSize, extraTokens = 0) {
function shouldCompact(messages, tools, ctxSize, extraTokens = 0, opts = {}) {
const dialog = dialogTokens(messages);
const enough = nonSystemCount(messages) >= MIN_COMPACT_MESSAGES || dialog > 1024;
if (opts && opts.voice) {
return dialog > voiceKeepTokens(ctxSize) && enough;
}
const budget = historyBudget(ctxSize, tools, 0, extraTokens);
const tokens = conversationTokens(messages);
// Large first requests or tool results can overflow before four messages.
// A small greeting must not be discarded merely because schemas are large.
return tokens > budget && (nonSystemCount(messages) >= MIN_COMPACT_MESSAGES || conversationTokens((messages || []).filter((m) => m.role !== 'system')) > 1024);
return tokens > budget && enough;
}
function isOverflowError(err) {
@@ -329,11 +351,14 @@ module.exports = {
THRESHOLD,
MIN_SUMMARY,
MIN_COMPACT_MESSAGES,
VOICE_KEEP_TOKENS,
COMPACT_PROMPT,
VOICE_COMPACT_PROMPT,
conversationTokens,
dialogTokens,
toolTokens,
estimateTokens,
voiceKeepTokens,
historyBudget,
shouldCompact,
nonSystemCount,
+27 -11
View File
@@ -72,6 +72,22 @@ function loadedCtxSize() {
return (loaded && loaded.ctxSize) || 8192;
}
function systemTokens(history) {
if (history && history[0] && history[0].role === 'system') return compaction.conversationTokens([history[0]]);
return 0;
}
function historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, attempt) {
return {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, attempt || 0, sidecarTokens, {
voice: !!budget.voice,
systemTokens: systemTokens(session.history),
}),
tools: toolDefs,
voice: !!budget.voice,
};
}
function toolResultCap(budget) {
const ctx = loadedCtxSize();
const voice = !!(budget && budget.voice);
@@ -608,22 +624,20 @@ async function runTurn(ctx) {
const sidecarTokens = compaction.conversationTokens(turnSidecars) + 256;
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage));
if (compaction.shouldCompact(session.history, toolDefs, ctxSize, sidecarTokens)) {
if (compaction.shouldCompact(session.history, toolDefs, ctxSize, sidecarTokens, { voice: !!budget.voice })) {
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'start',
method: 'llm',
method: budget.voice ? 'heuristic' : 'llm',
used: beforeUsage.used,
limit: beforeUsage.limit,
pct: beforeUsage.pct,
threshold: beforeUsage.threshold,
});
const compactOpts = {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0, sidecarTokens),
tools: toolDefs,
voice: !!budget.voice,
};
session.history = await compaction.compactWithLlm(session.history, Object.assign({}, compactOpts, {
const compactOpts = historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, 0);
session.history = budget.voice
? compaction.compact(session.history, compactOpts)
: await compaction.compactWithLlm(session.history, Object.assign({}, compactOpts, {
complete: (opts) => engine.complete(Object.assign({}, opts, {
desktopVision: false,
timeoutMs: budget.completeTimeoutMs,
@@ -643,7 +657,7 @@ async function runTurn(ctx) {
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'done',
method: 'llm',
method: budget.voice ? 'heuristic' : 'llm',
used: afterUsage.used,
limit: afterUsage.limit,
pct: afterUsage.pct,
@@ -715,7 +729,7 @@ async function runTurn(ctx) {
// The estimate can be lower than the model's tokenizer count.
// Every overflow retry must shrink even an apparently small history.
budgetTokens: Math.min(
compaction.historyBudget(ctxSize, toolDefs, overflowTry + 1, sidecarTokens),
historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, overflowTry + 1).budgetTokens,
Math.max(1, Math.floor(compaction.conversationTokens(session.history) * 0.85))
),
tools: toolDefs,
@@ -863,7 +877,9 @@ async function runTurn(ctx) {
}
if (stopEarly) break;
}
if (prepared.length) toolBudget.markToolRound(budget);
if (prepared.some((item) => item.name !== 'todo_write' && item.name !== 'update_goal')) {
toolBudget.markToolRound(budget);
}
if (stopEarly) {
return endTurn(emit, session, jobId, tracker, stopEarly);
}
+1 -1
View File
@@ -18,7 +18,7 @@ function fromPayload(payload, origin) {
voice,
maxTurns: num(payload.maxTurns, voice ? 6 : 24),
maxShellCalls: unlimitedShell ? 0 : num(payload.maxShellCalls, voice ? 1 : 0),
maxToolRounds: num(payload.maxToolRounds, voice ? 4 : 0),
maxToolRounds: num(payload.maxToolRounds, voice ? 6 : 0),
completeTimeoutMs: voice ? 45000 : 0,
completeIdleMs: voice ? 10000 : 0,
shellCalls: 0,
+161 -68
View File
@@ -7,8 +7,10 @@
const net = require('../lib/net.js');
const truncate = require('./truncate.js');
const WEB_TIMEOUT_MS = 12000;
const PAGE_TIMEOUT_MS = 20000;
const WEB_TIMEOUT_MS = 3500;
const PAGE_TIMEOUT_MS = 8000;
const SEARCH_BUDGET_MS = 8000;
const ENGINE_TIMEOUT_MS = 3000;
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const GOOGLE_UA =
@@ -34,14 +36,14 @@ const ENGINE_NAMES = [
];
const AUTO_ENGINES = [
'duckduckgo',
'ddg_lite',
'jina',
'bing',
'bing_rss',
'google',
'wikipedia',
'ddg_instant',
'ddg_lite',
'bing_rss',
'duckduckgo',
'bing',
'google',
];
const ENGINE_ALIASES = {
@@ -61,13 +63,64 @@ function abortError(timeoutMs) {
return err;
}
function remainingMs(deadline) {
return Math.max(0, Number(deadline) - Date.now());
}
function timeoutErrorResult(ms, extra) {
return Object.assign({ error: 'timed out after ' + ms + 'ms' }, extra || {});
}
function budgetMs(timeoutMs, fallback, max) {
const fallbackMs = Number(fallback) > 0 ? Number(fallback) : SEARCH_BUDGET_MS;
const cap = Number(max) > 0 ? Number(max) : fallbackMs;
const n = Number(timeoutMs);
if (!(n > 0)) return fallbackMs;
return n > cap ? cap : n;
}
function withDeadline(work, deadline, fallback) {
const left = remainingMs(deadline);
if (left <= 0) return Promise.resolve(typeof fallback === 'function' ? fallback() : fallback);
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve(typeof fallback === 'function' ? fallback() : fallback), left);
});
return Promise.race([Promise.resolve().then(work), timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
function linkAbort(parent, child) {
if (!parent || !child) return;
if (parent.aborted) {
try {
child.abort();
} catch (_) {}
return;
}
parent.addEventListener(
'abort',
() => {
try {
child.abort();
} catch (_) {}
},
{ once: true },
);
}
function fetchWithTimeout(url, opts, timeoutMs) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
if (!(ms > 0)) return Promise.reject(abortError(0));
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;
if (controller) {
init.signal = controller.signal;
if (opts && opts.signal) linkAbort(opts.signal, controller);
}
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
try {
@@ -84,7 +137,8 @@ function fetchWithTimeout(url, opts, timeoutMs) {
}
function readBodyWithTimeout(res, timeoutMs) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : 0;
if (!(ms > 0)) return Promise.reject(abortError(0));
if (!res || typeof res.text !== 'function') return Promise.resolve('');
let timer;
const timeout = new Promise((_, reject) => {
@@ -238,14 +292,16 @@ function pushHit(hits, seen, href, title, snippet, limit) {
}
async function fetchText(url, timeoutMs, opts) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const deadline = Date.now() + ms;
try {
net.assertPublicHttpUrl(url);
} catch (err) {
return { error: String(err && err.message || err), url };
}
try {
const res = await fetchWithTimeout(url, opts || {}, timeoutMs);
const text = await readBodyWithTimeout(res, timeoutMs);
const res = await fetchWithTimeout(url, opts || {}, remainingMs(deadline));
const text = await readBodyWithTimeout(res, remainingMs(deadline));
if (res.status >= 400) {
return { error: 'HTTP ' + res.status, url: String(res.url || url), status: res.status, text };
}
@@ -381,9 +437,11 @@ function isDdgChallenge(html) {
}
async function duckDuckGoSearch(query, timeoutMs, limit) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const deadline = Date.now() + ms;
const url = 'https://html.duckduckgo.com/html/';
const body = 'q=' + encodeURIComponent(query) + '&b=&kl=us-en';
let page = await fetchText(url, timeoutMs, {
let page = await fetchText(url, remainingMs(deadline), {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
@@ -397,7 +455,8 @@ async function duckDuckGoSearch(query, timeoutMs, limit) {
const posted = parseDdgHtmlHits(page.text, limit);
if (searchHasHits(posted)) return posted;
}
page = await fetchText(url + '?q=' + encodeURIComponent(query), timeoutMs);
if (remainingMs(deadline) <= 0) return { error: 'timed out after ' + ms + 'ms', url };
page = await fetchText(url + '?q=' + encodeURIComponent(query), remainingMs(deadline));
if (page.error) return page;
if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status };
return parseDdgHtmlHits(page.text, limit);
@@ -633,32 +692,48 @@ async function runWebSearch(query, opts) {
const q = String(query || '').trim();
if (!q) return { error: 'query required' };
const limit = clampLimit(opts.limit);
const timeoutMs = opts.timeoutMs;
const budget = budgetMs(opts.timeoutMs, SEARCH_BUDGET_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + budget;
const engine = resolveEngine(opts.engine);
if (engine !== 'auto') {
const fn = SEARCH_ENGINES[engine];
if (!fn) return { error: 'unknown engine', engine: opts.engine, engines: ENGINE_NAMES };
const result = await fn(q, timeoutMs, limit);
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
return {
error: (result && result.error) || 'no search results',
url: result && result.url,
tried: [engine],
engines: ENGINE_NAMES,
};
}
const prefer = Array.isArray(opts.prefer) ? opts.prefer.map(resolveEngine).filter((n) => SEARCH_ENGINES[n]) : [];
const chain = prefer.concat(AUTO_ENGINES.filter((name) => prefer.indexOf(name) < 0));
const tried = [];
const errors = {};
for (let i = 0; i < chain.length; i++) {
const name = chain[i];
tried.push(name);
const result = await SEARCH_ENGINES[name](q, timeoutMs, limit);
if (searchHasHits(result)) return tagSearchHits(result, name).slice(0, limit);
errors[name] = result && result.error ? result.error : 'no results';
}
return { error: 'no search results', tried, errors, engines: ENGINE_NAMES };
const timedOut = () => timeoutErrorResult(budget, {
tried: tried.slice(),
errors: Object.assign({}, errors),
engines: ENGINE_NAMES,
});
return withDeadline(async () => {
try {
if (engine !== 'auto') {
const fn = SEARCH_ENGINES[engine];
if (!fn) return { error: 'unknown engine', engine: opts.engine, engines: ENGINE_NAMES };
tried.push(engine);
const result = await fn(q, remainingMs(deadline), limit);
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
return {
error: (result && result.error) || 'no search results',
url: result && result.url,
tried: [engine],
engines: ENGINE_NAMES,
};
}
const prefer = Array.isArray(opts.prefer) ? opts.prefer.map(resolveEngine).filter((n) => SEARCH_ENGINES[n]) : [];
const chain = prefer.concat(AUTO_ENGINES.filter((name) => prefer.indexOf(name) < 0));
for (let i = 0; i < chain.length; i++) {
const left = remainingMs(deadline);
if (left <= 0) return timedOut();
if (tried.length && left < 50) break;
const name = chain[i];
tried.push(name);
const result = await SEARCH_ENGINES[name](q, Math.min(ENGINE_TIMEOUT_MS, left), limit);
if (searchHasHits(result)) return tagSearchHits(result, name).slice(0, limit);
errors[name] = result && result.error ? result.error : 'no results';
}
return { error: 'no search results', tried, errors, engines: ENGINE_NAMES };
} catch (err) {
return { error: String(err && err.message || err), tried, errors, engines: ENGINE_NAMES };
}
}, deadline, timedOut);
}
async function googleSearchWithFallback(query, timeoutMs) {
@@ -672,22 +747,27 @@ async function webSearch(query, timeoutMs) {
async function codeSearch(query, timeoutMs, limit) {
const q = String(query || '').trim();
if (!q) return { error: 'query required' };
const [github, npm, mdn] = await Promise.all([
githubSearch(q, timeoutMs, limit),
npmSearch(q, timeoutMs, limit),
mdnSearch(q, timeoutMs, limit),
]);
const out = { github: [], npm: [], mdn: [] };
if (searchHasHits(github)) out.github = tagSearchHits(github, 'github');
else if (github && github.error) out.github_error = github.error;
if (searchHasHits(npm)) out.npm = tagSearchHits(npm, 'npm');
else if (npm && npm.error) out.npm_error = npm.error;
if (searchHasHits(mdn)) out.mdn = tagSearchHits(mdn, 'mdn');
else if (mdn && mdn.error) out.mdn_error = mdn.error;
if (!out.github.length && !out.npm.length && !out.mdn.length) {
return { error: 'no code search results', github_error: out.github_error, npm_error: out.npm_error, mdn_error: out.mdn_error };
}
return out;
const budget = budgetMs(timeoutMs, SEARCH_BUDGET_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + budget;
return withDeadline(async () => {
const slice = remainingMs(deadline);
const [github, npm, mdn] = await Promise.all([
githubSearch(q, slice, limit),
npmSearch(q, slice, limit),
mdnSearch(q, slice, limit),
]);
const out = { github: [], npm: [], mdn: [] };
if (searchHasHits(github)) out.github = tagSearchHits(github, 'github');
else if (github && github.error) out.github_error = github.error;
if (searchHasHits(npm)) out.npm = tagSearchHits(npm, 'npm');
else if (npm && npm.error) out.npm_error = npm.error;
if (searchHasHits(mdn)) out.mdn = tagSearchHits(mdn, 'mdn');
else if (mdn && mdn.error) out.mdn_error = mdn.error;
if (!out.github.length && !out.npm.length && !out.mdn.length) {
return { error: 'no code search results', github_error: out.github_error, npm_error: out.npm_error, mdn_error: out.mdn_error };
}
return out;
}, deadline, () => timeoutErrorResult(budget));
}
async function webFetch(url, timeoutMs) {
@@ -696,9 +776,11 @@ async function webFetch(url, timeoutMs) {
} catch (err) {
return { error: String(err && err.message || err), url: String(url || '') };
}
const ms = budgetMs(timeoutMs, WEB_TIMEOUT_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + ms;
try {
const res = await fetchWithTimeout(url, {}, timeoutMs);
let text = htmlToText(await readBodyWithTimeout(res, timeoutMs));
const res = await fetchWithTimeout(url, {}, remainingMs(deadline));
let text = htmlToText(await readBodyWithTimeout(res, remainingMs(deadline)));
text = truncate.truncateWithMarker(text, 12000);
const href = String(res.url || url);
if (res.status >= 400) {
@@ -718,26 +800,37 @@ async function fetchPage(url, timeoutMs) {
}
const target = String(url);
const jinaUrl = 'https://r.jina.ai/' + target;
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : PAGE_TIMEOUT_MS;
try {
net.assertPublicHttpUrl(jinaUrl);
const res = await fetchWithTimeout(jinaUrl, { headers: { accept: 'text/plain', 'user-agent': AGENT_UA } }, ms);
if (res.status < 400) {
let text = await readBodyWithTimeout(res, ms);
text = truncate.truncateWithMarker(text, 12000);
if (text && text.length > 24 && !/verifying you are (a )?human/i.test(text)) {
return { status: res.status, url: target, text, via: 'jina' };
const ms = budgetMs(timeoutMs, PAGE_TIMEOUT_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + ms;
const timedOut = () => timeoutErrorResult(ms, { url: target });
return withDeadline(async () => {
try {
net.assertPublicHttpUrl(jinaUrl);
const left = remainingMs(deadline);
if (left > 0) {
const res = await fetchWithTimeout(jinaUrl, { headers: { accept: 'text/plain', 'user-agent': AGENT_UA } }, left);
if (res.status < 400) {
let text = await readBodyWithTimeout(res, remainingMs(deadline));
text = truncate.truncateWithMarker(text, 12000);
if (text && text.length > 24 && !/verifying you are (a )?human/i.test(text)) {
return { status: res.status, url: target, text, via: 'jina' };
}
}
}
}
} catch (_) {}
const raw = await webFetch(target, timeoutMs);
if (raw && !raw.via) raw.via = 'raw';
return raw;
} catch (_) {}
const left = remainingMs(deadline);
if (left <= 0) return timedOut();
const raw = await webFetch(target, left);
if (raw && !raw.via) raw.via = 'raw';
return raw;
}, deadline, timedOut);
}
module.exports = {
WEB_TIMEOUT_MS,
PAGE_TIMEOUT_MS,
SEARCH_BUDGET_MS,
ENGINE_TIMEOUT_MS,
BROWSER_UA,
GOOGLE_UA,
AGENT_UA,
+8 -3
View File
@@ -63,11 +63,16 @@ function wrapSession(summary, opts) {
const session = sessions.load(sessionId);
const ctxSize = (engine.getLoaded() && engine.getLoaded().ctxSize) || 8192;
const toolDefs = [];
session.history = await require('./agent/compaction.js').compactWithLlm(session.history, {
budgetTokens: require('./agent/compaction.js').historyBudget(ctxSize, toolDefs, 0),
const compactOpts = {
budgetTokens: require('./agent/compaction.js').historyBudget(ctxSize, toolDefs, 0, 0, { voice: !!opts.voice }),
tools: toolDefs,
voice: !!opts.voice,
};
session.history = opts.voice
? require('./agent/compaction.js').compact(session.history, compactOpts)
: await require('./agent/compaction.js').compactWithLlm(session.history, Object.assign({}, compactOpts, {
complete: (o) => engine.complete(Object.assign({}, o, { desktopVision: false })),
});
}));
sessions.replaceHistory(sessionId, session.history);
return session.history;
}
+58 -3
View File
@@ -14,7 +14,7 @@ const CATALOG = [
mmproj: 'MMPROJ_QWEN3_5_0_8B_MULTIMODAL_Q8_0',
minRamGb: 4,
approxDownloadGb: 0.7,
ctxSize: 8192,
ctxSize: 16384,
},
{
id: 'qwen3.5-2b',
@@ -26,7 +26,7 @@ const CATALOG = [
mmproj: 'MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0',
minRamGb: 6,
approxDownloadGb: 1.6,
ctxSize: 8192,
ctxSize: 16384,
},
{
id: 'qwen3.5-4b',
@@ -155,7 +155,7 @@ const CATALOG = [
vision: false,
minRamGb: 8,
approxDownloadGb: 1.2,
ctxSize: 8192,
ctxSize: 16384,
},
{
id: 'qwen3-4b',
@@ -315,6 +315,9 @@ const COMPACT_TOOL_ALLOW = [
'memory_search',
'memory_get',
'memory_write',
'todo_write',
'task',
'update_goal',
'capability_status',
'qvac_runtime_state',
'qvac_system_resources',
@@ -332,6 +335,54 @@ function filterToolsForModel(defs, idOrConstant) {
return filtered.length ? filtered : list;
}
function catalogId(idOrConstant) {
const e = findCatalogEntry(idOrConstant);
return String((e && e.id) || idOrConstant || '').toLowerCase();
}
function isQwenReasoningModel(idOrConstant) {
const id = catalogId(idOrConstant);
return /qwen3(?:\.\d+)?/.test(id) || /qwen3[._-]/.test(String(idOrConstant || '').toLowerCase());
}
// Qwen3.5's chat template leaves thinking off unless the reasoning channel is
// enabled. Cap it so HUD thinking still streams, then the addon force-closes
// <think> and the model can emit the tool XML / spoken reply.
function reasoningBudgetForModel(idOrConstant) {
if (!isQwenReasoningModel(idOrConstant)) return null;
const id = catalogId(idOrConstant);
if (/0\.8b|0\.6b|600m/.test(id)) return 128;
if (/1\.7b|(?:^|-)2b/.test(id)) return 192;
if (/(?:^|-)4b/.test(id)) return 320;
if (/(?:^|-)[89]b/.test(id)) return 448;
return 320;
}
// Slightly below Qwen thinking's 0.6 so compact models still follow the
// tool XML, but high enough to avoid the greedy repeat loops of temp 0.3.
function compactGenerationParams(idOrConstant) {
if (!isCompactToolModel(idOrConstant)) return {};
return { temp: 0.55 };
}
function generationParamsForModel(idOrConstant, extra) {
const base = Object.assign(
{
repeat_penalty: 1.15,
frequency_penalty: 0.15,
},
compactGenerationParams(idOrConstant)
);
const budget = reasoningBudgetForModel(idOrConstant);
if (budget != null) {
base.reasoning_budget = budget;
base.predict = Math.min(768, Math.max(384, budget + 288));
} else {
base.predict = isCompactToolModel(idOrConstant) ? 384 : 640;
}
return Object.assign({}, base, extra || {});
}
module.exports = {
CATALOG,
FALLBACK_LLM_IDS,
@@ -346,5 +397,9 @@ module.exports = {
isCompactToolModel,
compactToolAllowlist,
filterToolsForModel,
isQwenReasoningModel,
reasoningBudgetForModel,
compactGenerationParams,
generationParamsForModel,
catalogLabel,
};
+29 -5
View File
@@ -11,6 +11,7 @@ const events = require('./events.js');
const paths = require('./paths.js');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
const repeatLoop = require('./repeat-loop.js');
let sdk = null;
let initError = null;
@@ -273,6 +274,7 @@ async function load(opts, onProgress) {
const wantVision =
opts.vision !== false && (entry ? entry.vision === true : catalog.isVisionModel(opts.model || opts.modelSrc));
const mmprojSrc = wantVision ? await resolveMmproj(s, entry) : null;
const loadParams = catalog.generationParamsForModel((entry && entry.id) || opts.model);
const modelConfig = Object.assign(
{
device: dev.device,
@@ -281,6 +283,7 @@ async function load(opts, onProgress) {
tools: !!toolsOn,
'mmproj-use-gpu': !!mmprojGpu,
},
loadParams.reasoning_budget != null ? { reasoning_budget: loadParams.reasoning_budget } : {},
mmprojSrc ? { projectionModelSrc: mmprojSrc } : {}
);
const loadOpts = {
@@ -317,7 +320,7 @@ async function load(opts, onProgress) {
} catch (err) {
const msg = flattenError(err);
log('load failed: ' + msg);
if (dev.device === 'gpu' && String(opts.device || 'auto').toLowerCase() !== 'cpu') {
if (dev.device === 'gpu' && String(opts.device || 'auto').toLowerCase() === 'auto' && process.env.JARVIS_GPU_REQUIRED !== '1') {
log('retrying load on cpu');
modelConfig.device = 'cpu';
modelConfig.gpu_layers = 0;
@@ -340,8 +343,8 @@ async function load(opts, onProgress) {
tools: !!toolsOn,
vision: !!mmprojSrc,
device: dev.device,
backend: bl.backend,
backendId: bl.backendId,
backend: dev.device === 'gpu' ? bl.backend : 'cpu',
backendId: dev.device === 'gpu' ? bl.backendId : 0,
deviceName: bl.deviceName,
vram: res && (res.vram || res.vramBytes),
ctxSize: modelConfig.ctx_size,
@@ -371,9 +374,13 @@ async function complete(opts, onEvent) {
const history = prepareVisionHistory(rawHistory);
const tools = toTools(opts && opts.tools);
const dialect = (opts && opts.toolDialect) || catalog.toolDialectFor(loaded.friendlyId || loaded.constant);
const generationParams = catalog.generationParamsForModel(
loaded.friendlyId || loaded.constant,
opts && opts.generationParams
);
const params = {
modelId: (opts && opts.modelId) || loaded.modelId,
history,
history: toolParse.prepareToolHistory(history, dialect),
stream: opts && opts.stream === false ? false : true,
captureThinking: true,
};
@@ -381,7 +388,7 @@ async function complete(opts, onEvent) {
params.tools = tools;
params.toolDialect = dialect;
}
if (opts && opts.generationParams) params.generationParams = opts.generationParams;
if (Object.keys(generationParams).length) params.generationParams = generationParams;
const run = s.completion(params);
const requestId = run.requestId || (opts && opts.requestId) || null;
loaded.requestId = requestId;
@@ -416,9 +423,19 @@ async function complete(opts, onEvent) {
if (n.type === 'contentDelta') {
text += n.delta;
if (onEvent) onEvent(n);
if (repeatLoop.isRepeating(text)) {
abortRun();
settleTimeout();
return;
}
} else if (n.type === 'thinkingDelta') {
thinking += n.delta;
if (onEvent) onEvent(n);
if (repeatLoop.isRepeating(thinking)) {
abortRun();
settleTimeout();
return;
}
} else if (n.type === 'toolCall') {
toolCalls.push(n.call);
if (onEvent) onEvent(n);
@@ -432,6 +449,11 @@ async function complete(opts, onEvent) {
watch.bump();
text += token;
if (onEvent) onEvent({ type: 'contentDelta', delta: token });
if (repeatLoop.isRepeating(text)) {
abortRun();
settleTimeout();
return;
}
}
if (run.toolCallStream) {
for await (const evt of run.toolCallStream) {
@@ -476,6 +498,8 @@ async function complete(opts, onEvent) {
} else {
text = toolParse.stripToolMarkup(text);
}
text = repeatLoop.collapseRepeats(text);
thinking = repeatLoop.collapseRepeats(thinking);
return {
text,
thinking,
+45
View File
@@ -0,0 +1,45 @@
/**
* Detect and collapse degenerate generation loops. No Bare imports.
*
* Small Qwen runs with no predict cap will repeat a clause until the idle
* timeout. Catch the repeating tail and stop the stream.
*/
function repeatingUnit(text, minLen, copies) {
const s = String(text || '');
const min = minLen > 0 ? minLen : 24;
const need = copies > 0 ? copies : 3;
if (s.length < min * need) return '';
const window = s.length > 2400 ? s.slice(-2400) : s;
const maxLen = Math.min(180, Math.floor(window.length / need));
for (let len = min; len <= maxLen; len++) {
const unit = window.slice(-len);
if (unit.replace(/\s+/g, '').length < 12) continue;
let matched = true;
for (let i = 2; i <= need; i++) {
if (window.slice(-len * i, -len * (i - 1)) !== unit) {
matched = false;
break;
}
}
if (matched) return unit;
}
return '';
}
function isRepeating(text) {
return !!repeatingUnit(text);
}
function escapeRe(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function collapseRepeats(text) {
const s = String(text || '');
const unit = repeatingUnit(s);
if (!unit) return s.trim();
return s.replace(new RegExp('(?:' + escapeRe(unit) + '){3,}$'), unit).trim();
}
module.exports = { repeatingUnit, isRepeating, collapseRepeats };
+31 -2
View File
@@ -7,8 +7,8 @@
*/
const FORMAT_REMINDER =
'When you need a tool, close thinking first, then emit this XML (not JSON, not a spoken plan):\n' +
'<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>value</parameter>\n</function>\n</tool_call>';
'When you need a tool, emit only this XML (not JSON, not a question, not a spoken plan):\n' +
'<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>\nvalue\n</parameter>\n</function>\n</tool_call>';
const ALIASES = {
search: 'web_search',
@@ -164,6 +164,33 @@ function recover({ text, thinking, tools, existing } = {}) {
return { calls, text: spoken };
}
// Qwen3.5's GGUF template renders assistant.tool_calls as this XML. QVAC's
// completion transport keeps only {role, content}, so later turns would lose
// the call unless we replay the same markup into content first.
function qwen35ToolCallXml(call) {
const fn = call && (call.function || call);
const name = fn && fn.name;
if (!name) return '';
const args = parseJsonArgs(fn.arguments);
const params = Object.entries(args)
.map(([key, value]) =>
'<parameter=' + key + '>\n' + (typeof value === 'string' ? value : JSON.stringify(value)) + '\n</parameter>'
)
.join('\n');
return '<tool_call>\n<function=' + name + '>\n' + (params ? params + '\n' : '') + '</function>\n</tool_call>';
}
function prepareToolHistory(history, dialect) {
if (!Array.isArray(history) || dialect !== 'qwen35') return history;
return history.map((message) => {
if (!message || message.role !== 'assistant' || !message.tool_calls || !message.tool_calls.length) return message;
if (/<tool_call>/i.test(message.content || '')) return message;
const frames = message.tool_calls.map(qwen35ToolCallXml).filter(Boolean);
if (!frames.length) return message;
return Object.assign({}, message, { content: [message.content, ...frames].filter(Boolean).join('\n') });
});
}
module.exports = {
FORMAT_REMINDER,
ALIASES,
@@ -171,4 +198,6 @@ module.exports = {
stripToolMarkup,
recover,
remapName,
qwen35ToolCallXml,
prepareToolHistory,
};
+15
View File
@@ -41,12 +41,27 @@ function testCatalog() {
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-2b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-0.8b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-4b'), false);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-0.8b').ctxSize, 16384);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-2b').ctxSize, 16384);
assert.strictEqual(catalog.findCatalogEntry('qwen3-1.7b').ctxSize, 16384);
assert.ok(catalog.findCatalogEntry('qwen3.5-4b').ctxSize >= 8192);
const tiny = catalog.filterToolsForModel(
[{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b',
);
assert.deepStrictEqual(tiny.map((t) => t.name), ['web_search']);
assert.strictEqual(
catalog.filterToolsForModel([{ name: 'todo_write' }, { name: 'cu_drag' }], 'qwen3.5-0.8b')[0].name,
'todo_write',
);
assert.strictEqual(catalog.filterToolsForModel([{ name: 'cu_drag' }], 'qwen3.5-4b')[0].name, 'cu_drag');
assert.deepStrictEqual(catalog.compactGenerationParams('qwen3.5-0.8b'), { temp: 0.55 });
assert.strictEqual(catalog.reasoningBudgetForModel('qwen3.5-0.8b'), 128);
assert.strictEqual(catalog.reasoningBudgetForModel('qwen3.5-4b'), 320);
const largeGen = catalog.generationParamsForModel('qwen3.5-4b', { predict: 64 });
assert.strictEqual(largeGen.reasoning_budget, 320);
assert.strictEqual(largeGen.predict, 64);
assert.strictEqual(largeGen.repeat_penalty, 1.15);
assert.ok(/~5 GB/.test(catalog.catalogLabel(catalog.findCatalogEntry('qwen3-8b'))));
assert.ok(catalog.FALLBACK_LLM_IDS.indexOf('gemma4-4b') >= 0);
const listed = catalog.listCatalog().find((m) => m.id === 'gemma4-2b');