diff --git a/node/api/scripts/test-build-tool-use-messages.js b/node/api/scripts/test-build-tool-use-messages.js index 05ac1791..e3028a6e 100644 --- a/node/api/scripts/test-build-tool-use-messages.js +++ b/node/api/scripts/test-build-tool-use-messages.js @@ -59,6 +59,18 @@ check('msg3 has the gap marker', msgs[3].content.startsWith('--- gap: 3h ---\n[5 const noTime = buildToolUseMessages([{ from_agent: 'salem-engine', message: '# Your turn\nno time' }], NPC); check('no sent_at -> raw content, no prefix', noTime[0].content === '# Your turn\nno time'); +// ZBBS-HOME-436: a REJECTED quote-take replays its "bought" paraphrase with +// the [error] tool result immediately adjacent (paired by tool_call_id in +// stored row order), so the false-success window the wording opens is closed +// in the same breath. This pins the invariant the paraphrase relies on. +const rejected = buildToolUseMessages([ + { from_agent: 'salem-engine', message: '# Your turn', sent_at: agoISO(10) }, + { from_agent: NPC, message: '', tool_calls: [{ id: 't9', name: 'pay_with_item', input: { item: 'Meat', seller: 'John Ellis', quote_id: 1, consume_now: true } }], sent_at: agoISO(9) }, + { from_agent: 'salem-engine', message: '[error: quote expired]', tool_call_id: 't9', sent_at: agoISO(9) }, +], NPC); +check('rejected quote-take: bought paraphrase', rejected[1].content === '(I bought Meat from John Ellis and ate it on the spot)'); +check('rejected quote-take: [error] result directly follows', rejected[2].role === 'tool' && rejected[2].tool_call_id === 't9' && rejected[2].content === '[error: quote expired]'); + if (failed > 0) { console.log('FAILED ' + failed + ' / ' + (passed + failed)); failures.forEach(f => console.log(f)); diff --git a/node/api/scripts/test-paraphrase-tool-call.js b/node/api/scripts/test-paraphrase-tool-call.js index 4fb50d7a..05c3e013 100644 --- a/node/api/scripts/test-paraphrase-tool-call.js +++ b/node/api/scripts/test-paraphrase-tool-call.js @@ -75,6 +75,34 @@ assertEqual( '' ); +// A quote-take (quote_id present) settles instantly engine-side +// (ZBBS-HOME-424 fast path), so the paraphrase states the completed +// purchase — "offered" misread a done deal as pending and primed a re-buy +// (ZBBS-HOME-436, the six-meat morning). +assertEqual( + 'pay_with_item quote_id (number) + consume_now → bought + ate', + paraphraseToolCall('pay_with_item', { qty: 1, item: 'Meat', amount: 4, seller: 'John Ellis', quote_id: 1, consume_now: true }), + '(I bought Meat from John Ellis and ate it on the spot)' +); + +assertEqual( + 'pay_with_item quote_id (Llama-stringified) without consume_now → bought', + paraphraseToolCall('pay_with_item', { item: 'Bread', seller: 'John Ellis', quote_id: '3' }), + '(I bought Bread from John Ellis)' +); + +assertEqual( + 'pay_with_item quote_id zero → still an offer', + paraphraseToolCall('pay_with_item', { item: 'Water', seller: 'Hannah Boggs', quote_id: 0 }), + '(I offered to buy Water from Hannah Boggs)' +); + +assertEqual( + 'pay_with_item quote_id garbage string → still an offer', + paraphraseToolCall('pay_with_item', { item: 'Water', seller: 'Hannah Boggs', quote_id: 'abc' }), + '(I offered to buy Water from Hannah Boggs)' +); + // ---------- move_to (the field-name bug) ---------- assertEqual( diff --git a/node/api/src/services/virtual-agent.js b/node/api/src/services/virtual-agent.js index 0e96056d..04bd2dfe 100644 --- a/node/api/src/services/virtual-agent.js +++ b/node/api/src/services/virtual-agent.js @@ -2128,15 +2128,29 @@ function paraphraseToolCall(name, input) { case 'pay_with_item': { if (typeof inp.item !== 'string' || !inp.item) return ''; const seller = (typeof inp.seller === 'string' && inp.seller) ? ' from ' + inp.seller : ''; - // pay_with_item places an OFFER the seller must accept — the [ok] - // tool result means "offer placed", NOT "purchase completed" (the - // offer can later "fall through" if the seller never fulfils it). - // Paraphrase the attempt, not a success the history may contradict: - // "I bought and consumed cheese" while the NPC stays starving (and a - // later perception says the offer fell through) is exactly the - // incoherent self-history that degrades the model. Llama stringifies - // bools, so consume_now may be true or 'true'. - const eatNow = (inp.consume_now === true || inp.consume_now === 'true') ? ' to eat now' : ''; + // Llama stringifies bools, so consume_now may be true or 'true'. + const eatsNow = (inp.consume_now === true || inp.consume_now === 'true'); + // A quote-take (quote_id present) settles INSTANTLY in the engine + // (ZBBS-HOME-424 fast path): payment, goods, and any consume_now + // meal complete inside the call — "offered" misreads a done deal + // as still pending and primes a re-buy (ZBBS-HOME-436, the + // six-meat morning). A REJECTED quote-take replays with its + // [error] tool result right after this line, which corrects the + // claim in the same breath. Llama stringifies numbers too, so + // quote_id may be 1 or '1'. + const quoteId = Number(inp.quote_id); + if (Number.isFinite(quoteId) && quoteId > 0) { + const ateNow = eatsNow ? ' and ate it on the spot' : ''; + return '(I bought ' + inp.item + seller + ateNow + ')'; + } + // Without a quote this places an OFFER the seller must accept — + // the [ok] tool result means "offer placed", NOT "purchase + // completed" (the offer can later "fall through" if the seller + // never fulfils it). Paraphrase the attempt, not a success the + // history may contradict: "I bought and consumed cheese" while + // the NPC stays starving is exactly the incoherent self-history + // that degrades the model. + const eatNow = eatsNow ? ' to eat now' : ''; return '(I offered to buy ' + inp.item + seller + eatNow + ')'; } case 'consume':