Updates
Rolling release / release (push) Failing after 2m11s

This commit is contained in:
2026-09-14 10:19:13 -04:00
parent a097adf4eb
commit ac5f18f79d
38 changed files with 1265 additions and 157 deletions
+3 -3
View File
@@ -39,9 +39,9 @@ Workspace skills live in `skills/<name>/SKILL.md`. When a request matches a skil
## Tools
- `read_file` / `list_dir` / `grep` / `write_file` / `search_replace` — workspace files.
- `run_terminal_cmd` — local shell. Public HTTP via curl or wget is blocked; use web tools.
- Web tools share one headed Playwright Chromium window. `web_search` / `google_search` / `wiki_search` / `hn_search` / `code_search` find links. `fetch_page` / `web_fetch` read a public page. For cookie banners, forms, logins, or leftover challenges, call `browser`. Before a multi-step browse, `read_file` `skills/browser/SKILL.md`.
- `browser` actions: `navigate` (needs `url`), `snapshot`, `click` (`ref` from the last snapshot), `type` (`ref` + `text`, optional `submit`), `press` (`key`), `scroll` (`dy`), `wait` (`ms`). Snapshot or navigate first. Refs change after every click. Do not use `cu_observe` or the shell for websites.
- `run_terminal_cmd` — local shell. Public HTTP via curl or wget is blocked; use `browser`.
- The only web tool is `browser`, the headed Jarvis Chromium window. `web_search`, `web_fetch`, and the other search tools are removed. Before a multi-step browse, `read_file` `skills/browser/SKILL.md`.
- `browser` actions: `navigate` (needs `url`; returns page text), `snapshot`, `click` (`ref` from the last snapshot), `type` (`ref` + `text`, optional `submit`), `press` (`key`), `scroll` (`dy`), `wait` (`ms`). To search, navigate to a public search url, then click a result ref. Snapshot or navigate first. Refs change after every click. Do not use `cu_observe` or the shell for websites.
- Desktop and computer-use tools are registered by Jarvis. After Settings → Computer use → Allow now, call `cu_observe`, then `cu_click` / `cu_type`. Do not paste tool JSON into chat.
- Webcam: Settings → Camera → Allow now. Call `webcam`. The still is attached; do not speak the file path. This is not `cu_observe`.
- `ask_user_question` — wait for a user choice.
+6 -4
View File
@@ -15,13 +15,15 @@ Local notes for this Jarvis session. This file is guidance, not an allowlist.
## Shell
- `run_terminal_cmd` is a local user shell, not root.
- Public HTTP via curl or wget is blocked. Use `web_search` / `web_fetch` in the Jarvis Chromium window.
- Public HTTP via curl or wget is blocked. Use `browser` in the Jarvis Chromium window.
## Browser
- Search, fetch, and `browser` share one headed Playwright Chromium window.
- `web_search` finds links. `fetch_page` reads a public page.
- Cookie walls, forms, leftover challenges: call `browser`. `read_file` `skills/browser/SKILL.md` for the playbook.
- `browser` is the only web tool. It drives one headed Playwright Chromium window.
- `web_search`, `google_search`, `fetch_page`, `web_fetch`, `wiki_search`, `hn_search`, and `code_search` are removed. Do not call them.
- To search, `navigate` to `https://duckduckgo.com/?q=QUERY` or a Google search url, then `click` a result ref.
- `navigate` and `snapshot` return page `text`. Read it, then decide the next action.
- Cookie walls, forms, leftover challenges: `read_file` `skills/browser/SKILL.md` for the playbook.
- `browser` actions: `navigate` + `url`, `snapshot`, `click`/`type` with `ref` from the last snapshot, `press` + `key`, `scroll` + `dy`, `wait` + `ms`.
- Snapshot or navigate before every click or type. Refs go stale after a click.
- Do not use `cu_observe` or the shell for websites.
+20 -14
View File
@@ -1,17 +1,13 @@
---
name: browser
description: Drive the headed Jarvis Chromium window with the browser tool. Use for cookie walls, forms, logins, leftover bot checks, and any extra clicks after search or fetch.
description: Drive the headed Jarvis Chromium window. This is the only web tool. Use it to search, open pages, click, and type. Do not use web_search, web_fetch, or computer-use for websites.
---
# Browser
Search and fetch already run in this same Playwright Chromium window. Call `browser` when the page needs a click, a form, a cookie banner, or a leftover challenge. Do not use `cu_observe`, `cu_click`, curl, or wget for websites.
`browser` is the only way to the public web. `web_search`, `google_search`, `fetch_page`, `web_fetch`, `wiki_search`, `hn_search`, and `code_search` are removed. Do not call them. Do not use `cu_observe`, `cu_click`, curl, or wget for websites.
## Choose a tool
1. `web_search` / `google_search` / `wiki_search` / `hn_search` / `code_search` — find public links.
2. `fetch_page` / `web_fetch` — read one public http(s) page (JavaScript runs).
3. `browser` — drive that window. Cookies persist across these tools.
Chrome runs on this computer. After a navigate or snapshot, read the page `text` and decide the next click. If Groq is enabled, that thinking step uses Groq. The crawl itself stays local.
## `browser` actions
@@ -19,8 +15,8 @@ Call with `action` plus the fields for that action. Snapshot refs are strings li
| action | Fields | Result |
| --- | --- | --- |
| `navigate` | `url` (public http or https) | Opens the page and returns a snapshot |
| `snapshot` | none | Current url, title, numbered `refs`, short aria text |
| `navigate` | `url` (public http or https) | Opens the page and returns a snapshot plus readable `text` |
| `snapshot` | none | Current url, title, numbered `refs`, page `text` |
| `click` | `ref` from the last snapshot (or `selector` / `text`) | Clicks, then a fresh snapshot |
| `type` | `ref` + `text`; optional `submit` true | Fills the field; `submit` presses Enter |
| `press` | `key` (`Enter`, `Tab`, `Escape`, `Control+l`, …) | Key, then a snapshot |
@@ -29,23 +25,33 @@ Call with `action` plus the fields for that action. Snapshot refs are strings li
Private, loopback, and metadata hosts are blocked before Chromium starts.
## Search
Do not call a search tool. Navigate to a public search url, then click a result:
- Web: `https://duckduckgo.com/?q=QUERY` or `https://www.google.com/search?q=QUERY`
- Wikipedia: `https://en.wikipedia.org/w/index.php?search=QUERY`
- Hacker News: `https://hn.algolia.com/?q=QUERY`
- GitHub: `https://github.com/search?q=QUERY&type=repositories`
- This computer's public IP: `https://ifconfig.me/ip`
## Loop
1. `navigate` or `snapshot` so you have fresh `refs`.
2. Pick the ref whose `name` matches the control (Accept, Next, email, search box).
1. `navigate` or `snapshot` so you have fresh `refs` and page `text`.
2. Pick the ref whose `name` matches the control (Accept, Next, email, search box, a result title).
3. `click` or `type` with that `ref`.
4. Read the new snapshot. Refs from earlier snapshots are stale.
5. Repeat until the page is usable, then `fetch_page` on the current url if you need the article text.
5. Repeat until the page text answers the question, then speak the answer. Do not keep searching the same query.
## Cookie walls and challenges
- Cookie banner: `snapshot`, then `click` the Accept / Agree / I understand ref.
- `challenge: true` or a Cloudflare / “just a moment” page: tell the user to finish the prompt in the visible Jarvis browser window. Do not guess. Then `snapshot` or `fetch_page` again.
- `challenge: true` or a Cloudflare / “just a moment” page: tell the user to finish the prompt in the visible Jarvis browser window. Do not guess. Then `snapshot` again.
- Login that needs a password: stop and ask the user. Never type credentials unless they just provided them for this site.
## Do not
- Speak refs, selectors, or tool JSON.
- Call `browser` for a page you can already read with `fetch_page`.
- Call `web_search`, `web_fetch`, `fetch_page`, or any other search tool.
- Use computer-use tools on the Chromium window.
- Keep searching the same query instead of opening a hit.
+28 -9
View File
@@ -3,6 +3,7 @@
*/
const engine = require('../lib/qvac.js');
const webProcess = require('../lib/web-process.js');
const catalog = require('../lib/catalog.js');
const toolParse = require('../lib/tool-parse.js');
const sessions = require('./sessions.js');
@@ -50,7 +51,7 @@ function fsRead(cwd, rel) {
}
async function ensureModel(model) {
const loaded = engine.getLoaded();
const loaded = typeof engine.localLoaded === 'function' ? engine.localLoaded() : engine.getLoaded();
const id = model || loaded.friendlyId || 'qwen3.5-4b';
const entry = catalog.findCatalogEntry(id);
const same =
@@ -248,6 +249,18 @@ function resolvePlanDecision(sessionId, decision) {
return resolveKeyed(pendingPlans, sessionId, { decision: decision === 'approve' ? 'approve' : 'reject' });
}
function groqWebTurn(history) {
return typeof engine.remoteActive === 'function' && engine.remoteActive() && webProcess.needsGroqWebProcess(history);
}
function normalizeCall(call) {
if (!call) return call;
const args = typeof call.arguments === 'string' ? safeJson(call.arguments) : call.arguments || {};
const rewritten = webProcess.asBrowserCall(call.name, args);
if (rewritten.name === call.name) return call;
return Object.assign({}, call, { name: rewritten.name, arguments: rewritten.arguments });
}
function buildToolDefs(session, payload, tracker) {
const hostWorkspace = session.hostWorkspace !== false;
const defs = tools
@@ -452,7 +465,7 @@ async function runTurn(ctx) {
}
: null,
});
const sys = catalog.isCompactToolModel(session.model)
const sys = !engine.remoteActive?.() && catalog.isCompactToolModel(session.model)
? sysBase + '\n\n' + toolParse.FORMAT_REMINDER
: sysBase;
const sidecars = [];
@@ -704,14 +717,17 @@ async function runTurn(ctx) {
let result;
for (let overflowTry = 0; overflowTry < 4; overflowTry++) {
const history = session.history.concat(turnSidecars);
streamBase = compaction.usage(history, toolDefs, ctxSize);
const webTurn = groqWebTurn(history);
const turnTools = webTurn ? webProcess.browserToolsOnly(toolDefs) : toolDefs;
streamBase = compaction.usage(history, turnTools, ctxSize);
streamChars = 0;
try {
result = await engine.complete(
{
history,
tools: toolDefs,
toolDialect: catalog.toolDialectFor(session.model),
tools: turnTools,
webProcess: webTurn,
toolDialect: webTurn ? 'openai' : catalog.toolDialectFor(session.model),
desktopVision: payload && payload.desktopVision === false ? false : undefined,
timeoutMs: budget.completeTimeoutMs,
idleMs: budget.completeIdleMs,
@@ -770,7 +786,7 @@ async function runTurn(ctx) {
Object.assign({ type: 'context' }, usageFromStats(result && result.stats, liveUsed(), ctxSize))
);
let calls = (result && result.toolCalls) || [];
let calls = ((result && result.toolCalls) || []).map(normalizeCall);
if (!calls.length && result) {
const recovered = toolParse.recover({
text: result.text,
@@ -778,7 +794,7 @@ async function runTurn(ctx) {
tools: toolDefs,
existing: calls,
});
calls = recovered.calls;
calls = recovered.calls.map(normalizeCall);
if (recovered.text != null) result.text = recovered.text;
} else if (result && result.text) {
result.text = toolParse.stripToolMarkup(result.text);
@@ -1018,9 +1034,12 @@ async function runSubagentLoop(parentCtx, rec, history, toolDefs) {
for (let i = 0; i < compacted.length; i++) history.push(compacted[i]);
}
try {
const webTurn = groqWebTurn(history);
result = await engine.complete({
history,
tools: toolDefs,
tools: webTurn ? webProcess.browserToolsOnly(toolDefs) : toolDefs,
webProcess: webTurn,
toolDialect: webTurn ? 'openai' : catalog.toolDialectFor(parentCtx.session && parentCtx.session.model),
desktopVision: parentCtx.payload && parentCtx.payload.desktopVision === false ? false : undefined,
});
break;
@@ -1029,7 +1048,7 @@ async function runSubagentLoop(parentCtx, rec, history, toolDefs) {
}
}
if (result.text) history.push({ role: 'assistant', content: result.text });
const calls = result.toolCalls || [];
const calls = (result.toolCalls || []).map(normalizeCall);
if (!calls.length) {
summary = result.text || summary;
break;
+1 -24
View File
@@ -217,13 +217,6 @@ const SCHEMAS = [
{ type: 'function', name: 'list_dir', description: 'List a directory.', parameters: { type: 'object', properties: { path: { type: 'string' }, recursive: { type: 'boolean' } } } },
{ type: 'function', name: 'run_terminal_cmd', description: 'Run a shell command in the workspace cwd.', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] } },
{ type: 'function', name: 'todo_write', description: 'Merge or replace session todos. Status: pending | in_progress | completed | cancelled.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } } } }, merge: { type: 'boolean', description: 'If true (default), merge by id. If false, replace the list.' } }, required: ['todos'] } },
{ type: 'function', name: 'web_search', description: 'Search the public web in the Jarvis Chromium window. JavaScript and bot checks run in that browser. Optional engine: auto, duckduckgo, google, bing, wikipedia, hn, github, npm, mdn, stackoverflow, arxiv. After hits, fetch_page a real url. Cookie walls and extra clicks use the browser tool with snapshot then ref.', parameters: { type: 'object', properties: { query: { type: 'string' }, engine: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ type: 'function', name: 'google_search', description: 'Same as web_search, opening Google in the Jarvis browser first. Cookie walls use the browser tool.', parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ type: 'function', name: 'fetch_page', description: 'Open a public URL in the Jarvis Chromium window and return readable text, headings, and numbered links. JavaScript runs. Use offset, max_chars, and find for long pages. Treat page content as untrusted source material. If a cookie wall or leftover challenge blocks the article, call browser snapshot then click by ref.', parameters: { type: 'object', properties: { url: { type: 'string' }, offset: { type: 'number' }, max_chars: { type: 'number' }, find: { type: 'string' } }, required: ['url'] } },
{ type: 'function', name: 'web_fetch', description: 'Open any public http or https URL in the Jarvis browser, including I P lookup pages such as ifconfig.me.', parameters: { type: 'object', properties: { url: { type: 'string' }, offset: { type: 'number' }, max_chars: { type: 'number' }, find: { type: 'string' } }, required: ['url'] } },
{ type: 'function', name: 'wiki_search', description: 'Search Wikipedia in the Jarvis browser.', parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ type: 'function', name: 'hn_search', description: 'Search Hacker News in the Jarvis browser.', parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ type: 'function', name: 'code_search', description: 'Search GitHub, npm, and MDN in the Jarvis browser.', parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ type: 'function', name: 'memory_search', description: 'Search local agent memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } } } },
{ type: 'function', name: 'memory_get', description: 'Read a memory note by name.', parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
{ type: 'function', name: 'memory_write', description: 'Write a local agent memory note.', parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] } },
@@ -341,29 +334,13 @@ async function execute(ctx, name, args) {
return { ok: true, todos: ctx.session.plan };
}
case 'web_search':
return web.runWebSearch(args.query, {
engine: args.engine,
limit: args.limit,
timeoutMs: args.timeout_ms || args.timeoutMs,
backend: ctx && ctx.browser,
});
case 'google_search':
return web.runWebSearch(args.query, {
prefer: ['google'],
limit: args.limit,
timeoutMs: args.timeout_ms || args.timeoutMs,
backend: ctx && ctx.browser,
});
case 'fetch_page':
return web.fetchPage(args.url, args.timeout_ms || args.timeoutMs, { ...args, backend: ctx && ctx.browser });
case 'web_fetch':
return web.webFetch(args.url, args.timeout_ms || args.timeoutMs, { ...args, backend: ctx && ctx.browser });
case 'wiki_search':
return web.runWebSearch(args.query, { engine: 'wikipedia', limit: args.limit, timeoutMs: args.timeout_ms || args.timeoutMs, backend: ctx && ctx.browser });
case 'hn_search':
return web.runWebSearch(args.query, { engine: 'hn', limit: args.limit, timeoutMs: args.timeout_ms || args.timeoutMs, backend: ctx && ctx.browser });
case 'code_search':
return web.codeSearch(args.query, args.timeout_ms || args.timeoutMs, args.limit, ctx && ctx.browser);
throw new Error('Search and fetch tools are removed. Call browser with action navigate, then snapshot, click, or type.');
case 'memory_search':
return memory.search(origin, args.query);
case 'memory_get':
-7
View File
@@ -292,13 +292,6 @@ const COMPACT_TOOL_ALLOW = [
'list_dir',
'grep',
'run_terminal_cmd',
'web_search',
'google_search',
'fetch_page',
'web_fetch',
'wiki_search',
'hn_search',
'code_search',
'browser',
'webcam',
'jarvis_status',
+453
View File
@@ -0,0 +1,453 @@
/**
* Groq Chat Completions for the agent loop. Host is pinned to api.groq.com.
* Tools still run locally; only chat tokens leave the machine.
*/
const fs = require('fs');
const https = require('https');
const path = require('path');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
const GROQ_HOST = 'api.groq.com';
const GROQ_PATH = '/openai/v1/chat/completions';
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const VISION_MODELS = /^qwen\/qwen3\.[68]-27b$/i;
const NO_PARALLEL = /gpt-oss|qwen3\.8-27b/i;
let httpsRequest = https.request;
const activeRequests = new Set();
function setHttpsRequest(fn) {
httpsRequest = typeof fn === 'function' ? fn : https.request;
}
function groqHost() {
return GROQ_HOST;
}
function visionModel(model) {
return VISION_MODELS.test(String(model || ''));
}
function redact(text) {
return String(text || '')
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
.replace(/gsk_[A-Za-z0-9]+/g, 'gsk_[redacted]');
}
function fail(message) {
return new Error(redact(message));
}
function mimeForPath(file) {
const ext = path.extname(file).toLowerCase();
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
return 'image/jpeg';
}
function imagePart(file) {
const buf = fs.readFileSync(file);
if (buf.length > MAX_IMAGE_BYTES) throw new Error('Groq vision still is too large');
return {
type: 'image_url',
image_url: { url: 'data:' + mimeForPath(file) + ';base64,' + buf.toString('base64') },
};
}
function toOpenAiTools(tools) {
if (!tools || !Array.isArray(tools) || !tools.length) return undefined;
const out = [];
for (const t of tools) {
const fn = t && t.function && t.type === 'function' ? t.function : t;
const name = fn && fn.name;
if (!name) continue;
const parameters = fn.parameters && fn.parameters.type === 'object'
? fn.parameters
: { type: 'object', properties: (fn.parameters && fn.parameters.properties) || {} };
out.push({
type: 'function',
function: {
name,
description: fn.description || name,
parameters,
},
});
}
return out.length ? out : undefined;
}
function contentWithImages(msg, vision) {
const text = msg.content == null ? '' : String(msg.content);
const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
if (!vision || !attachments.length) return text;
const parts = [];
if (text) parts.push({ type: 'text', text });
for (const att of attachments) {
const file = att && (att.path || att.file);
if (!file) continue;
try {
parts.push(imagePart(file));
} catch (err) {
parts.push({ type: 'text', text: '[image unavailable: ' + redact(err.message) + ']' });
}
}
return parts.length ? parts : text;
}
function mapToolCalls(calls) {
return (calls || []).map((c, i) => {
const name = c.name || (c.function && c.function.name);
const args = c.arguments != null ? c.arguments : c.args != null ? c.args : (c.function && c.function.arguments);
return {
id: c.id || 'call_' + i,
type: 'function',
function: {
name,
arguments: typeof args === 'string' ? args : JSON.stringify(args || {}),
},
};
}).filter((c) => c.function.name);
}
function reasoningEffort(model) {
if (/gpt-oss/i.test(model)) return 'low';
if (/qwen3\.[68]-27b/i.test(model)) return 'none';
return null;
}
function assertChatModel(model) {
if (/compound/i.test(model)) {
throw new Error('groq/compound runs tools on Groq. Pick a chat model so Jarvis tools stay on this computer.');
}
}
function toOpenAiMessages(history, { vision } = {}) {
const out = [];
for (const msg of Array.isArray(history) ? history : []) {
if (!msg || !msg.role) continue;
if (msg.role === 'tool' || msg.role === 'function') {
out.push({
role: 'tool',
tool_call_id: msg.tool_call_id || msg.toolCallId || '',
content: msg.content == null ? '' : typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
...(msg.name ? { name: msg.name } : {}),
});
continue;
}
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
out.push({
role: 'assistant',
content: msg.content ? String(msg.content) : null,
tool_calls: mapToolCalls(msg.tool_calls),
});
continue;
}
if (msg.role === 'system' || msg.role === 'user' || msg.role === 'assistant') {
out.push({ role: msg.role, content: contentWithImages(msg, vision) });
}
}
return out;
}
function parseArgs(raw) {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return raw == null ? {} : { value: raw };
const trimmed = raw.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
return { value: parsed };
} catch (_) {
return { value: trimmed };
}
}
function harvestedCalls(buckets) {
const calls = [];
for (const c of buckets) {
if (!c || !c.name) continue;
calls.push({
id: c.id || '',
name: c.name,
arguments: parseArgs(c.arguments),
});
}
return calls;
}
function applyDelta(delta, state, onEvent) {
if (!delta || typeof delta !== 'object') return;
if (delta.content) {
state.text += delta.content;
if (onEvent) onEvent({ type: 'contentDelta', delta: delta.content });
}
const thinking = delta.reasoning || delta.reasoning_content;
if (thinking) {
state.thinking += thinking;
if (onEvent) onEvent({ type: 'thinkingDelta', delta: thinking });
}
if (!Array.isArray(delta.tool_calls)) return;
for (const part of delta.tool_calls) {
const i = Number.isInteger(part.index) ? part.index : state.toolCalls.length;
if (!state.toolCalls[i]) state.toolCalls[i] = { id: '', name: '', arguments: '' };
const slot = state.toolCalls[i];
if (part.id) slot.id = part.id;
const fn = part.function || {};
if (fn.name) slot.name += fn.name;
if (fn.arguments) slot.arguments += fn.arguments;
}
}
function consumeSse(chunk, carry, onEvent) {
let rest = carry + chunk;
let idx;
while ((idx = rest.indexOf('\n')) >= 0) {
let line = rest.slice(0, idx);
rest = rest.slice(idx + 1);
if (line.endsWith('\r')) line = line.slice(0, -1);
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data) continue;
if (data === '[DONE]') return { rest, done: true };
try {
onEvent(JSON.parse(data));
} catch (_) {}
}
return { rest, done: false };
}
function postChat(body, apiKey, { timeoutMs, abortHolder }) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
let req;
req = httpsRequest({
hostname: GROQ_HOST,
path: GROQ_PATH,
method: 'POST',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'Content-Length': Buffer.byteLength(payload),
},
}, (res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
activeRequests.delete(req);
const raw = Buffer.concat(chunks.map((c) => Buffer.isBuffer(c) ? c : Buffer.from(c))).toString('utf8');
resolve({ status: res.statusCode || 0, raw, headers: res.headers || {} });
});
res.on('error', (err) => {
activeRequests.delete(req);
reject(fail(err.message));
});
});
abortHolder.req = req;
activeRequests.add(req);
req.on('error', (err) => {
activeRequests.delete(req);
reject(fail(err.message));
});
if (timeoutMs > 0 && typeof req.setTimeout === 'function') {
req.setTimeout(timeoutMs, () => {
try { req.destroy(fail('Groq request timed out')); } catch (_) {}
});
}
req.write(payload);
req.end();
});
}
function streamChat(body, apiKey, { onChunk, timeoutMs, abortHolder, watch }) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
let req;
let settled = false;
const finish = (err, value) => {
if (settled) return;
settled = true;
activeRequests.delete(req);
if (err) reject(err);
else resolve(value);
};
req = httpsRequest({
hostname: GROQ_HOST,
path: GROQ_PATH,
method: 'POST',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'Content-Length': Buffer.byteLength(payload),
},
}, (res) => {
if ((res.statusCode || 0) >= 400) {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const raw = Buffer.concat(chunks.map((c) => Buffer.isBuffer(c) ? c : Buffer.from(c))).toString('utf8');
finish(fail('Groq HTTP ' + res.statusCode + ': ' + raw.slice(0, 400)));
});
res.on('error', (err) => finish(fail(err.message)));
return;
}
let carry = '';
res.on('data', (d) => {
if (watch && watch.timedOut()) {
try { req.destroy(); } catch (_) {}
return;
}
if (watch) watch.bump();
const text = Buffer.isBuffer(d) ? d.toString('utf8') : String(d);
const parsed = consumeSse(text, carry, onChunk);
carry = parsed.rest;
if (parsed.done) {
finish(null, true);
try { req.destroy(); } catch (_) {}
}
});
res.on('end', () => finish(null, true));
res.on('error', (err) => finish(fail(err.message)));
});
abortHolder.req = req;
activeRequests.add(req);
req.on('error', (err) => finish(fail(err.message)));
if (timeoutMs > 0 && typeof req.setTimeout === 'function') {
req.setTimeout(timeoutMs, () => {
try { req.destroy(); } catch (_) {}
finish(fail('Groq request timed out'));
});
}
req.write(payload);
req.end();
});
}
function cancel() {
for (const req of activeRequests) {
try { req.destroy(); } catch (_) {}
}
activeRequests.clear();
}
async function complete(opts, onEvent) {
const apiKey = String((opts && opts.apiKey) || '').trim();
const model = String((opts && opts.model) || '').trim();
if (!apiKey) throw new Error('Groq agent inference needs a Groq API key. Set it in Settings or GROQ_API_KEY.');
if (!model) throw new Error('Groq agent inference needs a groqModel.');
assertChatModel(model);
const vision = visionModel(model);
const tools = toOpenAiTools(opts && opts.tools);
const messages = toOpenAiMessages((opts && opts.history) || (opts && opts.messages) || [], { vision });
const effort = reasoningEffort(model);
const body = {
model,
messages,
stream: opts && opts.stream === false ? false : true,
temperature: 0.6,
...(effort ? { reasoning_effort: effort } : {}),
};
if (tools) {
body.tools = tools;
body.tool_choice = 'auto';
if (NO_PARALLEL.test(model)) body.parallel_tool_calls = false;
}
const abortHolder = { req: null };
const abortRun = () => {
try { if (abortHolder.req) abortHolder.req.destroy(); } 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 state = { text: '', thinking: '', toolCalls: [] };
try {
if (body.stream === false) {
const res = await Promise.race([
postChat(body, apiKey, { timeoutMs: (opts && opts.timeoutMs) || 0, abortHolder }),
timedOutGate.then(() => null),
]);
if (!res || watch.timedOut()) {
return { text: '', thinking: '', toolCalls: [], stats: null, requestId: null, stopReason: 'timeout' };
}
if (res.status >= 400) throw fail('Groq HTTP ' + res.status + ': ' + res.raw.slice(0, 400));
let parsed;
try { parsed = JSON.parse(res.raw); } catch (err) { throw fail('Groq returned invalid JSON'); }
const msg = parsed && parsed.choices && parsed.choices[0] && parsed.choices[0].message;
if (msg) {
if (msg.content) state.text = String(msg.content);
if (msg.reasoning || msg.reasoning_content) state.thinking = String(msg.reasoning || msg.reasoning_content);
if (Array.isArray(msg.tool_calls)) {
for (const call of msg.tool_calls) {
state.toolCalls.push({
id: call.id || '',
name: (call.function && call.function.name) || '',
arguments: (call.function && call.function.arguments) || '',
});
}
}
}
} else {
const consume = streamChat(body, apiKey, {
timeoutMs: (opts && opts.timeoutMs) || 0,
abortHolder,
watch,
onChunk: (evt) => {
const choice = evt && evt.choices && evt.choices[0];
if (!choice) return;
applyDelta(choice.delta || {}, state, onEvent);
if (choice.message) applyDelta(choice.message, state, onEvent);
},
});
consume.catch(() => {});
const raced = await Promise.race([consume.then(() => 'ok'), timedOutGate]);
if (raced !== 'ok' && raced !== 'timeout') throw raced;
}
const toolCalls = harvestedCalls(state.toolCalls);
let text = state.text;
let thinking = state.thinking;
if (tools && tools.length) {
const recovered = toolParse.recover({ text, thinking, tools: opts.tools, existing: toolCalls });
if (!toolCalls.length && recovered.calls.length) {
for (const call of recovered.calls) toolCalls.push(call);
}
text = recovered.text;
} else {
text = toolParse.stripToolMarkup(text);
}
return {
text,
thinking,
toolCalls,
stats: null,
requestId: null,
stopReason: watch.timedOut() ? 'timeout' : 'stop',
};
} finally {
watch.clear();
activeRequests.delete(abortHolder.req);
}
}
module.exports = {
GROQ_HOST,
GROQ_PATH,
groqHost,
visionModel,
reasoningEffort,
toOpenAiTools,
toOpenAiMessages,
setHttpsRequest,
complete,
cancel,
redact,
};
+57
View File
@@ -12,6 +12,8 @@ const paths = require('./paths.js');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
const repeatLoop = require('./repeat-loop.js');
const groq = require('./groq.js');
const webProcess = require('./web-process.js');
let sdk = null;
let initError = null;
@@ -19,6 +21,35 @@ let initPromise = null;
let holdCount = 0;
let loaded = emptyLoaded();
const activeRequests = new Map();
let remote = { provider: 'local', apiKey: '', model: 'openai/gpt-oss-20b' };
function configureRemote(opts) {
opts = opts || {};
remote.provider = opts.provider === 'groq' ? 'groq' : 'local';
if (opts.apiKey != null) remote.apiKey = String(opts.apiKey).trim();
if (opts.model) remote.model = String(opts.model).trim();
}
function remoteActive() {
return remote.provider === 'groq';
}
function groqLoaded() {
return {
modelId: 'groq:' + remote.model,
friendlyId: remote.model,
constant: remote.model,
tools: true,
vision: groq.visionModel(remote.model),
device: 'groq',
backend: 'groq',
backendId: null,
deviceName: 'Groq',
vram: null,
ctxSize: 131072,
requestId: null,
};
}
function emptyLoaded() {
return {
@@ -401,7 +432,28 @@ async function unload() {
}
}
function localLoaded() {
return Object.assign({}, loaded);
}
async function complete(opts, onEvent) {
// Groq thinks only about a local Chrome crawl. Every other turn stays here.
if (remoteActive() && opts && opts.webProcess) {
if (!remote.apiKey) {
throw new Error('Groq agent inference needs a Groq API key. Set it in Settings or GROQ_API_KEY.');
}
const rawHistory = (opts && opts.history) || (opts && opts.messages) || [];
const history = prepareVisionHistory(rawHistory).concat([{ role: 'system', content: webProcess.GROQ_WEB_HINT }]);
return groq.complete({
history,
tools: webProcess.browserToolsOnly(opts && opts.tools),
timeoutMs: opts && opts.timeoutMs,
idleMs: opts && opts.idleMs,
stream: opts && opts.stream,
model: remote.model,
apiKey: remote.apiKey,
}, onEvent);
}
const s = await ensureInit();
if (!loaded.modelId && !(opts && opts.modelId)) throw new Error('no model loaded');
const rawHistory = (opts && opts.history) || (opts && opts.messages) || [];
@@ -568,6 +620,7 @@ async function complete(opts, onEvent) {
}
async function cancel() {
groq.cancel();
const id = loaded.requestId;
const run = id && activeRequests.get(id);
try {
@@ -577,6 +630,7 @@ async function cancel() {
}
function getLoaded() {
if (remoteActive()) return groqLoaded();
return Object.assign({}, loaded);
}
@@ -606,7 +660,10 @@ module.exports = {
complete,
cancel,
getLoaded,
localLoaded,
resources,
configureRemote,
remoteActive,
VISION_FOLLOWUP_QUESTION,
prepareVisionHistory,
hold,
+12 -2
View File
@@ -6,6 +6,8 @@
* No Bare imports — unit-testable on Node.
*/
const webProcess = require('./web-process.js');
const FORMAT_REMINDER =
'When you need a tool, emit only this XML (not JSON, not a question, not a spoken plan):\n' +
'<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>\nvalue\n</parameter>\n</function>\n</tool_call>';
@@ -59,6 +61,9 @@ function remapName(name, names) {
if (names.has(raw)) return raw;
const folded = raw.toLowerCase().replace(/[\s-]+/g, '_');
if (names.has(folded)) return folded;
if (webProcess.isRetiredWeb(folded) && names.has('browser') && !names.has(webProcess.canonicalWeb(folded))) {
return 'browser';
}
const alias = ALIASES[folded];
if (alias && (names.size === 0 || names.has(alias))) return alias;
return raw;
@@ -149,8 +154,13 @@ function extractCalls(text, tools) {
let parsed = parseQwenXmlInner(frame);
if (!parsed.length) parsed = parseHermesInner(frame);
for (const call of parsed) {
const name = remapName(call.name, names);
const args = call.arguments && typeof call.arguments === 'object' ? call.arguments : {};
let name = remapName(call.name, names);
let args = call.arguments && typeof call.arguments === 'object' ? call.arguments : {};
if (names.has('browser') && webProcess.isRetiredWeb(call.name) && !names.has(String(call.name || '').trim())) {
const rewritten = webProcess.asBrowserCall(call.name, args);
name = rewritten.name;
args = rewritten.arguments;
}
const key = name + ':' + JSON.stringify(args);
if (seen.has(key)) continue;
seen.add(key);
+110
View File
@@ -0,0 +1,110 @@
/**
* Web browsing stays on the local Chrome window. Groq only thinks about
* browser results. Other tools stay on the local model.
*/
const RETIRED_WEB = new Set([
'web_search',
'google_search',
'fetch_page',
'web_fetch',
'wiki_search',
'hn_search',
'code_search',
]);
const CANON = {
search: 'web_search',
websearch: 'web_search',
google: 'google_search',
googlesearch: 'google_search',
fetch: 'web_fetch',
webfetch: 'web_fetch',
fetchpage: 'fetch_page',
open_url: 'fetch_page',
openurl: 'fetch_page',
};
function fold(name) {
return String(name || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
}
function canonicalWeb(name) {
const folded = fold(name);
return CANON[folded] || folded;
}
function isRetiredWeb(name) {
return RETIRED_WEB.has(canonicalWeb(name));
}
function searchUrl(query, engine) {
const q = encodeURIComponent(String(query || '').trim());
const id = fold(engine) || 'duckduckgo';
if (id === 'google') return 'https://www.google.com/search?q=' + q;
if (id === 'bing') return 'https://www.bing.com/search?q=' + q;
if (id === 'wikipedia' || id === 'wiki') return 'https://en.wikipedia.org/w/index.php?search=' + q;
if (id === 'hn' || id === 'hackernews') return 'https://hn.algolia.com/?q=' + q;
if (id === 'github') return 'https://github.com/search?q=' + q + '&type=repositories';
if (id === 'npm') return 'https://www.npmjs.com/search?q=' + q;
if (id === 'mdn') return 'https://developer.mozilla.org/en-US/search?q=' + q;
if (id === 'stackoverflow') return 'https://stackoverflow.com/search?q=' + q;
if (id === 'arxiv') return 'https://arxiv.org/search/?query=' + q + '&searchtype=all';
return 'https://duckduckgo.com/?q=' + q;
}
function asBrowserCall(name, args) {
const canonical = canonicalWeb(name);
const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
if (!RETIRED_WEB.has(canonical)) return { name, arguments: input };
if (canonical === 'web_fetch' || canonical === 'fetch_page') {
return { name: 'browser', arguments: { action: 'navigate', url: String(input.url || input.href || '') } };
}
const engine = canonical === 'google_search' ? 'google'
: canonical === 'wiki_search' ? 'wikipedia'
: canonical === 'hn_search' ? 'hn'
: canonical === 'code_search' ? (input.engine || 'github')
: (input.engine || 'duckduckgo');
return { name: 'browser', arguments: { action: 'navigate', url: searchUrl(input.query || input.q, engine) } };
}
function callName(call) {
if (!call) return '';
return call.name || (call.function && call.function.name) || '';
}
function needsGroqWebProcess(history) {
const list = Array.isArray(history) ? history : [];
for (let i = list.length - 1; i >= 0; i--) {
const msg = list[i];
if (!msg || !msg.role) continue;
if (msg.role === 'user') return false;
if (msg.role === 'tool' || msg.role === 'function') return callName(msg) === 'browser' || msg.name === 'browser';
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
const names = msg.tool_calls.map(callName).filter(Boolean);
return names.length > 0 && names.every((n) => n === 'browser');
}
if (msg.role === 'assistant') return false;
}
return false;
}
function browserToolsOnly(tools) {
return (Array.isArray(tools) ? tools : []).filter((tool) => {
if (!tool) return false;
return tool.name === 'browser' || (tool.function && tool.function.name === 'browser');
});
}
const GROQ_WEB_HINT = 'This turn is web-page processing only. Chrome already crawled on this computer. Think about the latest browser result, then speak the answer or call browser again to click, type, or open the next page. Do not call file, shell, desktop, or camera tools. Those stay on the local model.';
module.exports = {
RETIRED_WEB,
canonicalWeb,
isRetiredWeb,
asBrowserCall,
needsGroqWebProcess,
browserToolsOnly,
GROQ_WEB_HINT,
searchUrl,
};
+6 -6
View File
@@ -49,7 +49,7 @@ function testCatalog() {
[{ name: 'web_search' }, { name: 'browser' }, { name: 'webcam' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b',
);
assert.deepStrictEqual(tiny.map((t) => t.name), ['web_search', 'browser', 'webcam']);
assert.deepStrictEqual(tiny.map((t) => t.name), ['browser', 'webcam']);
assert.strictEqual(
catalog.filterToolsForModel([{ name: 'todo_write' }, { name: 'cu_drag' }], 'qwen3.5-0.8b')[0].name,
'todo_write',
@@ -424,11 +424,11 @@ async function testWebFetchTimeout() {
}
function testGoogleSearchParseAndFallback() {
assert.ok(tools.SCHEMAS.find((t) => t.name === 'google_search'));
assert.ok(tools.SCHEMAS.find((t) => t.name === 'fetch_page'));
assert.ok(tools.SCHEMAS.find((t) => t.name === 'wiki_search'));
assert.ok(tools.SCHEMAS.find((t) => t.name === 'web_search'));
assert.ok(tools.SCHEMAS.find((t) => t.name === 'web_fetch'));
assert.ok(!tools.SCHEMAS.find((t) => t.name === 'google_search'));
assert.ok(!tools.SCHEMAS.find((t) => t.name === 'fetch_page'));
assert.ok(!tools.SCHEMAS.find((t) => t.name === 'wiki_search'));
assert.ok(!tools.SCHEMAS.find((t) => t.name === 'web_search'));
assert.ok(!tools.SCHEMAS.find((t) => t.name === 'web_fetch'));
}
async function testGoogleSearchFallsBackToDuckDuckGo() {