Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/reflexion.added.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -1411,5 +1428,6 @@ export function install(rt) {
Object.assign(rt, {
memoryEnabled: true, renewMemory, archiveBotMemory, memoryEndpoint, memoryReviews,
memoryNews: () => Object.fromEntries(news),
reflexionNote,
})
}
60 changes: 60 additions & 0 deletions core/src/host/reflexion.js
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, number[]>>} */
const failures = new Map()
/** @type {Map<string, number>} */
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
}
17 changes: 15 additions & 2 deletions core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions core/test/memory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
39 changes: 39 additions & 0 deletions core/test/reflexion.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
5 changes: 5 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <signature>`), 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).
Expand Down
Loading
Loading