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?.(); 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')) { 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) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
this.streamingReply = false; this.streamingReply = false;
this.replyFinalized = true; this.replyFinalized = true;
this.followActive(); this.followActive();
+1 -1
View File
@@ -38,7 +38,7 @@ export class HarnessBridge extends EventEmitter {
...createPhase9GatewayTool(), ...createPhase9GatewayTool(),
...tools, ...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, webFetch: true,
permissionMode, permissionMode,
origin: 'jarvis-qvac', origin: 'jarvis-qvac',
+21 -3
View File
@@ -16,6 +16,8 @@ let loadPromise = null;
let ownerCount = 0; let ownerCount = 0;
let operationTail = Promise.resolve(); let operationTail = Promise.resolve();
const auxiliaryModels = new Map(); const auxiliaryModels = new Map();
const auxiliaryLoads = new Map();
const auxiliaryOwners = new Map();
export const QVAC_MASTER = Object.freeze({ export const QVAC_MASTER = Object.freeze({
configPath: process.env.QVAC_CONFIG_PATH, configPath: process.env.QVAC_CONFIG_PATH,
@@ -92,12 +94,20 @@ function resolveModelConfigAssets(sdk, config) {
return copy; 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. /** 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. */ * These are auxiliary model IDs; they do not create another QVAC runtime. */
export async function loadAuxiliaryModel(name, modelConfig = {}, modelType = undefined) { export async function loadAuxiliaryModel(name, modelConfig = {}, modelType = undefined) {
if (!name) throw new Error('auxiliary QVAC model name is required'); if (!name) throw new Error('auxiliary QVAC model name is required');
const existing = auxiliaryModels.get(String(name)); const existing = auxiliaryModels.get(String(name));
if (existing) return existing; 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(); const sdk = await qvacSdk();
if (typeof sdk.loadModel !== 'function') throw new Error('QVAC SDK does not expose loadModel()'); if (typeof sdk.loadModel !== 'function') throw new Error('QVAC SDK does not expose loadModel()');
const auxiliaryConfig = resolveModelConfigAssets(sdk, modelConfig); const auxiliaryConfig = resolveModelConfigAssets(sdk, modelConfig);
@@ -116,12 +126,19 @@ export async function loadAuxiliaryModel(name, modelConfig = {}, modelType = und
const modelId = await withQvacMaster(() => sdk.loadModel(loadOptions)); const modelId = await withQvacMaster(() => sdk.loadModel(loadOptions));
auxiliaryModels.set(String(name), modelId); auxiliaryModels.set(String(name), modelId);
return 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; if (!modelId) return;
const owners = auxiliaryOwners.get(modelId) || 0;
if (!force && owners > 1) { auxiliaryOwners.set(modelId, owners - 1); return; }
const sdk = await qvacSdk(); const sdk = await qvacSdk();
if (typeof sdk.unloadModel === 'function') await withQvacMaster(() => sdk.unloadModel({ modelId })); 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); for (const [name, id] of auxiliaryModels) if (id === modelId) auxiliaryModels.delete(name);
} }
@@ -138,8 +155,9 @@ export function qvacBusy() {
export async function closeQvac() { export async function closeQvac() {
if (ownerCount > 0) return; 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(); auxiliaryModels.clear();
auxiliaryOwners.clear();
loadPromise = null; loadPromise = null;
await Agent.engine.close(); await Agent.engine.close();
} }
+38 -2
View File
@@ -49,9 +49,45 @@ function speakableAddresses(text) {
.replace(/_/g, ' '); .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) { export function speakableForTts(text) {
return spellAcronyms(speakableAddresses(String(text || ''))) return spellAcronyms(speakableAddresses(stripMarkdownForSpeech(text)))
.replace(/[`]/g, "'")
.replace(/[<>]/g, ' ') .replace(/[<>]/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
+1 -1
View File
@@ -34,7 +34,7 @@ export class QvacVoiceAdapter extends EventEmitter {
assertSdkVersion(); assertSdkVersion();
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true; await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
try { 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'); if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, this.ttsConfig.config, 'tts-ggml');
} catch (error) { await this.stop(); throw error; } } catch (error) { await this.stop(); throw error; }
} }
+27 -17
View File
@@ -198,6 +198,7 @@ export class VoiceLoop extends EventEmitter {
async speak(text) { async speak(text) {
const generation = this._generation || 0; const generation = this._generation || 0;
if (this.tts && !this.status.tts && typeof this.tts.start === 'function') await this.ensureTts(); 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) { if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) {
this._releaseSpeaking(); this._releaseSpeaking();
return; return;
@@ -209,32 +210,41 @@ export class VoiceLoop extends EventEmitter {
return; return;
} }
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => { 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 { try {
if (generation !== this._generation) return; 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; 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 { } 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; 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 }; export { SentenceBuffer };
+7 -2
View File
@@ -1,9 +1,14 @@
export class VoiceMetrics { 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; } wakeAccepted() { this.wakeAccepts += 1; }
wakeRejected() { this.wakeRejects += 1; } wakeRejected() { this.wakeRejects += 1; }
feedbackDrop() { this.feedbackDrops += 1; } feedbackDrop() { this.feedbackDrops += 1; }
utterance() { this.utterances += 1; } utterance() { this.utterances += 1; }
reply() { this.replies += 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 ## GPU policy
`JARVIS_GPU_REQUIRED=1` is set by the service. The master requests GPU device `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) `npm run gpu-doctor` and consult [hardware compatibility](hardware-compatibility.md)
before changing a model profile. before changing a model profile.
+26 -2
View File
@@ -10,9 +10,10 @@ flowchart LR
R --> W[Wake engine] R --> W[Wake engine]
W -->|phrase| V[VAD + QVAC ASR] W -->|phrase| V[VAD + QVAC ASR]
V -->|final utterance| H[Harness bridge] V -->|final utterance| H[Harness bridge]
H -->|streamed reply| T[Sentence buffer] H -->|final reply| T[Sentence queue]
T --> Q[QVAC TTS] T --> Q[QVAC TTS]
Q --> OUT[PipeWire playback] Q --> OUT[PipeWire playback]
OUT -. prepare next sentence concurrently .-> Q
OUT -. anti-feedback gate .-> V 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). Run `npm run voice-doctor`, then follow [voice acceptance](voice-acceptance.md).
The daemon metrics cover wake accepts/rejects, feedback drops, utterances, and 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 ## 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 = '') { export function voiceSystemPrompt(name = 'Jarvis', extra = '') {
const who = String(name || 'Jarvis').trim() || 'Jarvis'; const who = String(name || 'Jarvis').trim() || 'Jarvis';
const notes = String(extra || '').replace(/\0/g, '').trim(); 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. 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. 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.
Reply in plain text only. Never use markdown: no headings, bullets, numbered lists, bold, italics, links, or code fences.
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 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. 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} ${followFiles}
Ground desktop, file, memory, model, and network claims in a tool result. Do not invent limits the tools did not report. 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. 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. 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. 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(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
.replace(/<function\s*=[^>]*>[\s\S]*?<\/function>/gi, '') .replace(/<function\s*=[^>]*>[\s\S]*?<\/function>/gi, '')
.replace(/<tool_call>[\s\S]*$/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); 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]) { for (const overflow of [false, true]) {
test(`turn loop persists compaction and sends one-shot reminders through retries (overflow=${overflow})`, async (t) => { test(`turn loop persists compaction and sends one-shot reminders through retries (overflow=${overflow})`, async (t) => {
const { mkdtempSync, rmSync } = await import('node:fs'); const { mkdtempSync, rmSync } = await import('node:fs');
@@ -260,13 +289,11 @@ for (const overflow of [false, true]) {
{ role: 'user', content: 'Remember the hostname.' }, { role: 'user', content: 'Remember the hostname.' },
{ role: 'assistant', content: 'I will remember nest.' }, { role: 'assistant', content: 'I will remember nest.' },
]); ]);
let summaries = 0;
let completions = 0; let completions = 0;
const promptSizes = []; const promptSizes = [];
engine.complete = async ({ history }) => { engine.complete = async ({ history }) => {
if (history[0].content.startsWith('Reply with the four summary sections')) { if (history[0].content.startsWith('Reply with the four summary sections')) {
summaries++; throw new Error('voice turns must not spend a completion on LLM summarization');
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.' };
} }
completions++; completions++;
promptSizes.push(compaction.conversationTokens(history)); promptSizes.push(compaction.conversationTokens(history));
@@ -285,7 +312,6 @@ for (const overflow of [false, true]) {
emit: (_kind, event) => updates.push(event), emit: (_kind, event) => updates.push(event),
}); });
assert.equal(result.text, 'Its hostname is nest.'); assert.equal(result.text, 'Its hostname is nest.');
assert.equal(summaries, 1);
assert.equal(completions, overflow ? 2 : 1); assert.equal(completions, overflow ? 2 : 1);
if (overflow) assert.ok(promptSizes[1] < promptSizes[0], 'overflow recovery must actually shrink the prompt'); 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')); 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({}), '');
assert.equal(spokenReply('[object Object]'), ''); 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', () => { test('voice settings default TTS on and honor an explicit disable', () => {
@@ -101,6 +105,12 @@ test('harness bridge caps voice shell chaining', () => {
'wiki_search', 'wiki_search',
'hn_search', 'hn_search',
'code_search', 'code_search',
'todo_write',
'task',
'update_goal',
'memory_search',
'memory_get',
'memory_write',
]); ]);
assert.equal(bridge.options.webFetch, true); 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); 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]', () => { test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
const { ConversationView } = harness(); const { ConversationView } = harness();
const popup = new ConversationView({ compact: true }); 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(); await acquireQvac(); assert.equal(loads, 2); releaseQvac();
} finally { await closeQvac(); Object.assign(Agent.engine, original); } } 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'); 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 () => { test('web_fetch times out instead of hanging the turn', async () => {
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const tools = require('../vendor/agent-harness/agent/tools.js'); 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', () => { 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, /call run_terminal_cmd once/);
assert.match(VOICE_SYSTEM_PROMPT, /Do not chain extra commands/); 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, /Keep a space between every word/);
assert.match(VOICE_SYSTEM_PROMPT, /Never use markdown/); assert.match(VOICE_SYSTEM_PROMPT, /Never wrap words in asterisks/);
assert.match(VOICE_SYSTEM_PROMPT, /Quantum Verse Automatic Computer/); 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, /spell it as Q V A C/);
assert.match(VOICE_SYSTEM_PROMPT, /Internet protocol addresses have no dots/); 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_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, /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, /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, /Do not stop in thoughts/);
assert.match(VOICE_SYSTEM_PROMPT, /ifconfig\.me\/ip/); assert.match(VOICE_SYSTEM_PROMPT, /ifconfig\.me\/ip/);
assert.match(VOICE_SYSTEM_PROMPT, /This computer can reach the internet/); 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(); 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', () => { 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>'); const text = tools.htmlToText('<!DOCTYPE html><html><head><title>Hi</title></head><body><p>Honey peer</p></body></html>');
assert.match(text, /Honey peer/); 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 catalog = require('../vendor/agent-harness/lib/catalog.js');
const toolParse = require('../vendor/agent-harness/lib/tool-parse.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', () => { 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-4gb-mm').model, 'qwen3.5-0.8b');
assert.equal(profile('laptop-8gb-mm').model, 'qwen3.5-2b'); 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.toolDialectFor(id), 'qwen35');
assert.equal(catalog.findCatalogEntry(id).tools, true); assert.equal(catalog.findCatalogEntry(id).tools, true);
assert.equal(catalog.isCompactToolModel(id), true); assert.equal(catalog.isCompactToolModel(id), true);
assert.equal(catalog.findCatalogEntry(id).ctxSize, 16384);
} }
assert.match(toolParse.FORMAT_REMINDER, /<function=TOOL_NAME>/); 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( 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', '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', () => { 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 { WakeEngine } from '../daemon/wake-engine.js';
import { VadSegmenter } from '../daemon/vad.js'; import { VadSegmenter } from '../daemon/vad.js';
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.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', () => { test('Phase 4 transcript filtering and sentence buffering are deterministic', () => {
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false); assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
assert.equal(isMeaningfulTranscript('hi'), false); assert.equal(isMeaningfulTranscript('hi'), false);
assert.equal(isSpeakable('OK.'), true); assert.equal(isSpeakable('OK.'), true);
assert.equal(isSpeakable('[object Object]'), false); 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('Your IP is 192.168.0.1'), 'Your I P is 192 168 0 1');
assert.equal( assert.equal(
speakableForTts('QVAC fetched https://example.com/ip'), speakableForTts('QVAC fetched https://example.com/ip'),
'Quantum Verse Automatic Computer fetched example dot com slash I P' '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); assert.equal(isMeaningfulTranscript('what time is it'), true);
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) }); const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
buffer.push('First sentence. Second'); buffer.push(' sentence!'); buffer.flush(); 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); assert.equal(loop.status.tts, false);
await loop.stop(); 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 THRESHOLD = 0.68;
const MIN_SUMMARY = 80; const MIN_SUMMARY = 80;
const MIN_COMPACT_MESSAGES = 4; 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 = const COMPACT_PROMPT =
'Summarize this coding-agent conversation. Use exactly these sections:\n' + 'Summarize this coding-agent conversation. Use exactly these sections:\n' +
'1. Goal\n' + '1. Goal\n' +
@@ -75,19 +78,38 @@ function estimateTokens(messages, tools) {
return conversationTokens(messages) + toolTokens(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 cap = ctxSize > 0 ? Number(ctxSize) : 8192;
const toolTok = toolTokens(tools); const toolTok = toolTokens(tools);
const reserve = Math.max(384, Math.floor(cap * (0.18 + (Number(attempt) || 0) * 0.08))); 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 budget = historyBudget(ctxSize, tools, 0, extraTokens);
const tokens = conversationTokens(messages); const tokens = conversationTokens(messages);
// Large first requests or tool results can overflow before four messages. // Large first requests or tool results can overflow before four messages.
// A small greeting must not be discarded merely because schemas are large. // 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) { function isOverflowError(err) {
@@ -329,11 +351,14 @@ module.exports = {
THRESHOLD, THRESHOLD,
MIN_SUMMARY, MIN_SUMMARY,
MIN_COMPACT_MESSAGES, MIN_COMPACT_MESSAGES,
VOICE_KEEP_TOKENS,
COMPACT_PROMPT, COMPACT_PROMPT,
VOICE_COMPACT_PROMPT, VOICE_COMPACT_PROMPT,
conversationTokens, conversationTokens,
dialogTokens,
toolTokens, toolTokens,
estimateTokens, estimateTokens,
voiceKeepTokens,
historyBudget, historyBudget,
shouldCompact, shouldCompact,
nonSystemCount, nonSystemCount,
+27 -11
View File
@@ -72,6 +72,22 @@ function loadedCtxSize() {
return (loaded && loaded.ctxSize) || 8192; 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) { function toolResultCap(budget) {
const ctx = loadedCtxSize(); const ctx = loadedCtxSize();
const voice = !!(budget && budget.voice); const voice = !!(budget && budget.voice);
@@ -608,22 +624,20 @@ async function runTurn(ctx) {
const sidecarTokens = compaction.conversationTokens(turnSidecars) + 256; const sidecarTokens = compaction.conversationTokens(turnSidecars) + 256;
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize); const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage)); 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, { emitUpdate(emit, session.id, jobId, {
type: 'compaction', type: 'compaction',
status: 'start', status: 'start',
method: 'llm', method: budget.voice ? 'heuristic' : 'llm',
used: beforeUsage.used, used: beforeUsage.used,
limit: beforeUsage.limit, limit: beforeUsage.limit,
pct: beforeUsage.pct, pct: beforeUsage.pct,
threshold: beforeUsage.threshold, threshold: beforeUsage.threshold,
}); });
const compactOpts = { const compactOpts = historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, 0);
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0, sidecarTokens), session.history = budget.voice
tools: toolDefs, ? compaction.compact(session.history, compactOpts)
voice: !!budget.voice, : await compaction.compactWithLlm(session.history, Object.assign({}, compactOpts, {
};
session.history = await compaction.compactWithLlm(session.history, Object.assign({}, compactOpts, {
complete: (opts) => engine.complete(Object.assign({}, opts, { complete: (opts) => engine.complete(Object.assign({}, opts, {
desktopVision: false, desktopVision: false,
timeoutMs: budget.completeTimeoutMs, timeoutMs: budget.completeTimeoutMs,
@@ -643,7 +657,7 @@ async function runTurn(ctx) {
emitUpdate(emit, session.id, jobId, { emitUpdate(emit, session.id, jobId, {
type: 'compaction', type: 'compaction',
status: 'done', status: 'done',
method: 'llm', method: budget.voice ? 'heuristic' : 'llm',
used: afterUsage.used, used: afterUsage.used,
limit: afterUsage.limit, limit: afterUsage.limit,
pct: afterUsage.pct, pct: afterUsage.pct,
@@ -715,7 +729,7 @@ async function runTurn(ctx) {
// The estimate can be lower than the model's tokenizer count. // The estimate can be lower than the model's tokenizer count.
// Every overflow retry must shrink even an apparently small history. // Every overflow retry must shrink even an apparently small history.
budgetTokens: Math.min( 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)) Math.max(1, Math.floor(compaction.conversationTokens(session.history) * 0.85))
), ),
tools: toolDefs, tools: toolDefs,
@@ -863,7 +877,9 @@ async function runTurn(ctx) {
} }
if (stopEarly) break; 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) { if (stopEarly) {
return endTurn(emit, session, jobId, tracker, stopEarly); return endTurn(emit, session, jobId, tracker, stopEarly);
} }
+1 -1
View File
@@ -18,7 +18,7 @@ function fromPayload(payload, origin) {
voice, voice,
maxTurns: num(payload.maxTurns, voice ? 6 : 24), maxTurns: num(payload.maxTurns, voice ? 6 : 24),
maxShellCalls: unlimitedShell ? 0 : num(payload.maxShellCalls, voice ? 1 : 0), 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, completeTimeoutMs: voice ? 45000 : 0,
completeIdleMs: voice ? 10000 : 0, completeIdleMs: voice ? 10000 : 0,
shellCalls: 0, shellCalls: 0,
+120 -27
View File
@@ -7,8 +7,10 @@
const net = require('../lib/net.js'); const net = require('../lib/net.js');
const truncate = require('./truncate.js'); const truncate = require('./truncate.js');
const WEB_TIMEOUT_MS = 12000; const WEB_TIMEOUT_MS = 3500;
const PAGE_TIMEOUT_MS = 20000; const PAGE_TIMEOUT_MS = 8000;
const SEARCH_BUDGET_MS = 8000;
const ENGINE_TIMEOUT_MS = 3000;
const BROWSER_UA = const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const GOOGLE_UA = const GOOGLE_UA =
@@ -34,14 +36,14 @@ const ENGINE_NAMES = [
]; ];
const AUTO_ENGINES = [ const AUTO_ENGINES = [
'duckduckgo',
'ddg_lite',
'jina', 'jina',
'bing',
'bing_rss',
'google',
'wikipedia', 'wikipedia',
'ddg_instant', 'ddg_instant',
'ddg_lite',
'bing_rss',
'duckduckgo',
'bing',
'google',
]; ];
const ENGINE_ALIASES = { const ENGINE_ALIASES = {
@@ -61,13 +63,64 @@ function abortError(timeoutMs) {
return err; 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) { function fetchWithTimeout(url, opts, timeoutMs) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS; 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 headers = Object.assign({ 'user-agent': BROWSER_UA }, (opts && opts.headers) || {});
const controller = typeof AbortController === 'function' ? new AbortController() : null; const controller = typeof AbortController === 'function' ? new AbortController() : null;
let timer; let timer;
const init = Object.assign({}, opts || {}, { headers }); 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) => { const timeout = new Promise((_, reject) => {
timer = setTimeout(() => { timer = setTimeout(() => {
try { try {
@@ -84,7 +137,8 @@ function fetchWithTimeout(url, opts, timeoutMs) {
} }
function readBodyWithTimeout(res, 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(''); if (!res || typeof res.text !== 'function') return Promise.resolve('');
let timer; let timer;
const timeout = new Promise((_, reject) => { const timeout = new Promise((_, reject) => {
@@ -238,14 +292,16 @@ function pushHit(hits, seen, href, title, snippet, limit) {
} }
async function fetchText(url, timeoutMs, opts) { async function fetchText(url, timeoutMs, opts) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const deadline = Date.now() + ms;
try { try {
net.assertPublicHttpUrl(url); net.assertPublicHttpUrl(url);
} catch (err) { } catch (err) {
return { error: String(err && err.message || err), url }; return { error: String(err && err.message || err), url };
} }
try { try {
const res = await fetchWithTimeout(url, opts || {}, timeoutMs); const res = await fetchWithTimeout(url, opts || {}, remainingMs(deadline));
const text = await readBodyWithTimeout(res, timeoutMs); const text = await readBodyWithTimeout(res, remainingMs(deadline));
if (res.status >= 400) { if (res.status >= 400) {
return { error: 'HTTP ' + res.status, url: String(res.url || url), status: res.status, text }; 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) { 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 url = 'https://html.duckduckgo.com/html/';
const body = 'q=' + encodeURIComponent(query) + '&b=&kl=us-en'; const body = 'q=' + encodeURIComponent(query) + '&b=&kl=us-en';
let page = await fetchText(url, timeoutMs, { let page = await fetchText(url, remainingMs(deadline), {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/x-www-form-urlencoded', 'content-type': 'application/x-www-form-urlencoded',
@@ -397,7 +455,8 @@ async function duckDuckGoSearch(query, timeoutMs, limit) {
const posted = parseDdgHtmlHits(page.text, limit); const posted = parseDdgHtmlHits(page.text, limit);
if (searchHasHits(posted)) return posted; 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 (page.error) return page;
if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status }; if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status };
return parseDdgHtmlHits(page.text, limit); return parseDdgHtmlHits(page.text, limit);
@@ -633,12 +692,23 @@ async function runWebSearch(query, opts) {
const q = String(query || '').trim(); const q = String(query || '').trim();
if (!q) return { error: 'query required' }; if (!q) return { error: 'query required' };
const limit = clampLimit(opts.limit); 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); const engine = resolveEngine(opts.engine);
const tried = [];
const errors = {};
const timedOut = () => timeoutErrorResult(budget, {
tried: tried.slice(),
errors: Object.assign({}, errors),
engines: ENGINE_NAMES,
});
return withDeadline(async () => {
try {
if (engine !== 'auto') { if (engine !== 'auto') {
const fn = SEARCH_ENGINES[engine]; const fn = SEARCH_ENGINES[engine];
if (!fn) return { error: 'unknown engine', engine: opts.engine, engines: ENGINE_NAMES }; if (!fn) return { error: 'unknown engine', engine: opts.engine, engines: ENGINE_NAMES };
const result = await fn(q, timeoutMs, limit); tried.push(engine);
const result = await fn(q, remainingMs(deadline), limit);
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit); if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
return { return {
error: (result && result.error) || 'no search results', error: (result && result.error) || 'no search results',
@@ -649,16 +719,21 @@ async function runWebSearch(query, opts) {
} }
const prefer = Array.isArray(opts.prefer) ? opts.prefer.map(resolveEngine).filter((n) => SEARCH_ENGINES[n]) : []; 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 chain = prefer.concat(AUTO_ENGINES.filter((name) => prefer.indexOf(name) < 0));
const tried = [];
const errors = {};
for (let i = 0; i < chain.length; i++) { 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]; const name = chain[i];
tried.push(name); tried.push(name);
const result = await SEARCH_ENGINES[name](q, timeoutMs, limit); const result = await SEARCH_ENGINES[name](q, Math.min(ENGINE_TIMEOUT_MS, left), limit);
if (searchHasHits(result)) return tagSearchHits(result, name).slice(0, limit); if (searchHasHits(result)) return tagSearchHits(result, name).slice(0, limit);
errors[name] = result && result.error ? result.error : 'no results'; errors[name] = result && result.error ? result.error : 'no results';
} }
return { error: 'no search results', tried, errors, engines: ENGINE_NAMES }; 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) { async function googleSearchWithFallback(query, timeoutMs) {
@@ -672,10 +747,14 @@ async function webSearch(query, timeoutMs) {
async function codeSearch(query, timeoutMs, limit) { async function codeSearch(query, timeoutMs, limit) {
const q = String(query || '').trim(); const q = String(query || '').trim();
if (!q) return { error: 'query required' }; if (!q) return { error: 'query required' };
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([ const [github, npm, mdn] = await Promise.all([
githubSearch(q, timeoutMs, limit), githubSearch(q, slice, limit),
npmSearch(q, timeoutMs, limit), npmSearch(q, slice, limit),
mdnSearch(q, timeoutMs, limit), mdnSearch(q, slice, limit),
]); ]);
const out = { github: [], npm: [], mdn: [] }; const out = { github: [], npm: [], mdn: [] };
if (searchHasHits(github)) out.github = tagSearchHits(github, 'github'); if (searchHasHits(github)) out.github = tagSearchHits(github, 'github');
@@ -688,6 +767,7 @@ async function codeSearch(query, timeoutMs, limit) {
return { error: 'no code search results', github_error: out.github_error, npm_error: out.npm_error, mdn_error: out.mdn_error }; return { error: 'no code search results', github_error: out.github_error, npm_error: out.npm_error, mdn_error: out.mdn_error };
} }
return out; return out;
}, deadline, () => timeoutErrorResult(budget));
} }
async function webFetch(url, timeoutMs) { async function webFetch(url, timeoutMs) {
@@ -696,9 +776,11 @@ async function webFetch(url, timeoutMs) {
} catch (err) { } catch (err) {
return { error: String(err && err.message || err), url: String(url || '') }; 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 { try {
const res = await fetchWithTimeout(url, {}, timeoutMs); const res = await fetchWithTimeout(url, {}, remainingMs(deadline));
let text = htmlToText(await readBodyWithTimeout(res, timeoutMs)); let text = htmlToText(await readBodyWithTimeout(res, remainingMs(deadline)));
text = truncate.truncateWithMarker(text, 12000); text = truncate.truncateWithMarker(text, 12000);
const href = String(res.url || url); const href = String(res.url || url);
if (res.status >= 400) { if (res.status >= 400) {
@@ -718,26 +800,37 @@ async function fetchPage(url, timeoutMs) {
} }
const target = String(url); const target = String(url);
const jinaUrl = 'https://r.jina.ai/' + target; const jinaUrl = 'https://r.jina.ai/' + target;
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : PAGE_TIMEOUT_MS; 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 { try {
net.assertPublicHttpUrl(jinaUrl); net.assertPublicHttpUrl(jinaUrl);
const res = await fetchWithTimeout(jinaUrl, { headers: { accept: 'text/plain', 'user-agent': AGENT_UA } }, ms); 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) { if (res.status < 400) {
let text = await readBodyWithTimeout(res, ms); let text = await readBodyWithTimeout(res, remainingMs(deadline));
text = truncate.truncateWithMarker(text, 12000); text = truncate.truncateWithMarker(text, 12000);
if (text && text.length > 24 && !/verifying you are (a )?human/i.test(text)) { if (text && text.length > 24 && !/verifying you are (a )?human/i.test(text)) {
return { status: res.status, url: target, text, via: 'jina' }; return { status: res.status, url: target, text, via: 'jina' };
} }
} }
}
} catch (_) {} } catch (_) {}
const raw = await webFetch(target, timeoutMs); const left = remainingMs(deadline);
if (left <= 0) return timedOut();
const raw = await webFetch(target, left);
if (raw && !raw.via) raw.via = 'raw'; if (raw && !raw.via) raw.via = 'raw';
return raw; return raw;
}, deadline, timedOut);
} }
module.exports = { module.exports = {
WEB_TIMEOUT_MS, WEB_TIMEOUT_MS,
PAGE_TIMEOUT_MS, PAGE_TIMEOUT_MS,
SEARCH_BUDGET_MS,
ENGINE_TIMEOUT_MS,
BROWSER_UA, BROWSER_UA,
GOOGLE_UA, GOOGLE_UA,
AGENT_UA, AGENT_UA,
+8 -3
View File
@@ -63,11 +63,16 @@ function wrapSession(summary, opts) {
const session = sessions.load(sessionId); const session = sessions.load(sessionId);
const ctxSize = (engine.getLoaded() && engine.getLoaded().ctxSize) || 8192; const ctxSize = (engine.getLoaded() && engine.getLoaded().ctxSize) || 8192;
const toolDefs = []; const toolDefs = [];
session.history = await require('./agent/compaction.js').compactWithLlm(session.history, { const compactOpts = {
budgetTokens: require('./agent/compaction.js').historyBudget(ctxSize, toolDefs, 0), budgetTokens: require('./agent/compaction.js').historyBudget(ctxSize, toolDefs, 0, 0, { voice: !!opts.voice }),
tools: toolDefs, 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 })), complete: (o) => engine.complete(Object.assign({}, o, { desktopVision: false })),
}); }));
sessions.replaceHistory(sessionId, session.history); sessions.replaceHistory(sessionId, session.history);
return session.history; return session.history;
} }
+58 -3
View File
@@ -14,7 +14,7 @@ const CATALOG = [
mmproj: 'MMPROJ_QWEN3_5_0_8B_MULTIMODAL_Q8_0', mmproj: 'MMPROJ_QWEN3_5_0_8B_MULTIMODAL_Q8_0',
minRamGb: 4, minRamGb: 4,
approxDownloadGb: 0.7, approxDownloadGb: 0.7,
ctxSize: 8192, ctxSize: 16384,
}, },
{ {
id: 'qwen3.5-2b', id: 'qwen3.5-2b',
@@ -26,7 +26,7 @@ const CATALOG = [
mmproj: 'MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0', mmproj: 'MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0',
minRamGb: 6, minRamGb: 6,
approxDownloadGb: 1.6, approxDownloadGb: 1.6,
ctxSize: 8192, ctxSize: 16384,
}, },
{ {
id: 'qwen3.5-4b', id: 'qwen3.5-4b',
@@ -155,7 +155,7 @@ const CATALOG = [
vision: false, vision: false,
minRamGb: 8, minRamGb: 8,
approxDownloadGb: 1.2, approxDownloadGb: 1.2,
ctxSize: 8192, ctxSize: 16384,
}, },
{ {
id: 'qwen3-4b', id: 'qwen3-4b',
@@ -315,6 +315,9 @@ const COMPACT_TOOL_ALLOW = [
'memory_search', 'memory_search',
'memory_get', 'memory_get',
'memory_write', 'memory_write',
'todo_write',
'task',
'update_goal',
'capability_status', 'capability_status',
'qvac_runtime_state', 'qvac_runtime_state',
'qvac_system_resources', 'qvac_system_resources',
@@ -332,6 +335,54 @@ function filterToolsForModel(defs, idOrConstant) {
return filtered.length ? filtered : list; 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 = { module.exports = {
CATALOG, CATALOG,
FALLBACK_LLM_IDS, FALLBACK_LLM_IDS,
@@ -346,5 +397,9 @@ module.exports = {
isCompactToolModel, isCompactToolModel,
compactToolAllowlist, compactToolAllowlist,
filterToolsForModel, filterToolsForModel,
isQwenReasoningModel,
reasoningBudgetForModel,
compactGenerationParams,
generationParamsForModel,
catalogLabel, catalogLabel,
}; };
+29 -5
View File
@@ -11,6 +11,7 @@ const events = require('./events.js');
const paths = require('./paths.js'); const paths = require('./paths.js');
const completeWatch = require('./complete-watch.js'); const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js'); const toolParse = require('./tool-parse.js');
const repeatLoop = require('./repeat-loop.js');
let sdk = null; let sdk = null;
let initError = null; let initError = null;
@@ -273,6 +274,7 @@ async function load(opts, onProgress) {
const wantVision = const wantVision =
opts.vision !== false && (entry ? entry.vision === true : catalog.isVisionModel(opts.model || opts.modelSrc)); opts.vision !== false && (entry ? entry.vision === true : catalog.isVisionModel(opts.model || opts.modelSrc));
const mmprojSrc = wantVision ? await resolveMmproj(s, entry) : null; const mmprojSrc = wantVision ? await resolveMmproj(s, entry) : null;
const loadParams = catalog.generationParamsForModel((entry && entry.id) || opts.model);
const modelConfig = Object.assign( const modelConfig = Object.assign(
{ {
device: dev.device, device: dev.device,
@@ -281,6 +283,7 @@ async function load(opts, onProgress) {
tools: !!toolsOn, tools: !!toolsOn,
'mmproj-use-gpu': !!mmprojGpu, 'mmproj-use-gpu': !!mmprojGpu,
}, },
loadParams.reasoning_budget != null ? { reasoning_budget: loadParams.reasoning_budget } : {},
mmprojSrc ? { projectionModelSrc: mmprojSrc } : {} mmprojSrc ? { projectionModelSrc: mmprojSrc } : {}
); );
const loadOpts = { const loadOpts = {
@@ -317,7 +320,7 @@ async function load(opts, onProgress) {
} catch (err) { } catch (err) {
const msg = flattenError(err); const msg = flattenError(err);
log('load failed: ' + msg); 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'); log('retrying load on cpu');
modelConfig.device = 'cpu'; modelConfig.device = 'cpu';
modelConfig.gpu_layers = 0; modelConfig.gpu_layers = 0;
@@ -340,8 +343,8 @@ async function load(opts, onProgress) {
tools: !!toolsOn, tools: !!toolsOn,
vision: !!mmprojSrc, vision: !!mmprojSrc,
device: dev.device, device: dev.device,
backend: bl.backend, backend: dev.device === 'gpu' ? bl.backend : 'cpu',
backendId: bl.backendId, backendId: dev.device === 'gpu' ? bl.backendId : 0,
deviceName: bl.deviceName, deviceName: bl.deviceName,
vram: res && (res.vram || res.vramBytes), vram: res && (res.vram || res.vramBytes),
ctxSize: modelConfig.ctx_size, ctxSize: modelConfig.ctx_size,
@@ -371,9 +374,13 @@ async function complete(opts, onEvent) {
const history = prepareVisionHistory(rawHistory); const history = prepareVisionHistory(rawHistory);
const tools = toTools(opts && opts.tools); const tools = toTools(opts && opts.tools);
const dialect = (opts && opts.toolDialect) || catalog.toolDialectFor(loaded.friendlyId || loaded.constant); const dialect = (opts && opts.toolDialect) || catalog.toolDialectFor(loaded.friendlyId || loaded.constant);
const generationParams = catalog.generationParamsForModel(
loaded.friendlyId || loaded.constant,
opts && opts.generationParams
);
const params = { const params = {
modelId: (opts && opts.modelId) || loaded.modelId, modelId: (opts && opts.modelId) || loaded.modelId,
history, history: toolParse.prepareToolHistory(history, dialect),
stream: opts && opts.stream === false ? false : true, stream: opts && opts.stream === false ? false : true,
captureThinking: true, captureThinking: true,
}; };
@@ -381,7 +388,7 @@ async function complete(opts, onEvent) {
params.tools = tools; params.tools = tools;
params.toolDialect = dialect; 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 run = s.completion(params);
const requestId = run.requestId || (opts && opts.requestId) || null; const requestId = run.requestId || (opts && opts.requestId) || null;
loaded.requestId = requestId; loaded.requestId = requestId;
@@ -416,9 +423,19 @@ async function complete(opts, onEvent) {
if (n.type === 'contentDelta') { if (n.type === 'contentDelta') {
text += n.delta; text += n.delta;
if (onEvent) onEvent(n); if (onEvent) onEvent(n);
if (repeatLoop.isRepeating(text)) {
abortRun();
settleTimeout();
return;
}
} else if (n.type === 'thinkingDelta') { } else if (n.type === 'thinkingDelta') {
thinking += n.delta; thinking += n.delta;
if (onEvent) onEvent(n); if (onEvent) onEvent(n);
if (repeatLoop.isRepeating(thinking)) {
abortRun();
settleTimeout();
return;
}
} else if (n.type === 'toolCall') { } else if (n.type === 'toolCall') {
toolCalls.push(n.call); toolCalls.push(n.call);
if (onEvent) onEvent(n); if (onEvent) onEvent(n);
@@ -432,6 +449,11 @@ async function complete(opts, onEvent) {
watch.bump(); watch.bump();
text += token; text += token;
if (onEvent) onEvent({ type: 'contentDelta', delta: token }); if (onEvent) onEvent({ type: 'contentDelta', delta: token });
if (repeatLoop.isRepeating(text)) {
abortRun();
settleTimeout();
return;
}
} }
if (run.toolCallStream) { if (run.toolCallStream) {
for await (const evt of run.toolCallStream) { for await (const evt of run.toolCallStream) {
@@ -476,6 +498,8 @@ async function complete(opts, onEvent) {
} else { } else {
text = toolParse.stripToolMarkup(text); text = toolParse.stripToolMarkup(text);
} }
text = repeatLoop.collapseRepeats(text);
thinking = repeatLoop.collapseRepeats(thinking);
return { return {
text, text,
thinking, 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 = const FORMAT_REMINDER =
'When you need a tool, close thinking first, then emit this XML (not JSON, not a spoken plan):\n' + '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>value</parameter>\n</function>\n</tool_call>'; '<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>\nvalue\n</parameter>\n</function>\n</tool_call>';
const ALIASES = { const ALIASES = {
search: 'web_search', search: 'web_search',
@@ -164,6 +164,33 @@ function recover({ text, thinking, tools, existing } = {}) {
return { calls, text: spoken }; 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 = { module.exports = {
FORMAT_REMINDER, FORMAT_REMINDER,
ALIASES, ALIASES,
@@ -171,4 +198,6 @@ module.exports = {
stripToolMarkup, stripToolMarkup,
recover, recover,
remapName, 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-2b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-0.8b'), true); assert.strictEqual(catalog.isCompactToolModel('qwen3.5-0.8b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-4b'), false); 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( const tiny = catalog.filterToolsForModel(
[{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }], [{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b', 'qwen3.5-0.8b',
); );
assert.deepStrictEqual(tiny.map((t) => t.name), ['web_search']); 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.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(/~5 GB/.test(catalog.catalogLabel(catalog.findCatalogEntry('qwen3-8b'))));
assert.ok(catalog.FALLBACK_LLM_IDS.indexOf('gemma4-4b') >= 0); assert.ok(catalog.FALLBACK_LLM_IDS.indexOf('gemma4-4b') >= 0);
const listed = catalog.listCatalog().find((m) => m.id === 'gemma4-2b'); const listed = catalog.listCatalog().find((m) => m.id === 'gemma4-2b');