Skip to content
Merged
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/decision-log.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot can now record a decision that mattered — an option it considered and whether it used or rejected it, and why — with `record_decision`. It goes into a local, append-only log (`<home>/decisions.jsonl`, capped) that a reviewer can read to see near-misses and judgment drift, and it is fired on the activity feed as `decision` so the Timeline or an audit view can show it. Off by default (`decisions: true` turns it on); a key, token or password is refused.
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as agents from './src/host/agents.js'
import * as prompt from './src/host/prompt.js'
import * as timeContext from './src/host/time-context.js'
import * as tools from './src/host/tools.js'
import * as decisions from './src/host/decisions.js'
import * as chatIndex from './src/host/chat-index.js'
import * as groups from './src/host/groups.js'
import * as approvals from './src/host/approvals.js'
Expand Down Expand Up @@ -49,7 +50,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel]
const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
82 changes: 82 additions & 0 deletions core/src/host/decisions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// The decisions a Bot records (#396). A Bot often considers a correct or risky option and then
// decides not to use it; those "considered and rejected" choices are invisible to a reviewer
// today. This gives a Bot one small tool to record such a choice, and the host appends it to a
// capped, append-only log. A Bot has no tool to edit or delete a past entry, so what a reviewer
// reads is what was really considered.
//
// Opt-in (config.decisions): with the flag off nothing is registered, written or fired, so a
// default install behaves exactly as before. The log is local text, one JSON line per entry; the
// same entry is fired on the activity feed as `decision` so a consumer (Timeline, an audit view)
// can show it without reading the file.
import { appendFile, mkdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { looksSecret } from './memory.js'
import { output } from './tools.js'

const OPTION_MAX = 200
const WHY_MAX = 300
const CHOICES = new Set(['used', 'rejected'])
const KEPT = 2_000
const NAME_SAFE = /^[\w-]{1,128}$/

const oneLine = (text, max) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max)

export function install(rt) {
if (rt.config?.decisions !== true) {
rt.decisionsEnabled = false
return
}
const { home } = rt
const path = join(home, 'decisions.jsonl')
// One append at a time, and a failure is a warning, never a turn failure (the log is a side
// record, not part of the Bot's work).
let writing = Promise.resolve()
function record(entry) {
const line = `${JSON.stringify(entry)}\n`
writing = writing.then(() => mkdir(dirname(path), { recursive: true }).then(() => appendFile(path, line, 'utf8')))
.catch(error => rt.warn('ds-bot: a decision was not written: %s', error))
return writing
}
// The entries, oldest first, capped to the newest `KEPT` lines. Reads only.
async function list() {
try {
const text = await readFile(path, 'utf8')
return text.split('\n').filter(Boolean).slice(-KEPT).map(line => { try { return JSON.parse(line) } catch { return undefined } }).filter(Boolean)
} catch (error) {
if (error?.code !== 'ENOENT') rt.warn('ds-bot: decisions could not be read: %s', error)
return []
}
}

rt.decisionsEnabled = true
rt.decisionsList = list
rt.ctx.tools.register(defineTool({
name: 'record_decision',
description: 'Record one decision that mattered — an option you considered and whether you used or rejected it, and why. A reviewer reads this log to see near-misses and judgment drift. One line per decision; not for routine steps, and never for secrets.',
parameters: {
option: { type: 'string', required: true, description: `What you considered, at most ${OPTION_MAX} characters` },
choice: { type: 'string', required: true, enum: ['used', 'rejected'], description: 'Whether you used it or rejected it' },
why: { type: 'string', description: `Why, in one short line, at most ${WHY_MAX} characters` },
},
output,
async execute(args, exec) {
await rt.load()
const botId = rt.selfOf(exec?.agent?.id)
const bot = rt.botOf(botId)
if (bot === undefined) return 'Only Bots on the team keep a decision log.'
const option = oneLine(args.option, OPTION_MAX)
const why = oneLine(args.why, WHY_MAX)
const choice = String(args.choice ?? '').trim().toLowerCase()
if (option === '') return 'Write the option you considered.'
if (!CHOICES.has(choice)) return 'choice must be "used" or "rejected".'
if (looksSecret(option) || looksSecret(why)) return 'A decision never holds a key, token or password; record the choice without it.'
const safeName = NAME_SAFE.test(botId) ? botId : 'bot'
const entry = { time: Date.now(), bot: safeName, botName: oneLine(bot.name, 40), option, choice, ...(why ? { why } : {}) }
await record(entry)
// The feed payload drops the id to a safe shape and adds the live botId (a consumer keys by it).
rt.fireFeed?.('decision', { botId, option, choice, ...(why ? { why } : {}), time: entry.time })
return `Recorded: ${choice} "${option}"${why ? ` — ${why}` : ''}.`
},
}))
}
2 changes: 1 addition & 1 deletion core/src/host/flybot-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { SOURCE_KIND } from './constants.js'
import { normRemoteHandle, remoteBotTitle } from '../shared/remote-label.js'

export const FEED_EVENTS = Object.freeze(['turn.start', 'turn.end', 'tool.start', 'tool.end', 'exchange.delivered'])
export const FEED_EVENTS = Object.freeze(['turn.start', 'turn.end', 'tool.start', 'tool.end', 'exchange.delivered', 'decision'])

