openvibers/openvibe.chat Commits
Chat: AI members use tools (search OpenVibe, time, dice, picks, a household's pantry) (#107)
Changes
@@@ -308,6 +308,14 @@ only; members see names and handles, managers the set-up); the settings page manmember can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting anaccount keeps the AI members it added (they belong to the room) without its subject on them, and exports them.++**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer++is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may++call `search_openvibe` (OpenVibe.Search's public results, `CHAT_SEARCH_URL`), `current_time`, `roll_dice`,++`pick_one`, and for a member linked to a Food household `household_pantry`; Chat runs them (read-only, bounded, results++at most 4,000 characters) and sends the results back, at most three rounds with four calls each, then a turn that must++answer in text. If a turn fails (a provider without tools), the member answers with `chat.member.reply` as before.++`CHAT_AI_MEMBER_TOOLS=0` turns tools off.++**Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model groupchat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter,Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays).@@@ -194,6 +194,10 @@ module.exports = {// above (CHAT_AI_ENABLED); each room answers at most this many times a UTC day.members: process.env.CHAT_AI_MEMBERS !== '0',memberRepliesPerDay: Math.max(1, int(process.env.CHAT_AI_MEMBER_REPLIES_PER_DAY, 100)),++ // Tools an AI member may use (server/rooms/ai-tools.js) through OpenVibe.AI's chat.member.turn: on unless++ // CHAT_AI_MEMBER_TOOLS=0. Network search goes to OpenVibe.Search's public API.++ memberTools: process.env.CHAT_AI_MEMBER_TOOLS !== '0',++ searchUrl: strip(process.env.CHAT_SEARCH_URL || 'https://search.openvibe.network'),},// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows@@@ -18,6 +18,10 @@* OpenAI, Anthropic or an OpenAI-compatible endpoint, their model, their daily budget). The room sees which* (engine_label). Such a member is outside the room's daily cap (the key's own budget limits it); if the key is* removed, its members go back to OpenVibe's model (dropKey).++ * - Tools (server/rooms/ai-tools.js, CHAT_AI_MEMBER_TOOLS=0 turns them off): the answer is a few turns of AI's++ * chat.member.turn, Chat running the tools the model asks for between them (search OpenVibe, the time, dice, a pick,++ * a linked household's pantry), at most MAX_TOOL_ROUNDS, then a turn that must answer in text. If a turn fails++ * (a provider without tools), the member answers with chat.member.reply as before.* - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).** Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).@@@ -26,6 +30,7 @@ const db = require('../db/database');const config = require('../config');const aiClient = require('../ai/client');const credentials = require('../ai/credentials');++const aiTools = require('./ai-tools');const MAX_PER_ROOM = 3;const TRANSCRIPT = 30;@@@ -33,6 +38,8 @@ const LIMITS = { name: 24, persona: 1000, memory: 2000 };const NAME_RE = /^[\p{L}\p{N}][\p{L}\p{N} ._'-]{0,22}[\p{L}\p{N}]$/u;const PER_PERSON_HOUR = 20;++const MAX_TOOL_ROUNDS = 3;++const MAX_CALLS_PER_ROUND = 4;const inFlight = new Set(); // room ids with an answer being writtenconst personHour = new Map(); // `${room}:${user}` → [timestamps]@@@ -180,6 +187,58 @@ function transcriptOf(rows) {return rows.map((m) => `${(m.display_name || m.username || 'someone').slice(0, 40)}${m.ai_member_id ? ' (AI)' : ''}: ${String(m.message || '').replace(/\s+/g, ' ').slice(0, 400)}`).join('\n').slice(-12000);}++/** The member's instructions for chat.member.turn (the same rules as AI's chat.member.reply template, plus tools). */++function systemPrompt(member, room) {++ return [++ `You are ${member.name}, an AI member of the chat room "${String(room.name || room.slug).slice(0, 120)}" on OpenVibe.Chat. The people in the room added you to their conversation.`,++ member.persona ? `How they want you to be: ${member.persona}` : '',++ '',++ 'How you answer:',++ '- Like a member of the conversation: natural, warm and to the point, in the language the person wrote in. Usually a few sentences; a list or a recipe when that is what they asked for. Plain text, no headings.',++ '- Answer the person who named you. Use the earlier messages, the room\'s notes and the shared context when they help.',++ '- You have tools: search OpenVibe (news, deals, coupons, wiki, rentals, videos), the current time, fair dice and random picks, and for a household its pantry. Use one when it gives a better answer, and say where a fact came from (give the link for a search result).',++ '- Never invent facts about the people, their plans or their things beyond what the messages, notes, context and tools say. When you do not know, say so and ask.',++ '- You can only write in this chat and use these tools. Do not claim to have done anything else (bought, booked, sent, saved, set a reminder).',++ '- No threats, nothing sexual involving minors, and never anyone\'s private details. Swearing in jest is fine if the room does it.',++ ].filter((l, i, a) => l !== '' || a[i - 1] !== '').join('\n');++}++++function userPrompt({ member, context, transcript, askedBy, text }) {++ return [++ context ? `Shared context (${context.source.slice(0, 80)}):\n${context.text.slice(0, 4000)}\n\n` : '',++ member.memory ? `The room's notes for you:\n${member.memory}\n\n` : '',++ `Recent messages, oldest first:\n${transcript}\n\n`,++ `${askedBy} just wrote to you: ${text}\n\n`,++ `Write your reply as ${member.name}. Return only the reply.`,++ ].join('');++}++++/**++ * The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn++ * with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).++ */++async function answerWithTools({ room, member, context, transcript, askedBy, text, opts }) {++ const tools = aiTools.definitions(member);++ const messages = [++ { role: 'system', content: systemPrompt(member, room) },++ { role: 'user', content: userPrompt({ member, context, transcript, askedBy, text }) },++ ];++ for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {++ const last = round === MAX_TOOL_ROUNDS;++ const out = await aiClient.structured('chat.member.turn', { messages, tools, tool_choice: last ? 'none' : 'auto', max_tokens: 600, temperature: 0.7 },++ { ...opts, idempotencyKey: `${opts.idempotencyKey}-t${round}` });++ const msg = out && Array.isArray(out.choices) && out.choices[0] && out.choices[0].message;++ if (!msg) return null;++ const calls = !last && Array.isArray(msg.tool_calls) ? msg.tool_calls.filter((c) => c && c.function && c.function.name).slice(0, MAX_CALLS_PER_ROUND) : [];++ if (!calls.length) return typeof msg.content === 'string' && msg.content.trim() ? msg.content : null;++ messages.push({ role: 'assistant', content: typeof msg.content === 'string' ? msg.content : null, tool_calls: calls });++ for (const c of calls) {++ messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: await aiTools.run(c.function.name, c.function.arguments, { member, contextFor }) });++ }++ }++ return null;++}++/*** After a person's message is stored and broadcast (rooms.post). Never throws: an AI member that cannot answer* stays quiet or says why. `deps` lets tests inject the poster and the broadcast.@@@ -208,14 +267,22 @@ async function afterPost(room, message, user, deps = {}) {broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });const history = await rooms.history(room, { limit: TRANSCRIPT });const context = await contextFor(member);-- const out = await aiClient.structured('chat.member.reply', {-- ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),-- memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',-- transcript: transcriptOf(history) || '(no earlier messages)',-- asked_by: (user.display_name || user.username || 'someone').slice(0, 80), message: String(message.message).slice(0, 2000),-- }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,-- ...(own ? { credential: { subject: member.engine_subject } } : {}) });-- const text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';++ const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,++ ...(own ? { credential: { subject: member.engine_subject } } : {}) };++ const transcript = transcriptOf(history) || '(no earlier messages)';++ const askedBy = (user.display_name || user.username || 'someone').slice(0, 80);++ let text = '';++ if (config.ai.memberTools) {++ try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, 2000), opts }) || '', 2000); } catch { text = ''; }++ }++ if (!text) {++ const out = await aiClient.structured('chat.member.reply', {++ ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),++ memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',++ transcript, asked_by: askedBy, message: String(message.message).slice(0, 2000),++ }, opts);++ text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';++ }if (!text) return await say(own ? 'I could not reach my model just now (its key may be out of today\'s budget, or the provider did not answer). Try again later, or the room\'s owner can switch me to OpenVibe\'s model.' : 'I could not answer just now. Try again in a minute.');if (!own) await countReply(room);return await say(text);nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * The tools a room's AI member can use (the owner's "modular AI conversation… tool calling"), run by Chat between the++ * turns of OpenVibe.AI's `chat.member.turn` (server/rooms/ai-members.js). Each is small, read-only and bounded: an AI++ * member still only writes in its room.++ *++ * search_openvibe OpenVibe.Search's public results for a query (news, deals, coupons, wiki, listings, videos…)++ * current_time the date and time now, in a time zone++ * roll_dice N dice of S sides++ * pick_one one of the options given, at random++ * household_pantry a Food household's pantry and plan (only for a member linked to one)++ *++ * definitions(member) → the OpenAI-style tool list for this member++ * run(name, args, ctx) → the result as a string (JSON, at most RESULT_MAX characters), never throws++ */++const crypto = require('crypto');++const config = require('../config');++++const RESULT_MAX = 4000;++++const fn = (name, description, properties = {}, required = []) => ({++ type: 'function',++ function: { name, description, parameters: { type: 'object', additionalProperties: false, properties, required } },++});++++const BASE = [++ fn('search_openvibe', 'Search the OpenVibe network (news, deals, coupon codes, wiki pages, rentals, videos, forum threads and more) and get the top results with links. Use it for questions about things on OpenVibe or recent news and deals.',++ { query: { type: 'string', description: 'What to search for, a few words' }, limit: { type: 'integer', minimum: 1, maximum: 8 } }, ['query']),++ fn('current_time', 'The current date and time, in a time zone (IANA name such as America/New_York; UTC when not given).',++ { timezone: { type: 'string' } }),++ fn('roll_dice', 'Roll dice fairly, for games and decisions.', { count: { type: 'integer', minimum: 1, maximum: 10 }, sides: { type: 'integer', minimum: 2, maximum: 1000 } }),++ fn('pick_one', 'Pick one of the given options at random, fairly (where to eat, who goes first).', { options: { type: 'array', minItems: 2, maxItems: 30, items: { type: 'string' } } }, ['options']),++];++const PANTRY = fn('household_pantry', 'What is in this household\'s pantry and its meal plan, from OpenVibe.Food, right now.');++++const definitions = (member) => (member && member.link_service === 'food' && member.link_resource ? [...BASE, PANTRY] : BASE);++++const clip = (o) => { const s = typeof o === 'string' ? o : JSON.stringify(o); return s.length > RESULT_MAX ? `${s.slice(0, RESULT_MAX - 1)}…` : s; };++const stripTags = (h) => String(h || '').replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");++const int = (v, min, max, dflt) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : dflt; };++++async function searchOpenvibe(args, { fetchImpl = globalThis.fetch } = {}) {++ const query = String(args.query || '').trim().slice(0, 200);++ if (!query) return { error: 'query is required' };++ const limit = int(args.limit, 1, 8, 5);++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), 8000);++ try {++ const res = await fetchImpl(`${config.ai.searchUrl}/api/v1/search?${new URLSearchParams({ q: query, limit: String(limit) })}`, { headers: { accept: 'application/json', 'user-agent': 'OpenVibe.Chat AI member' }, signal: ctrl.signal, redirect: 'error' });++ if (!res.ok) return { error: `OpenVibe.Search answered ${res.status}` };++ const body = await res.json();++ const results = (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((r) => ({++ title: String(r.title || '').slice(0, 200), site: r.owner, type: r.type,++ summary: stripTags(r.snippet_html || r.summary || '').slice(0, 300), url: r.canonical_url, published_at: r.published_at || null,++ }));++ return { query, results, note: results.length ? 'Cite the url when you use a result.' : 'Nothing on OpenVibe matched.' };++ } catch (err) {++ return { error: err && err.name === 'AbortError' ? 'OpenVibe.Search did not answer in time' : 'OpenVibe.Search is unreachable' };++ } finally { clearTimeout(timer); }++}++++function currentTime(args, { now = Date.now() } = {}) {++ let tz = String(args.timezone || 'UTC').trim() || 'UTC';++ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); } catch { tz = 'UTC'; }++ const d = new Date(now);++ return { timezone: tz, iso_utc: d.toISOString(), local: d.toLocaleString('en-US', { timeZone: tz, weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }) };++}++++function rollDice(args) {++ const count = int(args.count, 1, 10, 1); const sides = int(args.sides, 2, 1000, 6);++ const rolls = Array.from({ length: count }, () => crypto.randomInt(1, sides + 1));++ return { dice: `${count}d${sides}`, rolls, total: rolls.reduce((a, b) => a + b, 0) };++}++++function pickOne(args) {++ const options = (Array.isArray(args.options) ? args.options : []).map((o) => String(o).slice(0, 200)).filter(Boolean).slice(0, 30);++ if (options.length < 2) return { error: 'give at least two options' };++ return { options, picked: options[crypto.randomInt(0, options.length)] };++}++++async function householdPantry(_args, { member, contextFor }) {++ if (!member || member.link_service !== 'food') return { error: 'this AI member is not linked to a household' };++ const c = contextFor ? await contextFor(member) : null;++ return c ? { source: c.source, pantry_and_plan: c.text } : { error: 'OpenVibe.Food did not answer just now' };++}++++const RUNNERS = { search_openvibe: searchOpenvibe, current_time: currentTime, roll_dice: rollDice, pick_one: pickOne, household_pantry: householdPantry };++++/** Runs one tool call. `raw` is the model's JSON argument string. Unknown tools and bad JSON come back as errors. */++async function run(name, raw, ctx = {}) {++ const runner = RUNNERS[name];++ if (!runner || (name === 'household_pantry' && !(ctx.member && ctx.member.link_service === 'food'))) return clip({ error: `no tool called ${String(name).slice(0, 64)}` });++ let args = {};++ if (raw) { try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return clip({ error: 'the arguments were not valid JSON' }); } }++ if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};++ try { return clip(await runner(args, ctx)); } catch { return clip({ error: 'the tool failed' }); }++}++++module.exports = { definitions, run, RESULT_MAX, _runners: RUNNERS };@@@ -53,6 +53,7 @@ t('boot', async () => {aiClient = require('../server/ai/client');aiMembers = require('../server/rooms/ai-members');config = require('../server/config');++ config.ai.memberTools = false; // tools are test/room-ai-tools.test.js'; here one run is one replyann = h.addUser('ann', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++/**++ * Tools for a room's AI members (server/rooms/ai-tools.js, the turn loop in server/rooms/ai-members.js): the member's++ * answer is OpenVibe.AI's chat.member.turn with the tool list; Chat runs the calls the model makes (search OpenVibe,++ * time, dice, a pick, a linked household's pantry) and sends the results back; after three rounds it must answer in++ * text; a failing turn falls back to chat.member.reply; tools are bounded and refuse what is not theirs.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++let aiClient, aiTools, config;++++const t = suite('room AI members: tools');++let h, ann;++const api = (method, path, u, body) => h.http(method, `/api/chat${path}`, { token: u && u.token, body });++const settle = (ms = 200) => new Promise((r) => setTimeout(r, ms));++const calls = [];++let script = []; // what the fake AI answers, turn by turn: (input) => output++const toolCall = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } });++const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });++const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: tc }, finish_reason: 'tool_calls' }] });++++t('boot', async () => {++ h = await boot();++ aiClient = require('../server/ai/client');++ aiTools = require('../server/rooms/ai-tools');++ config = require('../server/config');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(ann.id));++ aiClient._setClient({++ runs: {++ create: async (workflow, input, o) => {++ calls.push({ workflow, input: JSON.parse(JSON.stringify(input)), o });++ if (workflow === 'chat.member.reply') return { run: { status: 'succeeded', output: { text: 'The plain reply.' } } };++ const next = script.shift();++ if (!next) return { run: { status: 'failed', error: { code: 'provider.error' } } };++ return { run: { status: 'succeeded', output: next(input) } };++ },++ },++ });++ assert.strictEqual((await api('POST', '/rooms', ann, { name: 'Game Night', visibility: 'private' })).status, 201);++ assert.strictEqual((await api('POST', '/rooms/game-night/ai-members', ann, { name: 'Dealer' })).status, 201);++});++++t('the tool list and the runners: bounded, fair, refusing what is not theirs', async () => {++ const names = aiTools.definitions({}).map((d) => d.function.name);++ assert.deepStrictEqual(names, ['search_openvibe', 'current_time', 'roll_dice', 'pick_one'], 'no pantry for an unlinked member');++ assert.ok(aiTools.definitions({ link_service: 'food', link_resource: 'plan1' }).some((d) => d.function.name === 'household_pantry'));++ const dice = JSON.parse(await aiTools.run('roll_dice', '{"count":50,"sides":6}'));++ assert.strictEqual(dice.rolls.length, 10, 'at most ten dice');++ assert.ok(dice.rolls.every((r) => r >= 1 && r <= 6));++ const pick = JSON.parse(await aiTools.run('pick_one', JSON.stringify({ options: ['tacos', 'pizza'] })));++ assert.ok(['tacos', 'pizza'].includes(pick.picked));++ assert.match(JSON.parse(await aiTools.run('current_time', '{"timezone":"Not/AZone"}')).timezone, /^UTC$/, 'a bad zone is UTC');++ assert.match(await aiTools.run('household_pantry', '{}', { member: {} }), /no tool called/);++ assert.match(await aiTools.run('rm_rf', '{}'), /no tool called/);++ assert.match(await aiTools.run('roll_dice', '{not json'), /not valid JSON/);++ const seen = [];++ const fake = async (url) => { seen.push(String(url)); return new Response(JSON.stringify({ results: [{ owner: 'deals', type: 'offer', title: 'Air mattress for $67', snippet_html: 'An <mark>air</mark> mattress', canonical_url: 'https://openvibe.deals/d/x' }] }), { status: 200, headers: { 'content-type': 'application/json' } }); };++ const found = await aiTools._runners.search_openvibe({ query: 'air mattress', limit: 99 }, { fetchImpl: fake });++ assert.ok(seen[0].startsWith(`${config.ai.searchUrl}/api/v1/search?`) && seen[0].includes('limit=8'));++ assert.deepStrictEqual([found.results[0].site, found.results[0].summary, found.results[0].url], ['deals', 'An air mattress', 'https://openvibe.deals/d/x']);++});++++t('@dealer rolls through a tool: the call runs here, its result goes back, the text answer is posted', async () => {++ const before = calls.length;++ script = [++ (input) => { assert.ok(input.tools.some((x) => x.function.name === 'roll_dice')); return callTools(toolCall('c1', 'roll_dice', { count: 2, sides: 6 })); },++ (input) => {++ const toolMsg = input.messages.find((m) => m.role === 'tool');++ const r = JSON.parse(toolMsg.content);++ assert.strictEqual(toolMsg.tool_call_id, 'c1');++ return say(`You rolled ${r.rolls.join(' and ')}: ${r.total}.`);++ },++ ];++ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer roll two dice' });++ await settle();++ const turns = calls.slice(before);++ assert.deepStrictEqual(turns.map((c) => c.workflow), ['chat.member.turn', 'chat.member.turn']);++ assert.match(turns[0].input.messages[0].content, /You are Dealer/);++ assert.match(turns[0].input.messages[1].content, /Ann just wrote to you: @dealer roll two dice/);++ assert.deepStrictEqual([turns[0].o.idempotencyKey.endsWith('-t0'), turns[1].o.idempotencyKey.endsWith('-t1')], [true, true]);++ const last = (await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop();++ assert.match(last.message, /^You rolled \d and \d: \d+\.$/);++ assert.strictEqual(last.display_name, 'Dealer');++});++++t('after three rounds of tools the fourth turn must answer in text; a failing turn falls back to chat.member.reply', async () => {++ let before = calls.length;++ script = [0, 1, 2].map((i) => () => callTools(toolCall(`r${i}`, 'current_time', {}))).concat([(input) => { assert.strictEqual(input.tool_choice, 'none'); return say('It is late.'); }]);++ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer what time is it, really?' });++ await settle();++ assert.strictEqual(calls.length - before, 4);++ assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop().message, 'It is late.');++++ before = calls.length;++ script = []; // the turn fails (a provider without tools)++ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer hello' });++ await settle();++ assert.deepStrictEqual(calls.slice(before).map((c) => c.workflow), ['chat.member.turn', 'chat.member.reply']);++ assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop().message, 'The plain reply.');++});++++t('CHAT_AI_MEMBER_TOOLS=0: straight to chat.member.reply', async () => {++ config.ai.memberTools = false;++ try {++ const before = calls.length;++ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer and now?' });++ await settle();++ assert.deepStrictEqual(calls.slice(before).map((c) => c.workflow), ['chat.member.reply']);++ } finally { config.ai.memberTools = true; }++});++++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
@@ -308,6 +308,14 @@ only; members see names and handles, managers the set-up); the settings page man
@@ -308,6 +308,14 @@ only; members see names and handles, managers the set-up); the settings page man
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
+**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer
+is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may
+call `search_openvibe` (OpenVibe.Search's public results, `CHAT_SEARCH_URL`), `current_time`, `roll_dice`,
+`pick_one`, and for a member linked to a Food household `household_pantry`; Chat runs them (read-only, bounded, results
+at most 4,000 characters) and sends the results back, at most three rounds with four calls each, then a turn that must
+answer in text. If a turn fails (a provider without tools), the member answers with `chat.member.reply` as before.
+`CHAT_AI_MEMBER_TOOLS=0` turns tools off.
+
**Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model group
**Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model group
chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter,
chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter,
Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays).
Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays).
@@ -194,6 +194,10 @@ module.exports = {
@@ -194,6 +194,10 @@ module.exports = {
// above (CHAT_AI_ENABLED); each room answers at most this many times a UTC day.
// above (CHAT_AI_ENABLED); each room answers at most this many times a UTC day.
members: process.env.CHAT_AI_MEMBERS !== '0',
members: process.env.CHAT_AI_MEMBERS !== '0',
memberRepliesPerDay: Math.max(1, int(process.env.CHAT_AI_MEMBER_REPLIES_PER_DAY, 100)),
memberRepliesPerDay: Math.max(1, int(process.env.CHAT_AI_MEMBER_REPLIES_PER_DAY, 100)),
+ // Tools an AI member may use (server/rooms/ai-tools.js) through OpenVibe.AI's chat.member.turn: on unless
+ // CHAT_AI_MEMBER_TOOLS=0. Network search goes to OpenVibe.Search's public API.
+ memberTools: process.env.CHAT_AI_MEMBER_TOOLS !== '0',
+ searchUrl: strip(process.env.CHAT_SEARCH_URL || 'https://search.openvibe.network'),
},
},
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
@@ -18,6 +18,10 @@
@@ -18,6 +18,10 @@
* OpenAI, Anthropic or an OpenAI-compatible endpoint, their model, their daily budget). The room sees which
* OpenAI, Anthropic or an OpenAI-compatible endpoint, their model, their daily budget). The room sees which
* (engine_label). Such a member is outside the room's daily cap (the key's own budget limits it); if the key is
* (engine_label). Such a member is outside the room's daily cap (the key's own budget limits it); if the key is
* removed, its members go back to OpenVibe's model (dropKey).
* removed, its members go back to OpenVibe's model (dropKey).
+ * - Tools (server/rooms/ai-tools.js, CHAT_AI_MEMBER_TOOLS=0 turns them off): the answer is a few turns of AI's
+ * chat.member.turn, Chat running the tools the model asks for between them (search OpenVibe, the time, dice, a pick,
+ * a linked household's pantry), at most MAX_TOOL_ROUNDS, then a turn that must answer in text. If a turn fails
+ * (a provider without tools), the member answers with chat.member.reply as before.
* - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).
* - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).
*
*
* Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).
* Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).
@@ -26,6 +30,7 @@ const db = require('../db/database');
@@ -26,6 +30,7 @@ const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const aiClient = require('../ai/client');
const aiClient = require('../ai/client');
const credentials = require('../ai/credentials');
const credentials = require('../ai/credentials');
+const aiTools = require('./ai-tools');
const MAX_PER_ROOM = 3;
const MAX_PER_ROOM = 3;
const TRANSCRIPT = 30;
const TRANSCRIPT = 30;
@@ -33,6 +38,8 @@ const LIMITS = { name: 24, persona: 1000, memory: 2000 };
@@ -33,6 +38,8 @@ const LIMITS = { name: 24, persona: 1000, memory: 2000 };
const NAME_RE = /^[\p{L}\p{N}][\p{L}\p{N} ._'-]{0,22}[\p{L}\p{N}]$/u;
const NAME_RE = /^[\p{L}\p{N}][\p{L}\p{N} ._'-]{0,22}[\p{L}\p{N}]$/u;
const PER_PERSON_HOUR = 20;
const PER_PERSON_HOUR = 20;
+const MAX_TOOL_ROUNDS = 3;
+const MAX_CALLS_PER_ROUND = 4;
const inFlight = new Set(); // room ids with an answer being written
const inFlight = new Set(); // room ids with an answer being written
const personHour = new Map(); // `${room}:${user}` → [timestamps]
const personHour = new Map(); // `${room}:${user}` → [timestamps]
@@ -180,6 +187,58 @@ function transcriptOf(rows) {
@@ -180,6 +187,58 @@ function transcriptOf(rows) {
return rows.map((m) => `${(m.display_name || m.username || 'someone').slice(0, 40)}${m.ai_member_id ? ' (AI)' : ''}: ${String(m.message || '').replace(/\s+/g, ' ').slice(0, 400)}`).join('\n').slice(-12000);
return rows.map((m) => `${(m.display_name || m.username || 'someone').slice(0, 40)}${m.ai_member_id ? ' (AI)' : ''}: ${String(m.message || '').replace(/\s+/g, ' ').slice(0, 400)}`).join('\n').slice(-12000);
}
}
+/** The member's instructions for chat.member.turn (the same rules as AI's chat.member.reply template, plus tools). */
+function systemPrompt(member, room) {
+ return [
+ `You are ${member.name}, an AI member of the chat room "${String(room.name || room.slug).slice(0, 120)}" on OpenVibe.Chat. The people in the room added you to their conversation.`,
+ member.persona ? `How they want you to be: ${member.persona}` : '',
+ '',
+ 'How you answer:',
+ '- Like a member of the conversation: natural, warm and to the point, in the language the person wrote in. Usually a few sentences; a list or a recipe when that is what they asked for. Plain text, no headings.',
+ '- Answer the person who named you. Use the earlier messages, the room\'s notes and the shared context when they help.',
+ '- You have tools: search OpenVibe (news, deals, coupons, wiki, rentals, videos), the current time, fair dice and random picks, and for a household its pantry. Use one when it gives a better answer, and say where a fact came from (give the link for a search result).',
+ '- Never invent facts about the people, their plans or their things beyond what the messages, notes, context and tools say. When you do not know, say so and ask.',
+ '- You can only write in this chat and use these tools. Do not claim to have done anything else (bought, booked, sent, saved, set a reminder).',
+ '- No threats, nothing sexual involving minors, and never anyone\'s private details. Swearing in jest is fine if the room does it.',
+ ].filter((l, i, a) => l !== '' || a[i - 1] !== '').join('\n');
+}
+
+function userPrompt({ member, context, transcript, askedBy, text }) {
+ return [
+ context ? `Shared context (${context.source.slice(0, 80)}):\n${context.text.slice(0, 4000)}\n\n` : '',
+ member.memory ? `The room's notes for you:\n${member.memory}\n\n` : '',
+ `Recent messages, oldest first:\n${transcript}\n\n`,
+ `${askedBy} just wrote to you: ${text}\n\n`,
+ `Write your reply as ${member.name}. Return only the reply.`,
+ ].join('');
+}
+
+/**
+ * The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn
+ * with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).
+ */
+async function answerWithTools({ room, member, context, transcript, askedBy, text, opts }) {
+ const tools = aiTools.definitions(member);
+ const messages = [
+ { role: 'system', content: systemPrompt(member, room) },
+ { role: 'user', content: userPrompt({ member, context, transcript, askedBy, text }) },
+ ];
+ for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
+ const last = round === MAX_TOOL_ROUNDS;
+ const out = await aiClient.structured('chat.member.turn', { messages, tools, tool_choice: last ? 'none' : 'auto', max_tokens: 600, temperature: 0.7 },
+ { ...opts, idempotencyKey: `${opts.idempotencyKey}-t${round}` });
+ const msg = out && Array.isArray(out.choices) && out.choices[0] && out.choices[0].message;
+ if (!msg) return null;
+ const calls = !last && Array.isArray(msg.tool_calls) ? msg.tool_calls.filter((c) => c && c.function && c.function.name).slice(0, MAX_CALLS_PER_ROUND) : [];
+ if (!calls.length) return typeof msg.content === 'string' && msg.content.trim() ? msg.content : null;
+ messages.push({ role: 'assistant', content: typeof msg.content === 'string' ? msg.content : null, tool_calls: calls });
+ for (const c of calls) {
+ messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: await aiTools.run(c.function.name, c.function.arguments, { member, contextFor }) });
+ }
+ }
+ return null;
+}
+
/**
/**
* After a person's message is stored and broadcast (rooms.post). Never throws: an AI member that cannot answer
* After a person's message is stored and broadcast (rooms.post). Never throws: an AI member that cannot answer
* stays quiet or says why. `deps` lets tests inject the poster and the broadcast.
* stays quiet or says why. `deps` lets tests inject the poster and the broadcast.
@@ -208,14 +267,22 @@ async function afterPost(room, message, user, deps = {}) {
@@ -208,14 +267,22 @@ async function afterPost(room, message, user, deps = {}) {
broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });
broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });
const history = await rooms.history(room, { limit: TRANSCRIPT });
const history = await rooms.history(room, { limit: TRANSCRIPT });
const context = await contextFor(member);
const context = await contextFor(member);
- const out = await aiClient.structured('chat.member.reply', {
- ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),
- memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',
- transcript: transcriptOf(history) || '(no earlier messages)',
- asked_by: (user.display_name || user.username || 'someone').slice(0, 80), message: String(message.message).slice(0, 2000),
- }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,
- ...(own ? { credential: { subject: member.engine_subject } } : {}) });
- const text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';
+ const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,
+ ...(own ? { credential: { subject: member.engine_subject } } : {}) };
+ const transcript = transcriptOf(history) || '(no earlier messages)';
+ const askedBy = (user.display_name || user.username || 'someone').slice(0, 80);
+ let text = '';
+ if (config.ai.memberTools) {
+ try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, 2000), opts }) || '', 2000); } catch { text = ''; }
+ }
+ if (!text) {
+ const out = await aiClient.structured('chat.member.reply', {
+ ai_name: member.name, persona: member.persona || '', room: String(room.name || room.slug).slice(0, 120),
+ memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',
+ transcript, asked_by: askedBy, message: String(message.message).slice(0, 2000),
+ }, opts);
+ text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';
+ }
if (!text) return await say(own ? 'I could not reach my model just now (its key may be out of today\'s budget, or the provider did not answer). Try again later, or the room\'s owner can switch me to OpenVibe\'s model.' : 'I could not answer just now. Try again in a minute.');
if (!text) return await say(own ? 'I could not reach my model just now (its key may be out of today\'s budget, or the provider did not answer). Try again later, or the room\'s owner can switch me to OpenVibe\'s model.' : 'I could not answer just now. Try again in a minute.');
if (!own) await countReply(room);
if (!own) await countReply(room);
return await say(text);
return await say(text);
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * The tools a room's AI member can use (the owner's "modular AI conversation… tool calling"), run by Chat between the
+ * turns of OpenVibe.AI's `chat.member.turn` (server/rooms/ai-members.js). Each is small, read-only and bounded: an AI
+ * member still only writes in its room.
+ *
+ * search_openvibe OpenVibe.Search's public results for a query (news, deals, coupons, wiki, listings, videos…)
+ * current_time the date and time now, in a time zone
+ * roll_dice N dice of S sides
+ * pick_one one of the options given, at random
+ * household_pantry a Food household's pantry and plan (only for a member linked to one)
+ *
+ * definitions(member) → the OpenAI-style tool list for this member
+ * run(name, args, ctx) → the result as a string (JSON, at most RESULT_MAX characters), never throws
+ */
+const crypto = require('crypto');
+const config = require('../config');
+
+const RESULT_MAX = 4000;
+
+const fn = (name, description, properties = {}, required = []) => ({
+ type: 'function',
+ function: { name, description, parameters: { type: 'object', additionalProperties: false, properties, required } },
+});
+
+const BASE = [
+ fn('search_openvibe', 'Search the OpenVibe network (news, deals, coupon codes, wiki pages, rentals, videos, forum threads and more) and get the top results with links. Use it for questions about things on OpenVibe or recent news and deals.',
+ { query: { type: 'string', description: 'What to search for, a few words' }, limit: { type: 'integer', minimum: 1, maximum: 8 } }, ['query']),
+ fn('current_time', 'The current date and time, in a time zone (IANA name such as America/New_York; UTC when not given).',
+ { timezone: { type: 'string' } }),
+ fn('roll_dice', 'Roll dice fairly, for games and decisions.', { count: { type: 'integer', minimum: 1, maximum: 10 }, sides: { type: 'integer', minimum: 2, maximum: 1000 } }),
+ fn('pick_one', 'Pick one of the given options at random, fairly (where to eat, who goes first).', { options: { type: 'array', minItems: 2, maxItems: 30, items: { type: 'string' } } }, ['options']),
+];
+const PANTRY = fn('household_pantry', 'What is in this household\'s pantry and its meal plan, from OpenVibe.Food, right now.');
+
+const definitions = (member) => (member && member.link_service === 'food' && member.link_resource ? [...BASE, PANTRY] : BASE);
+
+const clip = (o) => { const s = typeof o === 'string' ? o : JSON.stringify(o); return s.length > RESULT_MAX ? `${s.slice(0, RESULT_MAX - 1)}…` : s; };
+const stripTags = (h) => String(h || '').replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
+const int = (v, min, max, dflt) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : dflt; };
+
+async function searchOpenvibe(args, { fetchImpl = globalThis.fetch } = {}) {
+ const query = String(args.query || '').trim().slice(0, 200);
+ if (!query) return { error: 'query is required' };
+ const limit = int(args.limit, 1, 8, 5);
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 8000);
+ try {
+ const res = await fetchImpl(`${config.ai.searchUrl}/api/v1/search?${new URLSearchParams({ q: query, limit: String(limit) })}`, { headers: { accept: 'application/json', 'user-agent': 'OpenVibe.Chat AI member' }, signal: ctrl.signal, redirect: 'error' });
+ if (!res.ok) return { error: `OpenVibe.Search answered ${res.status}` };
+ const body = await res.json();
+ const results = (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((r) => ({
+ title: String(r.title || '').slice(0, 200), site: r.owner, type: r.type,
+ summary: stripTags(r.snippet_html || r.summary || '').slice(0, 300), url: r.canonical_url, published_at: r.published_at || null,
+ }));
+ return { query, results, note: results.length ? 'Cite the url when you use a result.' : 'Nothing on OpenVibe matched.' };
+ } catch (err) {
+ return { error: err && err.name === 'AbortError' ? 'OpenVibe.Search did not answer in time' : 'OpenVibe.Search is unreachable' };
+ } finally { clearTimeout(timer); }
+}
+
+function currentTime(args, { now = Date.now() } = {}) {
+ let tz = String(args.timezone || 'UTC').trim() || 'UTC';
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); } catch { tz = 'UTC'; }
+ const d = new Date(now);
+ return { timezone: tz, iso_utc: d.toISOString(), local: d.toLocaleString('en-US', { timeZone: tz, weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }) };
+}
+
+function rollDice(args) {
+ const count = int(args.count, 1, 10, 1); const sides = int(args.sides, 2, 1000, 6);
+ const rolls = Array.from({ length: count }, () => crypto.randomInt(1, sides + 1));
+ return { dice: `${count}d${sides}`, rolls, total: rolls.reduce((a, b) => a + b, 0) };
+}
+
+function pickOne(args) {
+ const options = (Array.isArray(args.options) ? args.options : []).map((o) => String(o).slice(0, 200)).filter(Boolean).slice(0, 30);
+ if (options.length < 2) return { error: 'give at least two options' };
+ return { options, picked: options[crypto.randomInt(0, options.length)] };
+}
+
+async function householdPantry(_args, { member, contextFor }) {
+ if (!member || member.link_service !== 'food') return { error: 'this AI member is not linked to a household' };
+ const c = contextFor ? await contextFor(member) : null;
+ return c ? { source: c.source, pantry_and_plan: c.text } : { error: 'OpenVibe.Food did not answer just now' };
+}
+
+const RUNNERS = { search_openvibe: searchOpenvibe, current_time: currentTime, roll_dice: rollDice, pick_one: pickOne, household_pantry: householdPantry };
+
+/** Runs one tool call. `raw` is the model's JSON argument string. Unknown tools and bad JSON come back as errors. */
+async function run(name, raw, ctx = {}) {
+ const runner = RUNNERS[name];
+ if (!runner || (name === 'household_pantry' && !(ctx.member && ctx.member.link_service === 'food'))) return clip({ error: `no tool called ${String(name).slice(0, 64)}` });
+ let args = {};
+ if (raw) { try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return clip({ error: 'the arguments were not valid JSON' }); } }
+ if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
+ try { return clip(await runner(args, ctx)); } catch { return clip({ error: 'the tool failed' }); }
+}
+
+module.exports = { definitions, run, RESULT_MAX, _runners: RUNNERS };
@@ -53,6 +53,7 @@ t('boot', async () => {
@@ -53,6 +53,7 @@ t('boot', async () => {
aiClient = require('../server/ai/client');
aiClient = require('../server/ai/client');
aiMembers = require('../server/rooms/ai-members');
aiMembers = require('../server/rooms/ai-members');
config = require('../server/config');
config = require('../server/config');
+ config.ai.memberTools = false; // tools are test/room-ai-tools.test.js'; here one run is one reply
ann = h.addUser('ann', { subject: ids.newId('user') });
ann = h.addUser('ann', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+/**
+ * Tools for a room's AI members (server/rooms/ai-tools.js, the turn loop in server/rooms/ai-members.js): the member's
+ * answer is OpenVibe.AI's chat.member.turn with the tool list; Chat runs the calls the model makes (search OpenVibe,
+ * time, dice, a pick, a linked household's pantry) and sends the results back; after three rounds it must answer in
+ * text; a failing turn falls back to chat.member.reply; tools are bounded and refuse what is not theirs.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+let aiClient, aiTools, config;
+
+const t = suite('room AI members: tools');
+let h, ann;
+const api = (method, path, u, body) => h.http(method, `/api/chat${path}`, { token: u && u.token, body });
+const settle = (ms = 200) => new Promise((r) => setTimeout(r, ms));
+const calls = [];
+let script = []; // what the fake AI answers, turn by turn: (input) => output
+const toolCall = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } });
+const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });
+const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: tc }, finish_reason: 'tool_calls' }] });
+
+t('boot', async () => {
+ h = await boot();
+ aiClient = require('../server/ai/client');
+ aiTools = require('../server/rooms/ai-tools');
+ config = require('../server/config');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(ann.id));
+ aiClient._setClient({
+ runs: {
+ create: async (workflow, input, o) => {
+ calls.push({ workflow, input: JSON.parse(JSON.stringify(input)), o });
+ if (workflow === 'chat.member.reply') return { run: { status: 'succeeded', output: { text: 'The plain reply.' } } };
+ const next = script.shift();
+ if (!next) return { run: { status: 'failed', error: { code: 'provider.error' } } };
+ return { run: { status: 'succeeded', output: next(input) } };
+ },
+ },
+ });
+ assert.strictEqual((await api('POST', '/rooms', ann, { name: 'Game Night', visibility: 'private' })).status, 201);
+ assert.strictEqual((await api('POST', '/rooms/game-night/ai-members', ann, { name: 'Dealer' })).status, 201);
+});
+
+t('the tool list and the runners: bounded, fair, refusing what is not theirs', async () => {
+ const names = aiTools.definitions({}).map((d) => d.function.name);
+ assert.deepStrictEqual(names, ['search_openvibe', 'current_time', 'roll_dice', 'pick_one'], 'no pantry for an unlinked member');
+ assert.ok(aiTools.definitions({ link_service: 'food', link_resource: 'plan1' }).some((d) => d.function.name === 'household_pantry'));
+ const dice = JSON.parse(await aiTools.run('roll_dice', '{"count":50,"sides":6}'));
+ assert.strictEqual(dice.rolls.length, 10, 'at most ten dice');
+ assert.ok(dice.rolls.every((r) => r >= 1 && r <= 6));
+ const pick = JSON.parse(await aiTools.run('pick_one', JSON.stringify({ options: ['tacos', 'pizza'] })));
+ assert.ok(['tacos', 'pizza'].includes(pick.picked));
+ assert.match(JSON.parse(await aiTools.run('current_time', '{"timezone":"Not/AZone"}')).timezone, /^UTC$/, 'a bad zone is UTC');
+ assert.match(await aiTools.run('household_pantry', '{}', { member: {} }), /no tool called/);
+ assert.match(await aiTools.run('rm_rf', '{}'), /no tool called/);
+ assert.match(await aiTools.run('roll_dice', '{not json'), /not valid JSON/);
+ const seen = [];
+ const fake = async (url) => { seen.push(String(url)); return new Response(JSON.stringify({ results: [{ owner: 'deals', type: 'offer', title: 'Air mattress for $67', snippet_html: 'An <mark>air</mark> mattress', canonical_url: 'https://openvibe.deals/d/x' }] }), { status: 200, headers: { 'content-type': 'application/json' } }); };
+ const found = await aiTools._runners.search_openvibe({ query: 'air mattress', limit: 99 }, { fetchImpl: fake });
+ assert.ok(seen[0].startsWith(`${config.ai.searchUrl}/api/v1/search?`) && seen[0].includes('limit=8'));
+ assert.deepStrictEqual([found.results[0].site, found.results[0].summary, found.results[0].url], ['deals', 'An air mattress', 'https://openvibe.deals/d/x']);
+});
+
+t('@dealer rolls through a tool: the call runs here, its result goes back, the text answer is posted', async () => {
+ const before = calls.length;
+ script = [
+ (input) => { assert.ok(input.tools.some((x) => x.function.name === 'roll_dice')); return callTools(toolCall('c1', 'roll_dice', { count: 2, sides: 6 })); },
+ (input) => {
+ const toolMsg = input.messages.find((m) => m.role === 'tool');
+ const r = JSON.parse(toolMsg.content);
+ assert.strictEqual(toolMsg.tool_call_id, 'c1');
+ return say(`You rolled ${r.rolls.join(' and ')}: ${r.total}.`);
+ },
+ ];
+ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer roll two dice' });
+ await settle();
+ const turns = calls.slice(before);
+ assert.deepStrictEqual(turns.map((c) => c.workflow), ['chat.member.turn', 'chat.member.turn']);
+ assert.match(turns[0].input.messages[0].content, /You are Dealer/);
+ assert.match(turns[0].input.messages[1].content, /Ann just wrote to you: @dealer roll two dice/);
+ assert.deepStrictEqual([turns[0].o.idempotencyKey.endsWith('-t0'), turns[1].o.idempotencyKey.endsWith('-t1')], [true, true]);
+ const last = (await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop();
+ assert.match(last.message, /^You rolled \d and \d: \d+\.$/);
+ assert.strictEqual(last.display_name, 'Dealer');
+});
+
+t('after three rounds of tools the fourth turn must answer in text; a failing turn falls back to chat.member.reply', async () => {
+ let before = calls.length;
+ script = [0, 1, 2].map((i) => () => callTools(toolCall(`r${i}`, 'current_time', {}))).concat([(input) => { assert.strictEqual(input.tool_choice, 'none'); return say('It is late.'); }]);
+ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer what time is it, really?' });
+ await settle();
+ assert.strictEqual(calls.length - before, 4);
+ assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop().message, 'It is late.');
+
+ before = calls.length;
+ script = []; // the turn fails (a provider without tools)
+ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer hello' });
+ await settle();
+ assert.deepStrictEqual(calls.slice(before).map((c) => c.workflow), ['chat.member.turn', 'chat.member.reply']);
+ assert.strictEqual((await api('GET', '/rooms/game-night/messages', ann)).body.messages.pop().message, 'The plain reply.');
+});
+
+t('CHAT_AI_MEMBER_TOOLS=0: straight to chat.member.reply', async () => {
+ config.ai.memberTools = false;
+ try {
+ const before = calls.length;
+ await api('POST', '/rooms/game-night/messages', ann, { message: '@dealer and now?' });
+ await settle();
+ assert.deepStrictEqual(calls.slice(before).map((c) => c.workflow), ['chat.member.reply']);
+ } finally { config.ai.memberTools = true; }
+});
+
+t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });