diff --git a/changelog.d/reflexion.added.md b/changelog.d/reflexion.added.md new file mode 100644 index 00000000..d722d60e --- /dev/null +++ b/changelog.d/reflexion.added.md @@ -0,0 +1 @@ +A Bot now learns from a failure that repeats. When a tool call keeps erroring, or a model call fails several times in a row, the Bot writes itself a short note ("the tool \"X\" has failed several times…") into its own memory, so its next conversations avoid the loop. The note goes through the normal memory path — size-capped, secret-scanned, visible in the memory panel and removable with `forget` — and is written at most once per failure per day. Set `reflexion: false` to turn it off. diff --git a/core/src/host/memory.js b/core/src/host/memory.js index d484e057..bb8e92cf 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -29,6 +29,7 @@ import { currentParticipants } from './room-membership.js' import { defineTool } from '@deepseek-ai/dsh-tools' import { splitRef, unquote } from './text.js' import { output } from './tools.js' +import { createReflexionTracker, lessonText } from './reflexion.js' // Fixed sizes, in characters. Both MEMORY.md files ride in every request of every // part, so together they stay near 8,000 characters, a few thousand tokens. A topic is @@ -418,6 +419,7 @@ export function install(rt) { const journalPath = join(root, 'journal.jsonl') const dayFormat = new Intl.DateTimeFormat('en-CA', { ...(config.timeZone ? { timeZone: config.timeZone } : {}), year: 'numeric', month: '2-digit', day: '2-digit' }) const today = () => dayFormat.format(Date.now()) + const reflexion = createReflexionTracker() // Ids are Session ids; anything else becomes a hash, so no id can leave the folder. const safeId = id => (/^[\w-]{1,128}$/.test(id) ? id : createHash('sha256').update(String(id)).digest('hex').slice(0, 32)) @@ -1401,6 +1403,21 @@ export function install(rt) { }, 'ds-bot: memory review') } + // A failure that repeated becomes a lasting note in the Bot's own memory (#TBD). Best + // effort: a full MEMORY.md or a Bot that is gone drops the note, and it never throws into + // the turn that reported the failure. + function reflexionNote(botId, signature) { + if (config.reflexion === false) return + if (!reflexion.hit(botId, signature)) return + const bot = rt.botOf(botId) + const text = lessonText(signature) + if (bot === undefined || text === undefined) return + void addEntry({ + scope: ownScope(botId), text, meta: { lesson: signature, added: today() }, + actor: { id: botId, name: bot.name }, label: 'your memory', + }).catch(() => { /* the Bot is gone or its MEMORY.md is full: the note is dropped */ }) + } + async function memoryEndpoint(endpoint, body) { if (endpoint === 'memory-view') return memoryView(body) if (endpoint === 'memory-ask') return memoryAsk(body) @@ -1411,5 +1428,6 @@ export function install(rt) { Object.assign(rt, { memoryEnabled: true, renewMemory, archiveBotMemory, memoryEndpoint, memoryReviews, memoryNews: () => Object.fromEntries(news), + reflexionNote, }) } diff --git a/core/src/host/reflexion.js b/core/src/host/reflexion.js new file mode 100644 index 00000000..59a21dad --- /dev/null +++ b/core/src/host/reflexion.js @@ -0,0 +1,60 @@ +// Reflexion (#TBD): a Bot learns from a failure that repeats. The feed and turns.js already +// see every failed tool call and every failed model turn; this module decides when such a +// failure has happened often enough to become a lasting note, and words that note. +// +// It is process-local and pure: nothing is persisted here. The note goes through the ordinary +// memory path (src/host/memory.js), so it is size-capped, secret-scanned, visible in the panel +// and removable with forget — the same rules as any other memory entry. +// +// A note is written at most once per signature per cooldown: a Bot stuck retrying a broken +// tool must not fill its memory with the same line. +export const DEFAULT_REFLEXION = Object.freeze({ threshold: 3, windowMs: 10 * 60 * 1000, cooldownMs: 24 * 60 * 60 * 1000, maxBots: 200 }) + +/** + * Tracks failures per Bot and signature. `hit(botId, signature)` records one failure at `now` + * and resolves true when it is the one that crosses `threshold` inside `windowMs` and no note + * for that signature was produced within `cooldownMs`. + */ +export function createReflexionTracker({ now = Date.now, ...options } = {}) { + const { threshold, windowMs, cooldownMs, maxBots } = { ...DEFAULT_REFLEXION, ...options } + /** @type {Map>} */ + const failures = new Map() + /** @type {Map} */ + const noted = new Map() + return { + hit(botId, signature) { + if (typeof botId !== 'string' || botId === '' || typeof signature !== 'string' || signature === '') return false + const time = now() + let bySignature = failures.get(botId) + if (bySignature === undefined) { + failures.set(botId, bySignature = new Map()) + if (failures.size > maxBots) failures.delete(failures.keys().next().value) + } + const recent = (bySignature.get(signature) ?? []).filter(at => time - at < windowMs) + recent.push(time) + if (recent.length < threshold) { bySignature.set(signature, recent); return false } + const key = `${botId}\u0000${signature}` + if (time - (noted.get(key) ?? -Infinity) < cooldownMs) { bySignature.set(signature, recent); return false } + noted.set(key, time) + bySignature.delete(signature) + return true + }, + } +} + +/** + * The note a signature becomes, or undefined for one it does not know. Kept short and + * actionable: it tells the Bot what to do differently, not what went wrong. + */ +export function lessonText(signature) { + if (signature.startsWith('tool:')) { + const tool = signature.slice('tool:'.length).trim().slice(0, 60) + if (tool === '') return undefined + return `The tool "${tool}" has failed several times in a row. When it fails, stop retrying it in a loop: tell the user what happened and try a different way, or ask.` + } + if (signature.startsWith('model:')) { + const code = signature.slice('model:'.length).trim().slice(0, 40) + return `A model call has failed repeatedly${code ? ` (${code})` : ''}. When that happens, tell the user plainly and wait instead of retrying in a loop.` + } + return undefined +} diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 6f031bcd..1736db83 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -319,9 +319,17 @@ export function install(rt) { case 'tool/ptc-dispatch': endCall(id, entry, event.data?.subCallId) break - case 'tool/result': + case 'tool/result': { + const call = entry.calls?.get(event.data?.callId) + const failed = event.data?.message?.isError === true || event.data?.error !== undefined endCall(id, entry, event.data?.callId) + // A tool that errors repeatedly becomes a Reflexion note for its Bot (reflexion.js). + if (failed) { + const botId = rt.selfOf(id) + if (rt.botOf(botId) !== undefined) rt.reflexionNote?.(botId, `tool:${String(call?.name ?? 'tool').slice(0, 60)}`) + } break + } case 'turn/start': setActivity(id, 'thinking') entry.turn = event.data.turn @@ -476,7 +484,12 @@ export function install(rt) { rt.connectorTurnChanged?.(id) const reason = event.data?.reason const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined - if (failure !== undefined) noteTurnFailure(id, failure) + if (failure !== undefined) { + noteTurnFailure(id, failure) + // A model call that fails repeatedly becomes a Reflexion note for its Bot (reflexion.js). + const botId = rt.selfOf(id) + if (rt.botOf(botId) !== undefined) rt.reflexionNote?.(botId, `model:${failure.code}`) + } if (switched) break // A model that writes its tool call as text ends the turn as if it were done, // though nothing ran. It gets one more turn on the same trigger, so a waiting diff --git a/core/test/memory.test.js b/core/test/memory.test.js index f4cd8ea8..332b51fa 100644 --- a/core/test/memory.test.js +++ b/core/test/memory.test.js @@ -1152,3 +1152,28 @@ test('a review out of a cross-owner room keeps entries out of the team memory', assert.equal(existsSync(join(home, 'memory', 'team', 'MEMORY.md')), false, 'no team write from another owner\'s room') assert.match(readFileSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'), 'utf8'), /The launch stays on Friday/) }) + +test('a tool that keeps failing leaves a Reflexion note in the Bot\'s own memory', async (t) => { + const { ctx, home, tool, read, stop } = start() + t.after(() => stop()) + await tool('recall', {}, 'b2') // load the team so b2 is a known chat session + const fail = () => { + ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/call', data: { callId: 'c1', name: 'bash', arguments: '{}' } }) + ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/result', data: { callId: 'c1', message: { isError: true } } }) + } + fail(); fail(); fail() + const path = join(home, 'memory', 'bots', 'b2', 'MEMORY.md') + for (let i = 0; i < 200; i++) { + await settle() + if (existsSync(path) && read('bots', 'b2', 'MEMORY.md').includes('bash')) break + } + assert.match(read('bots', 'b2', 'MEMORY.md'), /The tool "bash" has failed several times in a row\./) + // A successful call first (no accumulated failures) writes nothing. + const cleared = start() + t.after(() => cleared.stop()) + await cleared.tool('recall', {}, 'b2') + cleared.ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/call', data: { callId: 'c9', name: 'webfetch', arguments: '{}' } }) + cleared.ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/result', data: { callId: 'c9' } }) + await settle() + assert.equal(existsSync(join(cleared.home, 'memory', 'bots', 'b2', 'MEMORY.md')), false, 'a successful call notes nothing') +}) diff --git a/core/test/reflexion.test.js b/core/test/reflexion.test.js new file mode 100644 index 00000000..f1f9b47f --- /dev/null +++ b/core/test/reflexion.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createReflexionTracker, lessonText } from '../src/host/reflexion.js' + +test('the tracker notes a signature at the threshold, then stays quiet for the cooldown', () => { + let now = 0 + const tracker = createReflexionTracker({ now: () => now, threshold: 3, windowMs: 1_000, cooldownMs: 5_000 }) + const hit = () => tracker.hit('b', 'tool:bash') + assert.deepEqual([hit(), hit(), hit()], [false, false, true]) + assert.equal(hit(), false, 'a note is not repeated inside the cooldown') + now = 6_000 + assert.deepEqual([hit(), hit(), hit()], [false, false, true], 'a fresh burst after the cooldown notes again') +}) + +test('failures spread beyond the window do not accumulate', () => { + let now = 0 + const tracker = createReflexionTracker({ now: () => now, threshold: 2, windowMs: 100, cooldownMs: 10_000 }) + assert.equal(tracker.hit('b', 'tool:y'), false) + now = 200 + assert.equal(tracker.hit('b', 'tool:y'), false, 'the first failure fell out of the window') + now = 250 + assert.equal(tracker.hit('b', 'tool:y'), true) +}) + +test('empty ids or signatures are ignored, and Bots are tracked apart', () => { + const tracker = createReflexionTracker({ threshold: 2 }) + assert.equal(tracker.hit('', 'tool:x'), false) + assert.equal(tracker.hit('b', ''), false) + assert.equal(tracker.hit('b', 'tool:x'), false) + assert.equal(tracker.hit('b', 'tool:x'), true) + assert.equal(tracker.hit('c', 'tool:x'), false, 'another Bot starts its own count') +}) + +test('lessonText words a known signature and ignores an unknown one', () => { + assert.match(lessonText('tool:bash'), /The tool "bash" has failed several times/) + assert.match(lessonText('model:RATE_LIMIT'), /A model call has failed repeatedly \(RATE_LIMIT\)/) + assert.equal(lessonText('tool:'), undefined) + assert.equal(lessonText('something:x'), undefined) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 475fd402..f94298ab 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -67,6 +67,11 @@ external backend or task-specific session mapping; see the - Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. +### Reflexion: learning from repeated failures + +- `src/host/reflexion.js` turns a failure that repeats into a lasting note: `turns.js` reports every failed tool call (`tool/result` with `isError`/`error`, by tool name) and every failed model turn (by error code), and `createReflexionTracker` notes a signature once it has crossed the threshold inside the window and no note was written for it within the cooldown. `lessonText` words the note. +- The note is written through the ordinary memory path into the failing Bot's own `MEMORY.md` (meta `lesson: `), so it is size-capped, secret-scanned, visible in the memory panel and removable with `forget`. The write is best effort: a full `MEMORY.md` or a Bot that is gone drops it, and it never throws into the turn. `config.reflexion: false` turns it off. Tests: `core/test/reflexion.test.js` (pure) and the tool-failure case in `core/test/memory.test.js`. + ### Tasks The requester may also cancel a `result-ready` task before acceptance (#202). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 857328e3..9d043715 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2211,7 +2211,7 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js -@@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn +@@ -25,9 +25,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' @@ -2219,6 +2219,10 @@ diff --git a/src/host/memory.js b/src/host/memory.js import { defineTool } from '@deepseek-ai/dsh-tools' import { splitRef, unquote } from './text.js' import { output } from './tools.js' ++import { createReflexionTracker, lessonText } from './reflexion.js' + + // Fixed sizes, in characters. Both MEMORY.md files ride in every request of every + // part, so together they stay near 8,000 characters, a few thousand tokens. A topic is @@ -312,7 +312,7 @@ const teamRule = (main, writer) => (main ? '- The team memory is shared by every Bot. As a group admin you write it with remember scope "team", for what the whole team needs.' : '- The team memory is shared by every Bot. Main Bots and group admins write it; when the whole team should know something, tell a Main Bot with message_bot.') @@ -2246,6 +2250,14 @@ diff --git a/src/host/memory.js b/src/host/memory.js 'Reply with one JSON object and nothing else: {"changes": […]}, with an empty list when nothing should change.', `At most ${REVIEW_CHANGES_MAX} changes in one answer, the most lasting first; the rest can wait for the next review.`, 'Keep, as a new entry:', +@@ -417,6 +417,7 @@ export function install(rt) { + const journalPath = join(root, 'journal.jsonl') + const dayFormat = new Intl.DateTimeFormat('en-CA', { ...(config.timeZone ? { timeZone: config.timeZone } : {}), year: 'numeric', month: '2-digit', day: '2-digit' }) + const today = () => dayFormat.format(Date.now()) ++ const reflexion = createReflexionTracker() + + // Ids are Session ids; anything else becomes a hash, so no id can leave the folder. + const safeId = id => (/^[\w-]{1,128}$/.test(id) ? id : createHash('sha256').update(String(id)).digest('hex').slice(0, 32)) @@ -488,6 +488,22 @@ export function install(rt) { const writesTeam = botId => rt.isMain(botId) || Object.values(state.rooms).some(room => room.admin === botId) @@ -2354,6 +2366,35 @@ diff --git a/src/host/memory.js b/src/host/memory.js if (rt.botOf(bot.id) === undefined) return // An answer at the limit may have left some out, so the same lines are read // again, a few times at most, while each pass still changes something. +@@ -1371,6 +1371,21 @@ export function install(rt) { + }, 'ds-bot: memory review') + } + ++ // A failure that repeated becomes a lasting note in the Bot's own memory (#TBD). Best ++ // effort: a full MEMORY.md or a Bot that is gone drops the note, and it never throws into ++ // the turn that reported the failure. ++ function reflexionNote(botId, signature) { ++ if (config.reflexion === false) return ++ if (!reflexion.hit(botId, signature)) return ++ const bot = rt.botOf(botId) ++ const text = lessonText(signature) ++ if (bot === undefined || text === undefined) return ++ void addEntry({ ++ scope: ownScope(botId), text, meta: { lesson: signature, added: today() }, ++ actor: { id: botId, name: bot.name }, label: 'your memory', ++ }).catch(() => { /* the Bot is gone or its MEMORY.md is full: the note is dropped */ }) ++ } ++ + async function memoryEndpoint(endpoint, body) { + if (endpoint === 'memory-view') return memoryView(body) + if (endpoint === 'memory-ask') return memoryAsk(body) +@@ -1381,5 +1381,6 @@ export function install(rt) { + Object.assign(rt, { + memoryEnabled: true, renewMemory, archiveBotMemory, memoryEndpoint, memoryReviews, + memoryNews: () => Object.fromEntries(news), ++ reflexionNote, + }) + } diff --git a/src/host/models.js b/src/host/models.js --- a/src/host/models.js +++ b/src/host/models.js @@ -2889,6 +2930,25 @@ diff --git a/src/host/turns.js b/src/host/turns.js // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) +@@ -279,9 +279,17 @@ export function install(rt) { + case 'tool/ptc-dispatch': + endCall(id, entry, event.data?.subCallId) + break +- case 'tool/result': ++ case 'tool/result': { ++ const call = entry.calls?.get(event.data?.callId) ++ const failed = event.data?.message?.isError === true || event.data?.error !== undefined + endCall(id, entry, event.data?.callId) ++ // A tool that errors repeatedly becomes a Reflexion note for its Bot (reflexion.js). ++ if (failed) { ++ const botId = rt.selfOf(id) ++ if (rt.botOf(botId) !== undefined) rt.reflexionNote?.(botId, `tool:${String(call?.name ?? 'tool').slice(0, 60)}`) ++ } + break ++ } + case 'turn/start': + setActivity(id, 'thinking') + entry.turn = event.data.turn @@ -293,6 +293,10 @@ export function install(rt) { entry.answers = false entry.opened = false @@ -2967,7 +3027,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js entry.turn = undefined entry.info = undefined entry.texts = [] -@@ -401,6 +401,8 @@ export function install(rt) { +@@ -401,9 +401,16 @@ export function install(rt) { entry.calls = undefined entry.lastText = undefined entry.lastCalled = undefined @@ -2975,7 +3035,16 @@ diff --git a/src/host/turns.js b/src/host/turns.js + rt.connectorTurnChanged?.(id) const reason = event.data?.reason const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined - if (failure !== undefined) noteTurnFailure(id, failure) +- if (failure !== undefined) noteTurnFailure(id, failure) ++ if (failure !== undefined) { ++ noteTurnFailure(id, failure) ++ // A model call that fails repeatedly becomes a Reflexion note for its Bot (reflexion.js). ++ const botId = rt.selfOf(id) ++ if (rt.botOf(botId) !== undefined) rt.reflexionNote?.(botId, `model:${failure.code}`) ++ } + if (switched) break + // A model that writes its tool call as text ends the turn as if it were done, + // though nothing ran. It gets one more turn on the same trigger, so a waiting @@ -411,7 +411,8 @@ export function install(rt) { // the call. const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) @@ -4123,7 +4192,7 @@ diff --git a/test/memory.test.js b/test/memory.test.js const view = (await call('memory-view', { botId: 'b2' })).value assert.equal(view.error, null) assert.equal(view.summary?.text, 'You like tea.') -@@ -1076,3 +1076,70 @@ test('a memory call names the current part of the Bot chat', async (t) => { +@@ -1076,3 +1076,95 @@ test('a memory call names the current part of the Bot chat', async (t) => { assert.equal(answer.ok, true) assert.equal(requests[0].sessionId, 'b2-part2') }) @@ -4194,6 +4263,31 @@ diff --git a/test/memory.test.js b/test/memory.test.js + assert.equal(existsSync(join(home, 'memory', 'team', 'MEMORY.md')), false, 'no team write from another owner\'s room') + assert.match(readFileSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'), 'utf8'), /The launch stays on Friday/) +}) ++ ++test('a tool that keeps failing leaves a Reflexion note in the Bot\'s own memory', async (t) => { ++ const { ctx, home, tool, read, stop } = start() ++ t.after(() => stop()) ++ await tool('recall', {}, 'b2') // load the team so b2 is a known chat session ++ const fail = () => { ++ ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/call', data: { callId: 'c1', name: 'bash', arguments: '{}' } }) ++ ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/result', data: { callId: 'c1', message: { isError: true } } }) ++ } ++ fail(); fail(); fail() ++ const path = join(home, 'memory', 'bots', 'b2', 'MEMORY.md') ++ for (let i = 0; i < 200; i++) { ++ await settle() ++ if (existsSync(path) && read('bots', 'b2', 'MEMORY.md').includes('bash')) break ++ } ++ assert.match(read('bots', 'b2', 'MEMORY.md'), /The tool "bash" has failed several times in a row\./) ++ // A successful call first (no accumulated failures) writes nothing. ++ const cleared = start() ++ t.after(() => cleared.stop()) ++ await cleared.tool('recall', {}, 'b2') ++ cleared.ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/call', data: { callId: 'c9', name: 'webfetch', arguments: '{}' } }) ++ cleared.ctx.emitArgs('session/event', { id: 'b2' }, { type: 'tool/result', data: { callId: 'c9' } }) ++ await settle() ++ assert.equal(existsSync(join(cleared.home, 'memory', 'bots', 'b2', 'MEMORY.md')), false, 'a successful call notes nothing') ++}) diff --git a/test/model-menu.test.js b/test/model-menu.test.js --- a/test/model-menu.test.js +++ b/test/model-menu.test.js diff --git a/patches/ds-bot/files/src/host/reflexion.js b/patches/ds-bot/files/src/host/reflexion.js new file mode 100644 index 00000000..59a21dad --- /dev/null +++ b/patches/ds-bot/files/src/host/reflexion.js @@ -0,0 +1,60 @@ +// Reflexion (#TBD): a Bot learns from a failure that repeats. The feed and turns.js already +// see every failed tool call and every failed model turn; this module decides when such a +// failure has happened often enough to become a lasting note, and words that note. +// +// It is process-local and pure: nothing is persisted here. The note goes through the ordinary +// memory path (src/host/memory.js), so it is size-capped, secret-scanned, visible in the panel +// and removable with forget — the same rules as any other memory entry. +// +// A note is written at most once per signature per cooldown: a Bot stuck retrying a broken +// tool must not fill its memory with the same line. +export const DEFAULT_REFLEXION = Object.freeze({ threshold: 3, windowMs: 10 * 60 * 1000, cooldownMs: 24 * 60 * 60 * 1000, maxBots: 200 }) + +/** + * Tracks failures per Bot and signature. `hit(botId, signature)` records one failure at `now` + * and resolves true when it is the one that crosses `threshold` inside `windowMs` and no note + * for that signature was produced within `cooldownMs`. + */ +export function createReflexionTracker({ now = Date.now, ...options } = {}) { + const { threshold, windowMs, cooldownMs, maxBots } = { ...DEFAULT_REFLEXION, ...options } + /** @type {Map>} */ + const failures = new Map() + /** @type {Map} */ + const noted = new Map() + return { + hit(botId, signature) { + if (typeof botId !== 'string' || botId === '' || typeof signature !== 'string' || signature === '') return false + const time = now() + let bySignature = failures.get(botId) + if (bySignature === undefined) { + failures.set(botId, bySignature = new Map()) + if (failures.size > maxBots) failures.delete(failures.keys().next().value) + } + const recent = (bySignature.get(signature) ?? []).filter(at => time - at < windowMs) + recent.push(time) + if (recent.length < threshold) { bySignature.set(signature, recent); return false } + const key = `${botId}\u0000${signature}` + if (time - (noted.get(key) ?? -Infinity) < cooldownMs) { bySignature.set(signature, recent); return false } + noted.set(key, time) + bySignature.delete(signature) + return true + }, + } +} + +/** + * The note a signature becomes, or undefined for one it does not know. Kept short and + * actionable: it tells the Bot what to do differently, not what went wrong. + */ +export function lessonText(signature) { + if (signature.startsWith('tool:')) { + const tool = signature.slice('tool:'.length).trim().slice(0, 60) + if (tool === '') return undefined + return `The tool "${tool}" has failed several times in a row. When it fails, stop retrying it in a loop: tell the user what happened and try a different way, or ask.` + } + if (signature.startsWith('model:')) { + const code = signature.slice('model:'.length).trim().slice(0, 40) + return `A model call has failed repeatedly${code ? ` (${code})` : ''}. When that happens, tell the user plainly and wait instead of retrying in a loop.` + } + return undefined +} diff --git a/patches/ds-bot/files/test/reflexion.test.js b/patches/ds-bot/files/test/reflexion.test.js new file mode 100644 index 00000000..f1f9b47f --- /dev/null +++ b/patches/ds-bot/files/test/reflexion.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createReflexionTracker, lessonText } from '../src/host/reflexion.js' + +test('the tracker notes a signature at the threshold, then stays quiet for the cooldown', () => { + let now = 0 + const tracker = createReflexionTracker({ now: () => now, threshold: 3, windowMs: 1_000, cooldownMs: 5_000 }) + const hit = () => tracker.hit('b', 'tool:bash') + assert.deepEqual([hit(), hit(), hit()], [false, false, true]) + assert.equal(hit(), false, 'a note is not repeated inside the cooldown') + now = 6_000 + assert.deepEqual([hit(), hit(), hit()], [false, false, true], 'a fresh burst after the cooldown notes again') +}) + +test('failures spread beyond the window do not accumulate', () => { + let now = 0 + const tracker = createReflexionTracker({ now: () => now, threshold: 2, windowMs: 100, cooldownMs: 10_000 }) + assert.equal(tracker.hit('b', 'tool:y'), false) + now = 200 + assert.equal(tracker.hit('b', 'tool:y'), false, 'the first failure fell out of the window') + now = 250 + assert.equal(tracker.hit('b', 'tool:y'), true) +}) + +test('empty ids or signatures are ignored, and Bots are tracked apart', () => { + const tracker = createReflexionTracker({ threshold: 2 }) + assert.equal(tracker.hit('', 'tool:x'), false) + assert.equal(tracker.hit('b', ''), false) + assert.equal(tracker.hit('b', 'tool:x'), false) + assert.equal(tracker.hit('b', 'tool:x'), true) + assert.equal(tracker.hit('c', 'tool:x'), false, 'another Bot starts its own count') +}) + +test('lessonText words a known signature and ignores an unknown one', () => { + assert.match(lessonText('tool:bash'), /The tool "bash" has failed several times/) + assert.match(lessonText('model:RATE_LIMIT'), /A model call has failed repeatedly \(RATE_LIMIT\)/) + assert.equal(lessonText('tool:'), undefined) + assert.equal(lessonText('something:x'), undefined) +})