diff --git a/apps/gnome-extension/jarvis@qvac.local/stylesheet.css b/apps/gnome-extension/jarvis@qvac.local/stylesheet.css index 866a5f6..7f80f36 100644 --- a/apps/gnome-extension/jarvis@qvac.local/stylesheet.css +++ b/apps/gnome-extension/jarvis@qvac.local/stylesheet.css @@ -10,15 +10,20 @@ .jarvis-title { font-weight: bold; letter-spacing: 0.4px; font-size: 14px; } .jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; } .jarvis-status-line { color: #aeb6c8; font-size: 12px; } -.jarvis-thinking-toggle { color: #9aa8c0; font-size: 11px; padding: 2px 8px; border-radius: 8px; background-color: transparent; } -.jarvis-thinking-toggle:hover, .jarvis-thinking-toggle:focus { color: #f6f7fb; background-color: rgba(79, 210, 255, .16); } -.jarvis-thinking { color: #aeb6c8; font-size: 12px; padding: 6px 10px; border-left: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .06); border-radius: 8px; } +.jarvis-tabs { spacing: 6px; } +.jarvis-tab { padding: 4px 10px; border-radius: 999px; background-color: transparent; color: #9aa8c0; font-size: 12px; } +.jarvis-tab:hover, .jarvis-tab:focus { color: #f6f7fb; background-color: rgba(255, 255, 255, .08); } +.jarvis-tab-active { background-color: var(--jarvis-accent, #F4B942); color: #16191f; font-weight: bold; } +.jarvis-thinking { color: #aeb6c8; font-size: 12px; padding: 8px 10px; border-left: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .06); border-radius: 8px; } .jarvis-notice { color: #f4b942; font-size: 12px; } .jarvis-confirm { spacing: 8px; padding: 4px 0; } .jarvis-confirm-actions { spacing: 6px; } .jarvis-confirm-label { color: #f6f7fb; font-size: 13px; } -.jarvis-popup-scroll { height: 140px; } +.jarvis-popup-scroll { height: 180px; } +.jarvis-thinking-scroll { height: 160px; } +.jarvis-thinking-box { spacing: 0; } .jarvis-session-scroll { height: 220px; } +.jarvis-session-thinking { height: 200px; } .jarvis-transcript { spacing: 6px; } .jarvis-row { padding: 6px 10px; border-radius: 8px; font-size: 13px; } .jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); background-color: rgba(244, 185, 66, .08); } diff --git a/apps/gnome-extension/jarvis@qvac.local/ui.js b/apps/gnome-extension/jarvis@qvac.local/ui.js index 21227f7..c1ac7f7 100644 --- a/apps/gnome-extension/jarvis@qvac.local/ui.js +++ b/apps/gnome-extension/jarvis@qvac.local/ui.js @@ -23,10 +23,28 @@ export const coerceText = (value) => { export const safeText = (value) => coerceText(value).replace(/[<>]/g, ''); export const shortError = (value) => safeText(value).split('\n')[0].replace(/\s+/g, ' ').slice(0, 140); export const previewToolResult = (value) => { - const text = safeText(value).replace(/\s+/g, ' ').trim(); + const raw = typeof value === 'string' ? value : coerceText(value); + const text = safeText(raw).replace(/\s+/g, ' ').trim(); if (!text || text === '(no output)') return ''; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const first = parsed[0] && (parsed[0].title || parsed[0].url || parsed[0].name); + if (first) return parsed.length === 1 ? String(first).slice(0, 80) : `${parsed.length} results · ${String(first).slice(0, 60)}`; + return parsed.length ? `${parsed.length} results` : ''; + } + if (parsed && typeof parsed === 'object') { + if (parsed.error) return safeText(parsed.error).slice(0, 160); + if (parsed.status && parsed.url) return `${parsed.status} ${parsed.url}`.slice(0, 160); + } + } catch { + const status = text.match(/"status"\s*:\s*(\d+)/); + const href = text.match(/"url"\s*:\s*"([^"]+)"/); + if (status) return `${status[1]}${href ? ' ' + href[1] : ''}`.slice(0, 160); + } return text.slice(0, 160); }; +export const prettyToolName = (value) => safeText(value || 'tool').replace(/_/g, ' ').replace(/\s+/g, ' ').trim() || 'tool'; function wrapLabel(label) { if (label.clutter_text) { @@ -57,11 +75,15 @@ export class ConversationView { this._bindChip(this.settings, () => this.onSettings?.()); this.header.add_child(this.settings); this.statusLine = new St.Label({ text: 'armed · Hold Talk to speak', style_class: 'jarvis-status-line' }); - this.thinkingToggle = new St.Button({ label: 'Thinking', style_class: 'jarvis-thinking-toggle', visible: false, can_focus: true, reactive: true, x_align: Clutter.ActorAlign.START }); - this.thinkingToggle.accessible_name = 'Show or hide Jarvis thinking'; - this.thinkingToggle.connect('clicked', () => this.toggleThinking()); - this.thinking = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-thinking', visible: false, x_expand: true, can_focus: true })); - this.thinking.accessible_name = 'Jarvis thinking'; + this.tabs = new St.BoxLayout({ style_class: 'jarvis-tabs', x_expand: true }); + this.chatTab = new St.Button({ label: 'Chat', style_class: 'jarvis-tab jarvis-tab-active', reactive: true, can_focus: true }); + this.thinkTab = new St.Button({ label: 'Thinking', style_class: 'jarvis-tab', reactive: true, can_focus: true }); + this.chatTab.accessible_name = 'Chat tab'; + this.thinkTab.accessible_name = 'Thinking tab'; + this._bindChip(this.chatTab, () => this.showTab('chat', { user: true })); + this._bindChip(this.thinkTab, () => this.showTab('thinking', { user: true })); + this.tabs.add_child(this.chatTab); + this.tabs.add_child(this.thinkTab); this.notice = new St.Label({ text: '', style_class: 'jarvis-notice', visible: false, x_expand: true }); this.notice.accessible_name = 'Jarvis notice'; this.confirm = new St.BoxLayout({ style_class: 'jarvis-confirm', visible: false, x_expand: true, vertical: compact }); @@ -79,6 +101,13 @@ export class ConversationView { this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true }); this.transcript.accessible_name = 'Conversation transcript'; if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript); + this.thinkingScroll = new St.ScrollView({ style_class: compact ? 'jarvis-thinking-scroll' : 'jarvis-session-thinking', overlay_scrollbars: true, x_expand: true, visible: false }); + try { this.thinkingScroll.hscrollbar_policy = St.PolicyType.NEVER; this.thinkingScroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {} + this.thinkingBox = new St.BoxLayout({ style_class: 'jarvis-thinking-box', vertical: true, x_expand: true }); + this.thinking = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-thinking', x_expand: true, can_focus: true })); + this.thinking.accessible_name = 'Jarvis thinking'; + this.thinkingBox.add_child(this.thinking); + if (typeof this.thinkingScroll.set_child === 'function') this.thinkingScroll.set_child(this.thinkingBox); else this.thinkingScroll.add_child(this.thinkingBox); this.chipScroll = new St.ScrollView({ style_class: 'jarvis-chip-scroll', overlay_scrollbars: true, x_expand: true, visible: false }); try { this.chipScroll.vscrollbar_policy = St.PolicyType.NEVER; this.chipScroll.hscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {} this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); @@ -113,17 +142,20 @@ export class ConversationView { } this.root.add_child(this.header); this.root.add_child(this.statusLine); - this.root.add_child(this.thinkingToggle); - this.root.add_child(this.thinking); + this.root.add_child(this.tabs); this.root.add_child(this.notice); this.root.add_child(this.confirm); this.root.add_child(this.scroll); + this.root.add_child(this.thinkingScroll); this.root.add_child(this.chipScroll); this.root.add_child(this.entry); this.root.add_child(this.controls); this.streamingReply = false; this.replyFinalized = false; this._state = 'ARMED'; + this._tab = 'chat'; + this._userPickedTab = false; + this._activity = ''; this.reducedMotion = false; } _bindChip(button, action) { @@ -132,24 +164,42 @@ export class ConversationView { clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; - this.thinking.visible = false; - this.thinkingToggle.visible = false; this.notice.visible = false; this.confirm.visible = false; this.chipScroll.visible = false; this.chips.destroy_all_children(); this.streamingReply = false; this.replyFinalized = false; + this._activity = ''; + this._userPickedTab = false; + this.showTab('chat'); } setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); } - _followConversation() { - const adjustment = this.scroll?.get_vadjustment?.() || this.scroll?.vadjustment; + _followConversation() { this._followScroll(this.scroll); } + _followThinking() { this._followScroll(this.thinkingScroll); } + _followScroll(scroll) { + const adjustment = scroll?.get_vadjustment?.() || scroll?.vadjustment; if (!adjustment) return; GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE || GLib.PRIORITY_DEFAULT, () => { try { adjustment.value = Math.max(0, adjustment.upper - adjustment.page_size); } catch {} return GLib.SOURCE_REMOVE; }); } + showTab(name, { user = false } = {}) { + if (user) this._userPickedTab = true; + this._tab = name === 'thinking' ? 'thinking' : 'chat'; + this._applyTab(); + } + _applyTab() { + const thinking = this._tab === 'thinking'; + this.chatTab.style_class = thinking ? 'jarvis-tab' : 'jarvis-tab jarvis-tab-active'; + this.thinkTab.style_class = thinking ? 'jarvis-tab jarvis-tab-active' : 'jarvis-tab'; + this.scroll.visible = !thinking; + this.thinkingScroll.visible = thinking; + this.chipScroll.visible = !thinking && this.chips.get_n_children() > 0; + this.entry.visible = !thinking; + this.talk.visible = !thinking; + } addRow(who, text) { const body = safeText(text); const row = wrapLabel(new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true })); @@ -182,14 +232,67 @@ export class ConversationView { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; - this.thinkingToggle.visible = true; - this.thinkingToggle.label = 'Thinking ▾'; + if (this._state === 'THINKING' && !this._userPickedTab) this.showTab('thinking'); + this._followThinking(); } - toggleThinking() { this.thinking.visible = !this.thinking.visible; this.thinkingToggle.label = this.thinking.visible ? 'Thinking ▾' : 'Thinking ▸'; } - finishThinking() { if (this.thinking.text) { this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▸'; this.thinking.visible = false; } } - addToolCall(json) { this.streamingReply = false; this.replyFinalized = false; try { const call = JSON.parse(json); this._addToolRow(`Using ${safeText(call.name || 'tool')}…`); } catch { this._addToolRow(`Using ${safeText(json)}…`); } } - addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); const name = safeText(result.name || 'tool'); const preview = previewToolResult(result.result); this._addToolRow(preview ? `${name}: ${preview}` : `${name} complete`); } catch { this._addToolRow('Tool complete'); } } - _addToolRow(text) { const row = this.addRow('J', text); row.style_class = `${row.style_class} jarvis-row-tool`; } + toggleThinking() { this.showTab(this._tab === 'thinking' ? 'chat' : 'thinking', { user: true }); } + finishThinking() { if (this._state !== 'THINKING') this.showTab('chat'); } + addToolCall(json) { + this.streamingReply = false; + this.replyFinalized = false; + let name = 'tool'; + try { name = JSON.parse(json).name || 'tool'; } catch { name = json; } + const pretty = prettyToolName(name); + this._activity = `Using ${pretty}`; + this._refreshStatusLine(); + this._setToolActivity(pretty); + } + addToolResult(json) { + this.streamingReply = false; + let name = 'tool'; + let preview = ''; + let failed = false; + try { + const result = JSON.parse(json); + name = result.name || 'tool'; + const payload = result.result; + if (payload && typeof payload === 'object' && payload.error) { + failed = true; + preview = previewToolResult(payload.error); + } else { + preview = previewToolResult(payload); + if (/error|timed out|HTTP \d+/i.test(preview)) failed = true; + } + } catch { + preview = previewToolResult(json); + } + const pretty = prettyToolName(name); + this._setToolActivity(failed && preview ? `${pretty} · ${preview}` : preview ? `${pretty} · ${preview}` : `${pretty} · done`); + if (this._state === 'THINKING') { + this._activity = failed ? `${pretty} failed` : `${pretty} · done`; + this._refreshStatusLine(); + } + } + _setToolActivity(text) { + const body = safeText(text); + let row = this.transcript.get_last_child?.(); + if (!row || !String(row.style_class || '').includes('jarvis-row-tool')) { + row = wrapLabel(new St.Label({ text: body, style_class: 'jarvis-row jarvis-row-tool', can_focus: true })); + this.transcript.add_child(row); + while (this.transcript.get_n_children() > this.maxRows) { + const first = this.transcript.get_first_child(); + if (!first) break; + if (typeof this.transcript.remove_child === 'function') this.transcript.remove_child(first); + else first.destroy(); + } + } else { + row.text = body; + } + row.accessible_name = `Activity: ${body}`; + this._followConversation(); + return row; + } + _addToolRow(text) { return this._setToolActivity(text); } offerConfirm(tool, argsJson, pattern) { let jobId = ''; let toolCallId = ''; let detail = safeText(pattern); try { @@ -234,18 +337,43 @@ export class ConversationView { setState(state) { const value = STATES.has(state) ? state : 'ARMED'; this._state = value; - this.statusLine.text = `${value.toLowerCase()} · Hold Talk to speak`; + if (value === 'LISTENING') { + this._userPickedTab = false; + this._activity = ''; + this.showTab('chat'); + } else if (value === 'THINKING') { + if (!this._userPickedTab) this.showTab('thinking'); + } else if (value === 'SPEAKING' || value === 'ARMED') { + this._userPickedTab = false; + this.showTab('chat'); + } + this._refreshStatusLine(); this.title.text = value === 'SLEEPING' ? 'Jarvis · privacy' : 'Jarvis'; this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`; } + _refreshStatusLine() { + const value = this._state; + if (value === 'LISTENING') this.statusLine.text = 'Listening'; + else if (value === 'SPEAKING') this.statusLine.text = 'Speaking'; + else if (value === 'SLEEPING') this.statusLine.text = 'privacy · microphone off'; + else if (value === 'THINKING') this.statusLine.text = this._activity || 'Thinking'; + else this.statusLine.text = 'armed · Hold Talk to speak'; + } addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', reactive: true, can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); - this.chipScroll.visible = true; + this.chipScroll.visible = this._tab !== 'thinking'; + } + addStep(json) { + try { + const step = JSON.parse(json); + const action = String(step.action || ''); + if (action === 'grant' || action === 'revoke' || action === 'backend') return; + this.addRow('J', `${step.n ? `${step.n}. ` : ''}${action || 'computer step'}`); + } catch { this.addRow('J', json); } } - addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } } endTalk() { this.onTalk?.(false); } destroy() { this.root.destroy(); } } @@ -321,8 +449,12 @@ export class ComputerUseChrome { addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this._reveal(); } setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this._reveal(); } addStep(json) { - try { const step = JSON.parse(json); this.step.text = `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`; } - catch { this.step.text = safeText(json); } + let step = {}; + try { step = JSON.parse(json); } catch { return; } + const action = String(step.action || ''); + if (action === 'revoke') { this.hide(); return; } + if (action === 'grant' || action === 'backend') return; + this.step.text = `${step.n ? `${step.n}. ` : ''}${action || 'computer step'}`; this.step.visible = true; this._reveal(); } @@ -346,6 +478,7 @@ export class SessionPanel { this.root.set_width(width); this.root.set_position(monitor.x + Math.max(24, monitor.width - width - 24), monitor.y + 40); this.view.scroll.set_height(Math.max(120, Math.min(280, monitor.height - 360))); + this.view.thinkingScroll.set_height(Math.max(120, Math.min(240, monitor.height - 400))); } this.root.visible = true; } diff --git a/docs/security-privacy.md b/docs/security-privacy.md index 3f3160e..d8d7062 100644 --- a/docs/security-privacy.md +++ b/docs/security-privacy.md @@ -1,8 +1,10 @@ # Security and privacy -The default posture is local-only, explicit, and fail-closed. No telemetry or -cloud inference is required. The network policy rejects unexpected outbound -model or tool traffic; optional model-fetch features remain user initiated. +The default posture is local inference, explicit writes, and fail-closed +computer use. No telemetry or cloud inference is required. Model endpoints +stay on localhost. Public `web_search` and `web_fetch` are allowed by default +without a confirmation prompt. Shell commands that open public HTTP (curl, +wget) remain blocked by the runtime; use the web tools instead. ```mermaid flowchart TD diff --git a/skills/voice-prompt.js b/skills/voice-prompt.js index 2d3d420..3a32d5b 100644 --- a/skills/voice-prompt.js +++ b/skills/voice-prompt.js @@ -1,24 +1,27 @@ -export const VOICE_SYSTEM_PROMPT = `You are Jarvis, a local Ubuntu GNOME voice assistant running through Quantum Verse Automatic Computer, spelled Q V A C. -Q V A C stands for Quantum Verse Automatic Computer. In speech say Quantum Verse Automatic Computer, or spell it as Q V A C. Never say the letters as one word. -Use short spoken replies of one to three sentences unless the user asks for detail. -Every reply must be speakable out loud by text to speech. Write only words and numbers a person can say. +export const VOICE_SYSTEM_PROMPT = `You are Jarvis, a local Ubuntu GNOME voice assistant. The language model is Quantum Verse Automatic Computer, spelled Q V A C. In speech say Quantum Verse Automatic Computer, or spell it as Q V A C. Never say QVAC as one word. + +Speak one to three short sentences unless the user asks for more. Every reply is read aloud. Write only words and numbers a person can say. Reply in plain text only. Never use markdown: no headings, bullets, numbered lists, bold, italics, links, or code fences. -Never use acronyms or initialisms as a single 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 (central processing unit, graphics processor, memory, operating system). -Never speak punctuation. Internet protocol addresses have no dots: say 192 168 0 1, never 192.168.0.1. Host names use the word dot, for example example dot com. Paths and addresses use the word slash, never a slash character. Colons, underscores, hyphens, and at signs are the words colon, underscore, dash, and at. -Ground every desktop, file, memory, model, and network claim in a tool result. -The language model runs locally on this computer. This computer can reach the internet. -For any H T T P or H T T P S address, call web_fetch and speak the facts from the result. Do not use curl or wget. Never say that network or public sites are unavailable unless web_fetch itself failed. +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. + +Tool names, tool arguments, paths, and U R L strings use ordinary spelling. Only the final spoken reply is punctuation-free. +Thinking is private. After thoughts, call a tool or speak the answer. Do not stop in thoughts or say you will search later. + +Ground desktop, file, memory, model, and network claims in a tool result. Do not invent limits the tools did not report. +This computer can reach the internet. web_search and web_fetch 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. After web_search, call web_fetch on one real http or https page from the hits, then speak the answer. DuckDuckGo redirect links are not an answer. For this computer's public I P, call web_fetch on https://ifconfig.me/ip first. Use web_fetch for any website or I P lookup page. 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. + Computer use requires an explicit user grant from Settings, Computer use, Allow now, or Grant desktop in the tray. Never click or type while it is inactive, locked, or revoked. Never ask for passwords or credentials. Destructive actions require confirmation in both the heads-up display and spoken conversation. Prefer structured tools and accessibility references over coordinates. -For questions about the computer, files, processes, or system state, call the -most relevant registered tool before answering. Never say that computer use or -terminal access is unavailable unless a tool result reports that limitation. -When the user asks to use the command line or terminal, call run_terminal_cmd -once, then speak the result. Do not chain extra commands (hostnamectl then -uname then free) unless the previous result was an error. + +For questions about the computer, files, processes, or system state, call the most relevant registered tool before answering. Never say that computer use or terminal access is unavailable unless a tool result reports that limitation. +When the user asks to use the command line or terminal, call run_terminal_cmd once, then speak the result. Do not chain extra commands (hostnamectl then uname then free) unless the previous result was an error. If a tool result contains stdout or a command listing, quote the facts from it in speakable words. Do not say you received no output unless the result is exactly "(no output)". + When a useful follow-up action exists, append a HUD sidecar exactly as {"title":"...","chips":[{"id":"...","label":"..."}],"confirmation":null}. The sidecar is for the heads-up display and must not be spoken.`; diff --git a/test/gnome-extension.test.js b/test/gnome-extension.test.js index fe1f95c..54597b4 100644 --- a/test/gnome-extension.test.js +++ b/test/gnome-extension.test.js @@ -27,7 +27,9 @@ function harness() { this.visible = props.visible !== false; this.text = props.text || props.hint_text || ''; this.clutter_text = { connect() {}, ellipsize: null }; + this.vadjustment = { value: 0, upper: 240, page_size: 80 }; } + get_vadjustment() { return this.vadjustment; } add_child(child) { this.children.push(child); child.get_parent = () => this; } remove_child(child) { this.children = this.children.filter((item) => item !== child); } destroy_all_children() { this.children = []; } @@ -52,6 +54,15 @@ function harness() { get_text() { return this.text || ''; } set_text(value) { this.text = value; } } + class BoxLayout extends Actor {} + class Label extends Actor {} + class ScrollView extends Actor { + set_child(child) { + if (child instanceof Label) throw new TypeError('Object is of type St.Label - cannot convert to StScrollable'); + this.destroy_all_children(); + this.add_child(child); + } + } const menu = { box: new Actor(), actor: new Actor(), @@ -66,7 +77,7 @@ function harness() { const context = vm.createContext({ Extension: class {}, global: { stage: { get_key_focus() { return null; } } }, - St: { BoxLayout: Actor, Label: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView: Actor, PolicyType: { NEVER: 0, AUTOMATIC: 1 } }, + St: { BoxLayout, Label, Widget: Actor, Button: Actor, Entry: Actor, ScrollView, PolicyType: { NEVER: 0, AUTOMATIC: 1 } }, Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0, KEY_space: 32, KEY_Return: 65293, KEY_Escape: 65307 }, Pango: { WrapMode: { WORD_CHAR: 2 }, EllipsizeMode: { NONE: 0, END: 3 } }, PopupMenu: { @@ -123,7 +134,7 @@ test('empty chrome widgets start hidden', () => { const popup = new ConversationView({ compact: true }); const cu = new ComputerUseChrome(); assert.equal(popup.confirm.visible, false); - assert.equal(popup.thinking.visible, false); + assert.equal(popup.thinkingScroll.visible, false); assert.equal(popup.chipScroll.visible, false); assert.equal(cu.root.visible, false); assert.equal(cu.target.visible, false); @@ -147,8 +158,9 @@ test('final reply is shown after tool result rows', () => { popup.addToolCall('{"name":"runtime_status"}'); popup.addToolResult('{"name":"runtime_status"}'); popup.finalizeReply('Your computer is ready.'); - assert.equal(popup.transcript.children.length, 3); - assert.match(popup.transcript.children[2].text, /Your computer is ready/); + assert.equal(popup.transcript.children.length, 2); + assert.match(popup.transcript.children[0].style_class, /jarvis-row-tool/); + assert.match(popup.transcript.children[1].text, /Your computer is ready/); }); test('tokens after a tool result start a new spoken Jarvis row', () => { @@ -158,9 +170,9 @@ test('tokens after a tool result start a new spoken Jarvis row', () => { popup.addToolResult('{"name":"capability_status"}'); popup.token('Your computer is ready.'); popup.finalizeReply('Your computer is ready.'); - assert.equal(popup.transcript.children.length, 3); - assert.match(popup.transcript.children[2].text, /Your computer is ready/); - assert.doesNotMatch(popup.transcript.children[2].style_class, /jarvis-row-tool/); + assert.equal(popup.transcript.children.length, 2); + assert.match(popup.transcript.children[1].text, /Your computer is ready/); + assert.doesNotMatch(popup.transcript.children[1].style_class, /jarvis-row-tool/); }); test('whitespace-only tokens do not open an empty Jarvis row', () => { @@ -310,6 +322,12 @@ test('computer-use chrome shows a target without a transcript', () => { assert.equal(cu.target.visible, true); assert.equal(cu.cursor.visible, true); assert.ok(!cu.transcript); + cu.addStep('{"action":"revoke"}'); + assert.equal(cu.root.visible, false); + cu.setTarget('{"rect":[1,2,3,4]}'); + cu.addStep('{"action":"grant"}'); + assert.equal(cu.step.visible, false); + assert.equal(cu.root.visible, true); cu.hide(); assert.equal(cu.root.visible, false); }); @@ -342,12 +360,36 @@ test('Always allow Confirm remembers the decision string', () => { assert.deepEqual(answers, [['job-2', 'call-3', 'always']]); }); +test('truncated fetch JSON previews status and URL, not HTML', () => { + const { ConversationView } = harness(); + const popup = new ConversationView({ compact: true }); + popup.addToolCall('{"name":"web_fetch"}'); + popup.addToolResult(JSON.stringify({ + name: 'web_fetch', + result: '{"status":200,"url":"https://cage.report/CAGE/8GPQ2","text":" { + const { ConversationView } = harness(); + const popup = new ConversationView({ compact: true }); + popup.addToolCall('{"name":"web_search"}'); + popup.addToolResult(JSON.stringify({ + name: 'web_search', + result: JSON.stringify([{ url: 'https://whatismyipaddress.com/ip-lookup', title: 'IP Lookup' }]), + })); + assert.match(popup.transcript.children[0].text, /IP Lookup/); + assert.doesNotMatch(popup.transcript.children[0].text, /uddg|whatismyipaddress/); +}); + test('tool results show a stdout preview instead of only complete', () => { const { ConversationView } = harness(); const popup = new ConversationView({ compact: true }); popup.addToolCall('{"name":"run_terminal_cmd"}'); popup.addToolResult(JSON.stringify({ name: 'run_terminal_cmd', result: 'Linux 6.8\nexit 0' })); - assert.match(popup.transcript.children[1].text, /Linux 6\.8/); + assert.match(popup.transcript.children[0].text, /Linux 6\.8/); }); test('errors and reset failures are one-line notices, not chat rows', () => { @@ -406,6 +448,58 @@ test('closing the popup ends hold-to-talk', () => { assert.deepEqual(talks, [false]); }); +test('Chat and Thinking tabs exist and thinking auto-follows', () => { + const { ConversationView } = harness(); + const popup = new ConversationView({ compact: true }); + const session = new ConversationView({ compact: false }); + for (const view of [popup, session]) { + assert.equal(view.chatTab.label, 'Chat'); + assert.equal(view.thinkTab.label, 'Thinking'); + assert.equal(view.thinkingBox.children[0], view.thinking); + assert.equal(view.thinkingScroll.children[0], view.thinkingBox); + assert.equal(view.thinkingScroll.visible, false); + assert.equal(view.scroll.visible, true); + view.setState('THINKING'); + view.updateThinking('Considering the lookup. '); + assert.equal(view._tab, 'thinking'); + assert.equal(view.thinkingScroll.visible, true); + assert.equal(view.scroll.visible, false); + assert.match(view.thinking.text, /Considering the lookup/); + assert.equal(view.thinking.clutter_text.ellipsize, 0); + assert.equal(view.thinkingScroll.vadjustment.value, 160); + view.showTab('chat', { user: true }); + view.updateThinking('still thinking'); + assert.equal(view._tab, 'chat'); + assert.equal(view.thinkingScroll.visible, false); + assert.match(view.thinking.text, /still thinking/); + } +}); + +test('tool calls are activity rows, not spoken Jarvis replies', () => { + const { ConversationView } = harness(); + const popup = new ConversationView({ compact: true }); + popup.addToolCall('{"name":"web_fetch"}'); + assert.equal(popup.transcript.children.length, 1); + assert.match(popup.transcript.children[0].style_class, /jarvis-row-tool/); + assert.equal(popup.transcript.children[0].text, 'web fetch'); + assert.doesNotMatch(popup.transcript.children[0].text, /^J\s/); + assert.doesNotMatch(popup.transcript.children[0].style_class, /jarvis-row-jarvis/); + popup.addToolResult(JSON.stringify({ name: 'web_fetch', result: { error: 'timed out after 12000ms', url: 'https://ifconfig.me/ip' } })); + assert.equal(popup.transcript.children.length, 1); + assert.match(popup.transcript.children[0].text, /web fetch · timed out/); +}); + +test('status line describes the live THINKING step', () => { + const { ConversationView } = harness(); + const popup = new ConversationView({ compact: true }); + popup.setState('THINKING'); + assert.equal(popup.statusLine.text, 'Thinking'); + popup.addToolCall('{"name":"web_fetch"}'); + assert.equal(popup.statusLine.text, 'Using web fetch'); + popup.setState('LISTENING'); + assert.equal(popup.statusLine.text, 'Listening'); +}); + test('both settings entry points use the shared preferences window', () => { const prefs = readFileSync(new URL('../apps/gnome-extension/jarvis@qvac.local/prefs.js', import.meta.url), 'utf8'); const control = readFileSync(new URL('../apps/control-center/main.js', import.meta.url), 'utf8'); diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js index d58db75..bfb588b 100644 --- a/test/runtime-tools.test.js +++ b/test/runtime-tools.test.js @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createRuntimeTools } from '../skills/runtime-tools.js'; @@ -26,8 +27,44 @@ test('voice sidecars are removed from speech and retained for the HUD', () => { assert.equal(parsed.hud.title, 'Done'); }); +test('web_fetch times out instead of hanging the turn', async () => { + const require = createRequire(import.meta.url); + const tools = require('../vendor/agent-harness/agent/tools.js'); + const orig = globalThis.fetch; + globalThis.fetch = () => new Promise(() => {}); + try { + const hung = await tools.webFetch('https://example.com/ip', 40); + assert.match(String(hung.error), /timed out/i); + assert.equal(hung.url, 'https://example.com/ip'); + } finally { + globalThis.fetch = orig; + } + globalThis.fetch = async (url) => ({ + status: 200, + url: String(url), + text: async () => '203.0.113.8', + }); + try { + const ok = await tools.webFetch('https://ifconfig.me/ip', 200); + assert.equal(ok.status, 200); + assert.equal(ok.url, 'https://ifconfig.me/ip'); + assert.equal(ok.text, '203.0.113.8'); + } finally { + globalThis.fetch = orig; + } +}); + +test('public web search and fetch do not require confirmation', () => { + const require = createRequire(import.meta.url); + const policy = require('../vendor/agent-harness/agent/policy.js'); + assert.equal(policy.needsPermission('web_fetch', 'ask'), false); + assert.equal(policy.needsPermission('web_search', 'ask'), false); + assert.equal(policy.needsPermission('run_terminal_cmd', 'ask'), true); + assert.equal(policy.needsPermission('write_file', 'ask'), true); +}); + test('voice prompt tells the model not to chain extra terminal commands', () => { - assert.match(VOICE_SYSTEM_PROMPT, /call run_terminal_cmd\nonce/s); + assert.match(VOICE_SYSTEM_PROMPT, /call run_terminal_cmd once/); assert.match(VOICE_SYSTEM_PROMPT, /Do not chain extra commands/); assert.match(VOICE_SYSTEM_PROMPT, /Reply in plain text only/); assert.match(VOICE_SYSTEM_PROMPT, /Never use markdown/); @@ -35,8 +72,15 @@ test('voice prompt tells the model not to chain extra terminal commands', () => 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, /Spell them as separate letters/); - assert.match(VOICE_SYSTEM_PROMPT, /call web_fetch/); + assert.match(VOICE_SYSTEM_PROMPT, /web_fetch/); + assert.match(VOICE_SYSTEM_PROMPT, /web_search and web_fetch are unrestricted/); + assert.match(VOICE_SYSTEM_PROMPT, /Never say you will use a tool/); + assert.match(VOICE_SYSTEM_PROMPT, /Do not stop in thoughts/); + assert.match(VOICE_SYSTEM_PROMPT, /ifconfig\.me\/ip/); assert.match(VOICE_SYSTEM_PROMPT, /This computer can reach the internet/); + assert.match(VOICE_SYSTEM_PROMPT, /HTTP access is not allowed/); + assert.match(VOICE_SYSTEM_PROMPT, /Tool names, tool arguments/); + assert.match(VOICE_SYSTEM_PROMPT, /File tools may read any path they accept/); assert.match(VOICE_SYSTEM_PROMPT, /Allow now/); assert.doesNotMatch(VOICE_SYSTEM_PROMPT, /Never claim cloud access/); }); diff --git a/test/shell-tools.test.js b/test/shell-tools.test.js index 12e3a0a..0fa15b1 100644 --- a/test/shell-tools.test.js +++ b/test/shell-tools.test.js @@ -31,10 +31,57 @@ test('run_terminal_cmd description still exists for the model', () => { assert.match(def.description, /shell command/i); }); +test('voice unfinished tool talk nudges another call', () => { + const voice = toolBudget.fromPayload({}, 'jarvis-qvac'); + assert.equal(toolBudget.shouldNudgeToolCall('Let me fetch a specific lookup result for the IP address.', voice), true); + assert.equal(toolBudget.shouldNudgeToolCall('Let me search again more specifically.', voice), true); + assert.equal(toolBudget.shouldNudgeToolCall('I should search more specifically for Honeypier LLC.', voice), true); + assert.equal(toolBudget.shouldNudgeToolCall('Your public I P is 203 0 113 8.', voice), false); + assert.equal(toolBudget.shouldNudgeToolCall('Let me know if you need more.', voice), false); + toolBudget.forceAnswer(voice); + assert.equal(toolBudget.shouldNudgeToolCall('Let me fetch it.', voice), false); +}); + +test('DuckDuckGo search hits decode to real page URLs', () => { + assert.equal( + tools.decodeSearchUrl('//duckduckgo.com/l/?uddg=https%3A%2F%2Fwhatismyipaddress.com%2Fip-lookup&rut=abc'), + 'https://whatismyipaddress.com/ip-lookup' + ); +}); + +test('complete watch aborts after idle silence', async () => { + const watch = require('../vendor/agent-harness/lib/complete-watch.js'); + const hits = []; + const w = watch.attachCompleteWatch({ idleMs: 25, abort: () => hits.push('abort') }); + w.bump(); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.equal(w.timedOut(), true); + assert.deepEqual(hits, ['abort']); +}); + +test('complete watch idle is reset by bump', async () => { + const watch = require('../vendor/agent-harness/lib/complete-watch.js'); + const w = watch.attachCompleteWatch({ idleMs: 80, abort() {} }); + w.bump(); + await new Promise((resolve) => setTimeout(resolve, 25)); + w.bump(); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(w.timedOut(), false); + w.clear(); +}); + +test('html pages are stripped to text for web_fetch', () => { + const text = tools.htmlToText('Hi

Honey peer

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