// Relay roles a consumer can show. Retry, resume and secret-card nudges are the host
// talking to itself, not the team talking to each other.
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export function install(rt) {

// Turns, tool calls and deliveries (flybot-feed.js): metadata folded from session events.
installFeed(rt, fire)
// Host modules of their own (e.g. decisions.js, #396) may fire a feed event on the same channel.
rt.fireFeed = fire

// ---------------------------------------------------------------- bots
// The team: every Bot but friends' Bots, which are chats of their own (remote-chat.js).
Expand Down
61 changes: 61 additions & 0 deletions core/test/decisions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { apply } from '../index.js'
import { fakeContext } from './fake-ctx.js'

const TEAM = {
version: 1, mainBotIds: ['a'],
bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } },
rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,
}

async function boot(config = {}) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM))
const ctx = fakeContext()
Object.assign(ctx, {
llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) },
workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} },
sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) },
sessionTitle: { rename: async () => {} },
sessions: { flush: async () => {} },
})
const rt = apply(ctx, { home, ...config })
await ctx.flybot.ready()
const events = []
try { ctx.flybot.on('decision', payload => events.push(payload)) } catch { /* old core without the event */ }
const tool = (name, args, id = 'a') => ctx.registeredTools.get(name).execute(args, { agent: { id } })
const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }
return { ctx, rt, home, tool, events, stop }
}

test('#396: a Bot records a decision; the host logs it and fires a feed event (opt-in)', async (t) => {
const { home, tool, events, stop } = await boot({ decisions: true })
t.after(stop)
const out = await tool('record_decision', { option: 'Delete the whole repo', choice: 'rejected', why: 'destructive' })
assert.match(out, /Recorded: rejected "Delete the whole repo" — destructive\./)
const log = readFileSync(join(home, 'decisions.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
assert.equal(log.length, 1)
assert.deepEqual([log[0].option, log[0].choice, log[0].bot, log[0].botName], ['Delete the whole repo', 'rejected', 'a', 'Chief'])
assert.deepEqual(events.map(event => [event.option, event.choice, event.why]), [['Delete the whole repo', 'rejected', 'destructive']])
})

test('#396: bad input is refused, and a secret is never logged', async (t) => {
const { home, tool, stop } = await boot({ decisions: true })
t.after(stop)
assert.match(await tool('record_decision', { option: ' ', choice: 'used' }), /Write the option/)
await assert.rejects(tool('record_decision', { option: 'x', choice: 'maybe' }))
const key = `sk-${'abcdefghijklmnopqrstuv'}`
assert.match(await tool('record_decision', { option: `Leak ${key}`, choice: 'used' }), /never holds a key/)
assert.equal(existsSync(join(home, 'decisions.jsonl')), false)
})

test('#396: with the flag off the tool is not registered and nothing is written', async (t) => {
const { ctx, home, stop } = await boot()
t.after(stop)
assert.equal(ctx.registeredTools.get('record_decision'), undefined)
assert.equal(existsSync(join(home, 'decisions.jsonl')), false)
})
17 changes: 17 additions & 0 deletions core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ export interface FlyBotEvents {
'tool.start': ToolStarted
'tool.end': ToolEnded
'exchange.delivered': ExchangeDelivered
/** A decision a Bot recorded through record_decision (#396): one option, used or rejected. */
'decision': DecisionMade
}

/**
* A decision a Bot recorded (#396): an option it considered, whether it used or rejected it, and
* why. Sanitized to one line; fired only when the host enables the decision log (config.decisions).
*/
export interface DecisionMade {
botId: BotId
/** What the Bot considered, one short line. */
option: string
choice: 'used' | 'rejected'
/** Why, one short line, when given. */
why?: string
/** Epoch ms. */
time: number
}
export type On = <K extends keyof FlyBotEvents>(event: K, listener: (payload: FlyBotEvents[K]) => void, options?: { all?: boolean }) => () => void

Expand Down
5 changes: 5 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ external backend or task-specific session mapping; see the

- `memory.js` exposes `rt.botMemoryRead(botId)` (a Bot's own entries, capped at 200) and `rt.botMemoryAppend(botId, text, meta)` (one entry through the ordinary path — secret-scanned, size-capped, journaled). `hooks.js` gates them as `flybot.memory` for a plugin the host names in `flybot.memoryPlugins` (empty by default). The tree uses it to carry a merged Bot's memory into the kept Bot. Tests: the `flybot.memory` case in `core/test/flybot.test.js`.

### Decisions a Bot records (#396)

- `src/host/decisions.js` gives a Bot one small tool, `record_decision({ option, choice, why })` (`choice` is `used` or `rejected`), so a "considered and rejected" choice a reviewer cannot otherwise see is written down. The host appends one JSON line per decision to `<home>/decisions.jsonl` (capped at 2000 lines, one at a time; a write failure is a warning, never a turn failure) and fires the entry on the activity feed as `decision`. A Bot has no tool to edit or delete a past entry.
- **Opt-in** (`config.decisions`): with the flag off the tool is not registered, nothing is written and no event is fired, so a default install is unchanged. A key, token or password in the text is refused (`looksSecret`), so the log needs no secret. Tests: `core/test/decisions.test.js`.

### Tasks

The requester may also cancel a `result-ready` task before acceptance (#202).
Expand Down
Loading
Loading