@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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 }; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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'));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Vendored
+27
-11
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Vendored
+8
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+58
-3
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+29
-5
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+15
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user