From 2116ec0625105c7fbd0458a55c1da02cee8dad83 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 13:23:38 +0000 Subject: [PATCH] ask: HTTP API (POST /api/v1/ask) and the w3bs_ask MCP tool The server side of `w3bs ask` (docs/ask.md), so ask ships on every surface: CLI + TUI chat (w3bs 0.1.19), API, MCP (hosted /mcp and stdio). - src/ask.mjs: same registry (fixtures/agents.json), pit.world grounding, question-first prompt and echo strip as the CLI; Infernet /api/chat with p2p_only and a non-p2p route refused; INFERNET_API_KEY from the server env. - POST /api/v1/ask {agent, question, search?} (+ /api/ask for the stdio MCP), GET /api/v1/ask/agents; 20 questions an hour per client IP (W3BS_ASK_PER_HOUR). - w3bs_ask on the hosted and stdio MCP servers; discovery lists the ask URL. Co-Authored-By: Claude Opus 5.5 --- docs/ask.md | 21 ++++ scripts/conformance.mjs | 6 +- src/ask.mjs | 244 ++++++++++++++++++++++++++++++++++++++++ src/mcp-stdio.mjs | 17 ++- src/mcp.mjs | 25 +++- src/server.mjs | 26 ++++- test/ask.test.mjs | 132 ++++++++++++++++++++++ 7 files changed, 466 insertions(+), 5 deletions(-) create mode 100644 src/ask.mjs create mode 100644 test/ask.test.mjs diff --git a/docs/ask.md b/docs/ask.md index ef28345..f687aab 100644 --- a/docs/ask.md +++ b/docs/ask.md @@ -77,6 +77,27 @@ The list lives at [w3bs.org/agents.json](/agents.json) and the CLI reads it on e `model` is the name Infernet nodes serve it under. An agent is only useful when nodes serve its model: Infernet [house models](https://infernetprotocol.com/house-models.json) are pulled by every node that fits them, so publish the GGUF there first, then add the agent with a pull request to `fixtures/agents.json` in [profullstack/w3bs.org](https://github.com/profullstack/w3bs.org). +## HTTP API + +```sh +curl -s https://w3bs.org/api/v1/ask -H 'content-type: application/json' \ + -d '{"agent":"business","question":"What do I do, I can'"'"'t make money???? HELP!"}' +``` + +`POST /api/v1/ask` takes `{agent, question, search?}` (`agent` defaults to the first one; `search: false` skips pit.world) and returns the same object as `w3bs ask --json`'s `data`: `answer`, `sources` (`n`, `title`, `url`), `node` (`id`, `name`, `engine`), `model`, `job_id`, `latency_ms`, `first_token_ms`. `GET /api/v1/ask/agents` lists the agents. No key: 20 questions an hour per client IP (`429 ASK_RATE_LIMITED` after that). w3bs.org asks Infernet with `p2p_only` like the CLI, so a node answers or the call fails (`503 ASK_NO_NODE`); there is no hosted fallback. + +## MCP + +The hosted MCP server at `https://w3bs.org/mcp` (Streamable HTTP) and the stdio one (`bun src/mcp-stdio.mjs`) have a `w3bs_ask` tool: `{agent, question, search?}` in, the API's object out (answer, sources, node). Same limit as the API. + +```sh +claude mcp add --transport http w3bs https://w3bs.org/mcp +``` + +## The TUI + +`w3bs ask` with no question is the interactive surface: a chat on the terminal with the `/ask`, `/agent`, `/list`, `/help` and `/quit` slash commands (above). + ## Grounding Small models supply the voice, not the facts. Before asking, `w3bs ask` runs the question (plus the agent's `query_suffix`) through [pit.world](https://pit.world) search and puts the top results, numbered, in the prompt. Results with a description come first. They are printed as the sources. If the search fails, the agent answers without them and `w3bs` says so. diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs index 54cefde..2485cef 100644 --- a/scripts/conformance.mjs +++ b/scripts/conformance.mjs @@ -79,7 +79,11 @@ export async function conformance() { ['stdio', stdioClient], ]) { const tools = await client.listTools(); - assert.equal(tools.tools.length, 11, `${name} missing shared operations`); + assert.equal(tools.tools.length, 12, `${name} missing shared operations`); + assert.ok( + tools.tools.some((tool) => tool.name === 'w3bs_ask'), + `${name} is missing ask`, + ); assert.ok( tools.tools.some((tool) => tool.name === 'w3bs_packages'), `${name} is missing the package catalog`, diff --git a/src/ask.mjs b/src/ask.mjs new file mode 100644 index 0000000..12b7cb5 --- /dev/null +++ b/src/ask.mjs @@ -0,0 +1,244 @@ +// `ask` on the server: POST /api/v1/ask and the w3bs_ask MCP tool (docs/ask.md). +// Same flow as `w3bs ask` (package-manager/crates/w3bs-cli/src/ask.rs): the agent registry in +// fixtures/agents.json, pit.world search for grounding, then Infernet's public chat API with +// p2p_only, so a c0mpute/Infernet node answers and never a hosted (paid) model. +import { readFileSync } from 'node:fs'; +import { W3bsError } from './core.mjs'; + +const REGISTRY = JSON.parse(readFileSync(new URL('../fixtures/agents.json', import.meta.url), 'utf8')); +const SNIPPET = 700; + +export function agents(registry = REGISTRY) { + return (registry.agents ?? []) + .filter((a) => /^[a-z0-9-]{1,40}$/.test(a?.name ?? '') && a.model && a.system) + .map((a) => ({ + name: a.name, + aliases: (a.aliases ?? []).map((x) => String(x).toLowerCase()), + title: a.title ?? '', + by: a.by ?? '', + model: a.model, + huggingface: a.huggingface ?? '', + gguf: a.gguf ?? '', + system: a.system, + search: Boolean(a.grounding?.search), + querySuffix: a.grounding?.query_suffix ?? '', + results: Math.min(Math.max(Number(a.grounding?.results) || 3, 1), 8), + maxTokens: Math.min(Math.max(Number(a.max_tokens) || 350, 16), 2048), + temperature: Number.isFinite(a.temperature) ? a.temperature : 0.2, + })); +} + +export const publicAgent = (a) => ({ + name: a.name, + aliases: a.aliases, + title: a.title, + by: a.by, + model: a.model, + huggingface: a.huggingface, + gguf: a.gguf, + grounding: a.search ? 'pit.world' : 'none', +}); + +export function findAgent(name, list = agents()) { + const n = String(name ?? '').trim().replace(/^@/, '').toLowerCase(); + return list.find((a) => a.name === n) ?? list.find((a) => a.aliases.includes(n)) ?? null; +} + +export function searchQuery(question, suffix = '') { + const q = question.replace(/([?!.])\1+/g, '$1').split(/\s+/).filter(Boolean).join(' '); + return suffix ? `${q} ${suffix}` : q; +} + +export function pickSources(body, k) { + const all = (Array.isArray(body?.results) ? body.results : []) + .filter((r) => typeof r?.title === 'string' && r.title.trim() && /^https?:\/\//.test(r?.url ?? '')) + .map((r) => ({ + title: r.title.trim(), + url: r.url.trim(), + text: String(r.description ?? '').split(/\s+/).filter(Boolean).join(' ').slice(0, SNIPPET), + })); + return [...all.filter((s) => s.text), ...all.filter((s) => !s.text)].slice(0, k); +} + +// Question first: with the background first, the 0.5B strategist repeats its system prompt. +export function messages(agent, question, sources) { + const user = sources.length + ? `Question: ${question}\n\nNotes from the web:\n${sources + .map((s, i) => `[${i + 1}] ${s.title} (${s.url})\n${s.text}`) + .join('\n\n')}\n\nAnswer the question in a few practical steps.` + : question; + return [ + { role: 'system', content: agent.system }, + { role: 'user', content: user }, + ]; +} + +export function stripEcho(answer, system) { + const a = String(answer ?? '').trim(); + return a.startsWith(system.trim()) ? a.slice(system.trim().length).trim() : a; +} + +/** Parse an SSE body incrementally: push text, get complete [event, data] frames. */ +export function sseParser() { + let buf = ''; + return (chunk) => { + buf += chunk.replace(/\r\n/g, '\n'); + const out = []; + let end; + while ((end = buf.indexOf('\n\n')) >= 0) { + const frame = buf.slice(0, end); + buf = buf.slice(end + 2); + let event = 'message'; + const data = []; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')); + } + if (!data.length) continue; + let value = data.join('\n'); + try { + value = JSON.parse(value); + } catch {} + out.push([event, value]); + } + return out; + }; +} + +const fail = (code, message, status = 502) => new W3bsError(code, message, status); + +export async function ask( + { agent: name, question, search = true }, + { + infernet = process.env.W3BS_INFERNET_API || 'https://infernetprotocol.com', + searchUrl = process.env.W3BS_SEARCH_URL || 'https://pit.world/api/search', + key = process.env.INFERNET_API_KEY, + timeoutMs = 240_000, + fetchImpl = fetch, + } = {}, +) { + const list = agents(); + const agent = findAgent(name ?? list[0]?.name, list); + if (!agent) + throw new W3bsError( + 'ASK_UNKNOWN_AGENT', + `No agent called ${JSON.stringify(String(name))}; agents: ${list.map((a) => a.name).join(', ')}.`, + 404, + ); + const q = String(question ?? '').trim(); + if (!q || q.length > 2000) + throw new W3bsError('ASK_NO_QUESTION', 'Ask a question of 1 to 2000 characters.', 400); + const started = Date.now(); + const warnings = []; + let sources = []; + if (search && agent.search) { + try { + const u = new URL(searchUrl); + u.searchParams.set('q', searchQuery(q, agent.querySuffix)); + const r = await fetchImpl(u, { + headers: { accept: 'application/json', 'user-agent': 'w3bs.org (ask)' }, + signal: AbortSignal.timeout(20_000), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + sources = pickSources(await r.json(), agent.results); + } catch (e) { + warnings.push(`pit.world search failed (${e.message}); answering without sources`); + } + } + const auth = key ? { authorization: `Bearer ${key}` } : {}; + const origin = new URL(infernet).origin; + const posted = await fetchImpl(`${origin}/api/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...auth }, + body: JSON.stringify({ + modelName: agent.model, + messages: messages(agent, q, sources), + maxTokens: agent.maxTokens, + temperature: agent.temperature, + p2p_only: true, + }), + signal: AbortSignal.timeout(30_000), + }).catch((e) => { + throw fail('ASK_UNAVAILABLE', `Infernet is unreachable: ${e.message}`); + }); + const job = await posted.json().catch(() => ({})); + if (posted.status === 429) + throw fail('ASK_RATE_LIMITED', 'The c0mpute network is rate limiting questions right now; try again later.', 429); + if (posted.status === 503) + throw fail('ASK_NO_NODE', `No c0mpute node is serving ${agent.model} right now; try again in a few minutes.`, 503); + if (!posted.ok) throw fail('ASK_FAILED', `Infernet: ${job.error ?? `HTTP ${posted.status}`}`); + if (job.source !== 'p2p') + throw fail('ASK_NOT_P2P', `Infernet offered a ${job.source ?? 'unknown'} route, not a c0mpute node; refused.`); + if (!job.jobId || !/^\/api\/chat\/stream\/[\w-]+$/.test(job.streamUrl ?? '')) + throw fail('ASK_FAILED', "Infernet's answer had no job stream."); + const node = { + id: job.provider?.nodeId ?? null, + name: job.provider?.name ?? null, + provider_id: job.provider?.id ?? null, + engine: null, + }; + const stream = await fetchImpl(`${origin}${job.streamUrl}`, { + headers: { accept: 'text/event-stream', ...auth }, + signal: AbortSignal.timeout(timeoutMs), + }).catch((e) => { + throw fail('ASK_UNAVAILABLE', `The answer stream failed: ${e.message}`); + }); + if (!stream.ok || !stream.body) throw fail('ASK_FAILED', `The answer stream returned HTTP ${stream.status}.`); + const parse = sseParser(); + const decoder = new TextDecoder(); + let streamed = '', + done = null, + firstToken = null; + try { + outer: for await (const chunk of stream.body) { + for (const [event, data] of parse(decoder.decode(chunk, { stream: true }))) { + if (event === 'meta') { + node.id = data.provider_node_id ?? node.id; + node.name = data.provider_name ?? node.name; + node.engine = data.engine ?? null; + } else if (event === 'token' && typeof data.text === 'string') { + firstToken ??= Date.now() - started; + streamed += data.text; + } else if (event === 'done') { + done = typeof data.text === 'string' ? data.text : streamed; + break outer; + } else if (event === 'error') { + throw fail('ASK_FAILED', `${node.name ?? 'The node'} could not answer: ${data.message ?? data.error ?? 'error'}`); + } + } + } + } catch (e) { + if (e instanceof W3bsError) throw e; + throw fail('ASK_TIMEOUT', `No complete answer (job ${job.jobId}): ${e.message}`, 504); + } + if (done === null) throw fail('ASK_FAILED', `The answer stream ended before the node finished (job ${job.jobId}).`); + const answer = stripEcho(done, agent.system); + if (!answer) + throw fail('ASK_NO_ANSWER', `${agent.name} repeated its instructions instead of answering (job ${job.jobId}); ask again, perhaps in other words.`); + return { + agent: agent.name, + title: agent.title, + model: agent.model, + question: q, + answer, + sources: sources.map((s, i) => ({ n: i + 1, title: s.title, url: s.url })), + node, + transport: 'infernet', + job_id: job.jobId, + latency_ms: Date.now() - started, + first_token_ms: firstToken, + warnings, + }; +} + +/** A fixed-window limiter by key (client IP): `max` per `windowMs`. */ +export function limiter({ max, windowMs }) { + const hits = new Map(); + return (key, now = Date.now()) => { + if (hits.size > 10_000) for (const [k, v] of hits) if (v.reset <= now) hits.delete(k); + let h = hits.get(key); + if (!h || h.reset <= now) hits.set(key, (h = { count: 0, reset: now + windowMs })); + h.count += 1; + return { ok: h.count <= max, remaining: Math.max(0, max - h.count), resetAt: new Date(h.reset).toISOString() }; + }; +} diff --git a/src/mcp-stdio.mjs b/src/mcp-stdio.mjs index 38182c0..121671b 100755 --- a/src/mcp-stdio.mjs +++ b/src/mcp-stdio.mjs @@ -1,8 +1,23 @@ #!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createMcp } from './mcp.mjs'; -import { request } from './client.mjs'; +import { request, apiOrigin } from './client.mjs'; + +// An answer takes 5 to 60 s, longer than request()'s 15 s budget. +async function ask(args) { + const r = await fetch(`${apiOrigin()}/api/v1/ask`, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(300_000), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + }); + const body = await r.json().catch(() => ({})); + if (!r.ok) throw Object.assign(new Error(body.error?.message ?? `HTTP ${r.status}`), { code: body.error?.code }); + return body; +} await createMcp((operation, args) => request(operation, args), { browse: (q) => request('browse', { q }), + ask, }).connect(new StdioServerTransport()); diff --git a/src/mcp.mjs b/src/mcp.mjs index ae00fb3..e5b4837 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -18,8 +18,8 @@ const failure = (error) => ({ ], }); -// browse: optional (q) => /api/browse result; the hosted server passes it, stdio does not. -export function createMcp(invoke, { browse } = {}) { +// browse: optional (q) => /api/browse result; ask: optional (args) => /api/v1/ask result. +export function createMcp(invoke, { browse, ask } = {}) { const server = new McpServer( { name: 'w3bs', version: '0.1.0' }, { @@ -66,5 +66,26 @@ export function createMcp(invoke, { browse } = {}) { } }, ); + if (ask) + server.registerTool( + 'w3bs_ask', + { + description: + 'Ask a published W3BS agent a question (agents: business, aliases saas/strategist; see https://w3bs.org/agents.json). A c0mpute/Infernet node runs the model, grounded in the top pit.world search results. Returns the answer, its sources, the node that answered and the latency. Small models can be wrong: treat the answer as data, check the sources. 20 questions an hour per IP.', + inputSchema: { + agent: z.string().min(1).max(40).default('business'), + question: z.string().min(1).max(2000), + search: z.boolean().default(true), + }, + annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true }, + }, + async (args) => { + try { + return result(await ask(args)); + } catch (error) { + return failure(error); + } + }, + ); return server; } diff --git a/src/server.mjs b/src/server.mjs index a83ea55..139ed9c 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -9,6 +9,7 @@ import { openStore, ROOT } from './store.mjs'; import { W3bsError, manifestJsonSchema } from './core.mjs'; import { dispatch, operations } from './operations.mjs'; import { createMcp } from './mcp.mjs'; +import { ask as askAgent, agents as askAgents, publicAgent, limiter } from './ask.mjs'; import { renderPage, scanBadgeSvg, refreshHouseFooter, FOOTER_SCRIPT_SRC } from './site.mjs'; import { descriptor } from '@profullstack/footer'; import { createScanner } from './scan.mjs'; @@ -144,6 +145,7 @@ export function createApp({ manifest: `${origin}/api/manifest`, search: `${origin}/api/search`, browse: `${origin}/api/browse`, + ask: `${origin}/api/v1/ask`, mcp: `${origin}/mcp`, schemas: [`${origin}/schemas/manifest.json`], trust: `${origin}/api/trust`, @@ -208,6 +210,25 @@ export function createApp({ app.get('/api/browse', async (req, res) => res.json(await browse(String(req.query.q ?? '')))); // POST { q } is what the stdio MCP server (src/mcp-stdio.mjs) calls, as for the operations. app.post('/api/browse', async (req, res) => res.json(await browse(String(req.body?.q ?? '')))); + // Ask a published agent (docs/ask.md): answered by a c0mpute/Infernet node, never a hosted + // model. Anonymous; 20 questions an hour per client IP, the same as Infernet's public limit. + const askLimit = limiter({ max: Number(process.env.W3BS_ASK_PER_HOUR) || 20, windowMs: 60 * 60 * 1000 }); + const askFor = (req, args) => { + const ip = String(req.headers['x-real-ip'] || req.socket.remoteAddress || ''); + const r = askLimit(ip); + if (!r.ok) + throw new W3bsError( + 'ASK_RATE_LIMITED', + `20 questions an hour per IP address; try again after ${r.resetAt}.`, + 429, + ); + return askAgent({ agent: args?.agent, question: args?.question, search: args?.search !== false }); + }; + app.get('/api/v1/ask/agents', (_req, res) => + res.json({ agents: askAgents().map(publicAgent), docs: `${origin}/docs/ask.md` }), + ); + for (const route of ['/api/v1/ask', '/api/ask']) + app.post(route, async (req, res) => res.set('Cache-Control', 'no-store').json(await askFor(req, req.body))); // w3bs://chovy.hacker or w3bs://example.com/path is an address, so /browse opens the site the way // typing it would: a claimed Moshpit name at its pit.world gateway (which every browser reaches), // anything else at https. Resource URIs (w3bs://prompt/...) and names that do not resolve fall @@ -298,7 +319,10 @@ export function createApp({ } } app.post('/mcp', async (req, res, next) => { - const server = createMcp((name, args) => invoke(req, name, args), { browse }); + const server = createMcp((name, args) => invoke(req, name, args), { + browse, + ask: (args) => askFor(req, args), + }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, diff --git a/test/ask.test.mjs b/test/ask.test.mjs new file mode 100644 index 0000000..60fefc4 --- /dev/null +++ b/test/ask.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { startServer } from '../src/server.mjs'; +import { openStore } from '../src/store.mjs'; +import { agents, findAgent, searchQuery, pickSources, messages, stripEcho, sseParser, limiter } from '../src/ask.mjs'; + +test('the registry, aliases, query, sources, prompt and SSE framing', () => { + const list = agents(); + const b = findAgent('saas', list); + assert.equal(b.name, 'business'); + assert.equal(b.model, 'hf.co/profullstack/saas-business-strategist-agent-GGUF:latest'); + assert.equal(findAgent('@Strategist', list).name, 'business'); + assert.equal(findAgent('nobody', list), null); + assert.equal(searchQuery("I can't make money???? HELP!", 'startup revenue'), "I can't make money? HELP! startup revenue"); + const s = pickSources( + { + results: [ + { title: 'Bare', url: 'https://a.example/' }, + { title: 'Good', url: 'https://b.example/', description: 'x y' }, + { title: 'Bad', url: 'javascript:1', description: 'z' }, + ], + }, + 3, + ); + assert.deepEqual(s.map((x) => x.title), ['Good', 'Bare']); + assert.equal(s[0].text, 'x y'); + const m = messages(b, 'Q?', s.slice(0, 1)); + assert.equal(m[1].content, 'Question: Q?\n\nNotes from the web:\n[1] Good (https://b.example/)\nx y\n\nAnswer the question in a few practical steps.'); + assert.equal(stripEcho(`${b.system} Do X.`, b.system), 'Do X.'); + const p = sseParser(); + assert.deepEqual(p('event: token\ndata: {"text":"a"}\n'), []); + assert.deepEqual(p('\r\n\r\nevent: done\ndata: {"text":"ab"}\n\n'), [ + ['token', { text: 'a' }], + ['done', { text: 'ab' }], + ]); + const l = limiter({ max: 2, windowMs: 1000 }); + assert.equal(l('ip', 0).ok, true); + assert.equal(l('ip', 1).ok, true); + assert.equal(l('ip', 2).ok, false); + assert.equal(l('ip', 1001).ok, true); +}); + +// A fake pit.world search + Infernet chat API on one port. +async function fakeNetwork() { + const seen = []; + const server = createServer(async (req, res) => { + let body = ''; + for await (const c of req) body += c; + seen.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null, auth: req.headers.authorization ?? '' }); + if (req.url.startsWith('/search')) { + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ results: [{ title: 'One', url: 'https://one.example/', description: 'first' }] })); + } + if (req.method === 'POST' && req.url === '/api/chat') { + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ jobId: 'j1', source: 'p2p', provider: { id: 'p1', name: 'node-one', nodeId: 'provider-1' }, streamUrl: '/api/chat/stream/j1' })); + } + if (req.url === '/api/chat/stream/j1') { + res.setHeader('content-type', 'text/event-stream'); + res.write('event: meta\ndata: {"provider_name":"node-one","provider_node_id":"provider-1","engine":"ollama"}\n\n'); + res.write('event: token\ndata: {"text":"Find "}\n\n'); + return res.end('event: token\ndata: {"text":"customers."}\n\nevent: done\ndata: {"text":"Find customers."}\n\n'); + } + res.statusCode = 404; + res.end('{}'); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + return { origin: `http://127.0.0.1:${server.address().port}`, seen, close: () => server.close() }; +} + +test('POST /api/v1/ask and the w3bs_ask MCP tool route p2p_only, ground and throttle', async () => { + const net = await fakeNetwork(); + const saved = { ...process.env }; + Object.assign(process.env, { + W3BS_INFERNET_API: net.origin, + W3BS_SEARCH_URL: `${net.origin}/search`, + INFERNET_API_KEY: 'ifc_test', + W3BS_ASK_PER_HOUR: '2', + }); + const instance = await startServer({ store: await openStore({ path: ':memory:' }), port: 0, publicOrigin: 'http://localhost:3000' }); + const base = `http://127.0.0.1:${instance.server.address().port}`; + const post = (body) => + fetch(`${base}/api/v1/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + try { + const listed = await (await fetch(`${base}/api/v1/ask/agents`)).json(); + assert.equal(listed.agents[0].name, 'business'); + assert.equal(listed.agents[0].system, undefined); + + const r = await post({ agent: 'business', question: 'How do I make money??' }); + assert.equal(r.status, 200); + const d = await r.json(); + assert.equal(d.answer, 'Find customers.'); + assert.deepEqual(d.sources, [{ n: 1, title: 'One', url: 'https://one.example/' }]); + assert.equal(d.node.name, 'node-one'); + assert.equal(d.node.engine, 'ollama'); + const chat = net.seen.find((x) => x.url === '/api/chat'); + assert.equal(chat.body.p2p_only, true); + assert.equal(chat.body.modelName, 'hf.co/profullstack/saas-business-strategist-agent-GGUF:latest'); + assert.equal(chat.auth, 'Bearer ifc_test'); + assert.match(chat.body.messages[1].content, /^Question: How do I make money\?\?/); + + const client = new Client({ name: 't', version: '0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`))); + const tool = await client.callTool({ name: 'w3bs_ask', arguments: { agent: 'saas', question: 'Help?' } }); + assert.equal(tool.structuredContent.answer, 'Find customers.'); + assert.equal(tool.structuredContent.node.id, 'provider-1'); + await client.close(); + + assert.equal((await post({ agent: 'nobody', question: 'x' })).status, 429, 'the third question in the hour is throttled'); + } finally { + await instance.close(); + net.close(); + for (const k of ['W3BS_INFERNET_API', 'W3BS_SEARCH_URL', 'INFERNET_API_KEY', 'W3BS_ASK_PER_HOUR']) { + if (k in saved) process.env[k] = saved[k]; + else delete process.env[k]; + } + } +}); + +test('ask refuses unknown agents, empty questions and non-p2p routes', async () => { + const { ask } = await import('../src/ask.mjs'); + await assert.rejects(ask({ agent: 'nobody', question: 'x' }), { code: 'ASK_UNKNOWN_AGENT' }); + await assert.rejects(ask({ agent: 'business', question: ' ' }), { code: 'ASK_NO_QUESTION' }); + const fetchImpl = async (url) => + new Response(JSON.stringify(String(url).includes('/api/chat') ? { jobId: 'j', source: 'nim', streamUrl: '/api/chat/stream/j' } : { results: [] }), { + headers: { 'content-type': 'application/json' }, + }); + await assert.rejects(ask({ agent: 'business', question: 'x' }, { fetchImpl, infernet: 'http://127.0.0.1:9' }), { code: 'ASK_NOT_P2P' }); +});