openvibers/openvibe.chat Commits
Vibe: keep every source chip when a search result is clipped (#260)
Changes
@@@ -329,9 +329,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, texfor (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;@@@ -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 };@@@ -84,6 +84,14 @@ t('the tool list and the runners: bounded, fair, refusing what is not theirs', aassert.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 = [];
@@ -329,9 +329,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
@@ -329,9 +329,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
for (const c of calls) {
for (const c of calls) {
called.add(c.function.name);
called.add(c.function.name);
if (steps) require('./how').step(steps, c.function.name, c.function.arguments);
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;
return null;
@@ -265,17 +265,32 @@ async function myAgents(_args, ctx) {
@@ -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 };
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];
const runner = RUNNERS[name];
if (!runner || (name === 'household_pantry' && !(ctx.member && ctx.member.link_service === 'food')) || (name === 'web_search' && !isVibe(ctx.member))
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 === 'queue_task' || name === 'queue_status') && !isWorkspace(ctx.room))
|| ((name === 'remember' || name === 'forget') && !(hasMemory(ctx.member, ctx.room, ctx.memory) && ctx.user))
|| ((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 = {};
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 = {};
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 };
@@ -84,6 +84,14 @@ t('the tool list and the runners: bounded, fair, refusing what is not theirs', a
@@ -84,6 +84,14 @@ t('the tool list and the runners: bounded, fair, refusing what is not theirs', a
assert.deepStrictEqual(noWeb.results, []);
assert.deepStrictEqual(noWeb.results, []);
assert.match(noWeb.note, /Nothing found on the web/);
assert.match(noWeb.note, /Nothing found on the web/);
assert.match(noWeb.note, /2–3 key nouns/);
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.
// 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' });
require('../server/rooms/web-search')._setTokens({ getToken: async () => 'chat-actor-token' });
const sent = [];
const sent = [];