openvibers/openvibe.chat Commits

Vibe: keep every source chip when a search result is clipped (#260)

OP OpenVibers committed 15 days ago · 5945cd29f1 · 3 files, +32 −9

A six-result web search can be longer than the 4000-character result the model reads. how.cite parsed that clipped JSON, failed, and the answer showed no source chips or How sources. aiTools.runWithResult now returns both the bounded text and the whole result; the model still gets the clipped copy, and the answer keeps every citation for its chips.

Changes

ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex cd5a6ba..7eeb75f 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -329,9 +329,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
for (const c of calls) {
called.add(c.function.name);
if (steps) require('./how').step(steps, c.function.name, c.function.arguments);
-- const result = await aiTools.run(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
-- if (steps) require('./how').cite(steps, c.function.name, result);
-- messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: result });
++ const tool = await aiTools.runWithResult(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, timezone: tz, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
++ if (steps) require('./how').cite(steps, c.function.name, tool.result);
++ messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: tool.text });
}
}
return null;
ddiff --git a/server/rooms/ai-tools.js b/server/rooms/ai-tools.js
iindex 084fb6c..60e9e0a 100644
---- a/server/rooms/ai-tools.js
++++ b/server/rooms/ai-tools.js
@@@ -265,17 +265,32 @@ async function myAgents(_args, ctx) {
const RUNNERS = { search_openvibe: searchOpenvibe, read_page: readPage, web_search: webSearch, current_time: currentTime, roll_dice: rollDice, pick_one: pickOne, household_pantry: householdPantry, queue_task: queueTask, queue_status: queueStatus, remember, forget, set_up_agent: setUpAgent, my_agents: myAgents };
--/** 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 = {}) {
++/**
++ * Runs one tool call and returns both what the model reads (the JSON clipped to RESULT_MAX) and the whole result, so
++ * the answer's sources are kept even when a long web result is clipped for the model (11-01).
++ */
++async function runWithResult(name, raw, ctx = {}) {
const runner = RUNNERS[name];
if (!runner || (name === 'household_pantry' && !(ctx.member && ctx.member.link_service === 'food')) || (name === 'web_search' && !isVibe(ctx.member))
|| ((name === 'queue_task' || name === 'queue_status') && !isWorkspace(ctx.room))
|| ((name === 'remember' || name === 'forget') && !(hasMemory(ctx.member, ctx.room, ctx.memory) && ctx.user))
-- || ((name === 'set_up_agent' || name === 'my_agents') && !(ownChat(ctx.member, ctx.room) && ctx.user))) return clip({ error: `no tool called ${String(name).slice(0, 64)}` });
++ || ((name === 'set_up_agent' || name === 'my_agents') && !(ownChat(ctx.member, ctx.room) && ctx.user))) {
++ const error = { error: `no tool called ${String(name).slice(0, 64)}` };
++ return { text: clip(error), result: error };
++ }
let args = {};
-- if (raw) { try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return clip({ error: 'the arguments were not valid JSON' }); } }
++ if (raw) {
++ try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; }
++ catch { const error = { error: 'the arguments were not valid JSON' }; return { text: clip(error), result: error }; }
++ }
if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
-- try { return clip(await runner(args, ctx)); } catch { return clip({ error: 'the tool failed' }); }
++ try { const result = await runner(args, ctx); return { text: clip(result), result }; }
++ catch { const error = { error: 'the tool failed' }; return { text: clip(error), result: error }; }
++}
++
++/** 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 = {}) {
++ return (await runWithResult(name, raw, ctx)).text;
}
--module.exports = { definitions, run, RESULT_MAX, _runners: RUNNERS };
++module.exports = { definitions, run, runWithResult, RESULT_MAX, _runners: RUNNERS };
ddiff --git a/test/room-ai-tools.test.js b/test/room-ai-tools.test.js
iindex 3ffe659..9fa4b8c 100644
---- a/test/room-ai-tools.test.js
++++ b/test/room-ai-tools.test.js
@@@ -84,6 +84,14 @@ t('the tool list and the runners: bounded, fair, refusing what is not theirs', a
assert.deepStrictEqual(noWeb.results, []);
assert.match(noWeb.note, /Nothing found on the web/);
assert.match(noWeb.note, /2–3 key nouns/);
++ // A long web result is clipped for the model but kept whole for the answer's source chips (11-01).
++ const long = Array.from({ length: 6 }, (_, i) => ({ scope: 'web', kind: 'page', title: `${'Long title '.repeat(20)} ${i}`, snippet: `${'A sentence of the story. '.repeat(20)} ${i}`, url: `https://example.com/story/${i}`, site: 'example.com', provider: 'deepseek', published_at: '2026-10-27T00:00:00Z' }));
++ const big = await aiTools.runWithResult('web_search', '{"query":"a long result"}', { member: { handle: 'vibe' }, subject, webSearchOpts: { fabricClient: { fabric: async () => ({ results: long }) } } });
++ assert.ok(big.text.length <= aiTools.RESULT_MAX, 'the model still gets a bounded result');
++ assert.strictEqual(big.result.citations.length, 6, 'the whole result keeps every citation');
++ const bigAcc = { steps: [], sources: [] };
++ require('../server/rooms/how').cite(bigAcc, 'web_search', big.result);
++ assert.strictEqual(bigAcc.citations.length, 6, 'the chips keep every citation even when the model result was clipped');
// The fabric is down (404 here): the old Actor task still answers, as before the fabric.
require('../server/rooms/web-search')._setTokens({ getToken: async () => 'chat-actor-token' });
const sent = [];