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/645-team-turn-budget.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A team can now cap its model turns per team-local day (`prefs.turnBudget`, 200 for a fresh team, `0` turns the budget off). At or over the cap only a turn the user starts themselves is still dispatched; scheduled reports, tidy passes and rounds a Bot starts are refused before a model call, with one line in the host log ([#645](https://github.com/ThinkFlowLab/FlyBot/issues/645)).
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import * as approvals from './src/host/approvals.js'
import * as resume from './src/host/resume.js'
import * as memory from './src/host/memory.js'
import * as parts from './src/host/parts.js'
import * as turnBudget from './src/host/turn-budget.js'
import * as images from './src/host/images.js'
import * as browser from './src/host/browser.js'
import * as secrets from './src/host/secrets.js'
Expand Down Expand Up @@ -62,7 +63,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, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel]
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
9 changes: 9 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { questionReply } from './parts.js'
import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js'
import { isFaceRoom } from './room-membership.js'
import { readQuietHours } from '../shared/quiet-hours.js'
import { readTurnBudget } from './turn-budget.js'
import { faceMentions } from './face-mentions.js'
import { visibleText } from '../shared/preview.js'

Expand Down Expand Up @@ -300,6 +301,14 @@ export function install(rt) {
if (quiet === undefined) delete state.prefs.quietHours
else state.prefs.quietHours = quiet
}
// Team turn budget (#645, the C half): a whole number of turns per team-local day, `0` off.
// Clearing the field is not "off" — it goes back to the default the store seeded — so an
// invalid value is refused rather than stored as a number nobody meant.
if (body.turnBudget !== undefined) {
const budget = readTurnBudget(body.turnBudget)
if (budget === undefined) delete state.prefs.turnBudget
else state.prefs.turnBudget = budget
}
return rt.save()
}

Expand Down
8 changes: 8 additions & 0 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants
import { homeLock } from './home-lock.js'
import { openJournal } from './journal.js'
import { tempWritable } from './real-dir.js'
import { TEAM_TURNS_PER_DAY } from './turn-budget.js'
import { codePoints, currentColor, normalize, unquote } from './text.js'

// The saved team's format version. Version 2 (#47) adds the identity schema: member
Expand Down Expand Up @@ -257,6 +258,13 @@ export function install(rt) {
// Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset.
if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {}
let migrated = !hadCapabilities
// Team turn budget (#645, the C half; owner decision 2026-11-07): a fresh team starts at the
// default cap, and a team saved before the budget existed stays off (0) until the owner sets a
// number. The file itself is what tells them apart, so one line here is one migration.
if (state.prefs.turnBudget === undefined) {
state.prefs.turnBudget = parsed === null || parsed === undefined ? TEAM_TURNS_PER_DAY : 0
migrated = true
}
if (!isRecord(state.projects)) {
state.projects = {}
migrated = true
Expand Down
87 changes: 87 additions & 0 deletions core/src/host/turn-budget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Team turn budget (#645, the C half; owner decision 2026-11-07). A team spends model turns, and
// a team spends too many of them on work nobody asked for: the count is per team-local day, and at
// or over the cap only a turn the user started themselves is dispatched. A timer's turn, a tidy's,
// a round a Bot started and a kickoff are refused before the model — no request, no line to the
// user, one line in the host log. `0` is off.
//
// Two facts keep the count honest. A turn is spent when its own first step starts
// (`session/event` `step/start`, step 1), so a turn of many steps costs one and a turn the gate
// refused never costs any. The gate is `agent/pre-step`, the seam parts.js already refuses a
// step at: `{ kind: 'reject' }` ends the turn before a model call, and DSH reports it as blocked.
//
// Whose turn it is comes from turns.js's recorded verdict (turn-origin.js): `owner` is the user's
// own message or a line they wrote in a group; everything else — `scheduled`, `other`, or a turn
// whose messages carried no verdict — is system-started and waits for the counter to reset.
import { dayKey } from './project-metrics.js'
import { OWNER } from './turn-origin.js'

// The cap a fresh team starts with, and the day a team counted in before it had a zone of its own.
export const TEAM_TURNS_PER_DAY = 200
export const TEAM_TURN_ZONE = 'Asia/Shanghai'

// The cap the team runs with: a whole number, `0` is off, and an unset or malformed field keeps
// the fresh-team default rather than quietly turning the budget off.
export function turnBudgetOf(prefs) {
const value = prefs?.turnBudget
return Number.isSafeInteger(value) && value >= 0 ? value : TEAM_TURNS_PER_DAY
}

// The settings write (`prefs` on the api): a whole number of turns, `0` for off, `null` to clear
// back to the default. Anything else is refused, never stored as a number nobody meant.
export function readTurnBudget(value) {
if (value === null || value === undefined) return undefined
if (!Number.isSafeInteger(value) || value < 0) throw Object.assign(new Error('Turn budget must be a whole number of turns (0 turns the budget off)'), { code: 'bot/turn-budget' })
return value
}

// What the saved counter says about one day: a record from another day is not a count at all, so
// the day boundary needs no timer — it resets the first time it is read or written.
export function turnsUsed(record, day) {
return record?.day === day && Number.isSafeInteger(record.count) && record.count > 0 ? record.count : 0
}

// The team's day. The clock the host already keeps (`timeZone`) is the team's zone; a host without
// one counts in Asia/Shanghai, and a zone Intl cannot read falls back to it rather than throwing.
export function teamDayKey(rt, at) {
const wanted = typeof rt?.config?.timeZone === 'string' && rt.config.timeZone !== '' ? rt.config.timeZone : TEAM_TURN_ZONE
try {
new Intl.DateTimeFormat('en-CA', { timeZone: wanted })
return dayKey(at, wanted)
} catch {
return dayKey(at, TEAM_TURN_ZONE)
}
}

export function install(rt) {
const { ctx } = rt
const day = () => teamDayKey(rt, Date.now())
const nameOf = id => rt.botOf(rt.selfOf(id))?.name ?? id

// One turn, one spend, on its first step. A refused turn never reaches a step and a long turn
// pays once, so the counter measures dispatched turns and not model calls.
ctx.on('session/event', (session, event) => {
const id = session?.id
if (id === undefined || event?.type !== 'step/start' || event.data?.step !== 1) return
if (!rt.isBotSession(id)) return
const today = day()
void rt.mutate(() => {
const used = turnsUsed(rt.state.turns, today)
rt.state.turns = { day: today, count: used + 1 }
}).catch(error => rt.warn('ds-bot: turn budget: a turn was not counted: %s', error))
})

// Before the turn's first step reaches a model: over the cap, only the user's own turn goes on.
ctx.on('agent/pre-step', async ({ agent, step }, next) => {
await rt.load()
if (step !== 1 || !rt.isBotSession(agent.id)) return next()
const cap = turnBudgetOf(rt.state.prefs)
if (cap === 0) return next()
const today = day()
const used = turnsUsed(rt.state.turns, today)
if (used < cap) return next()
const origin = rt.live?.get(agent.id)?.origin
if (origin === OWNER) return next()
rt.warn('ds-bot: turn budget: %d of %d turns used on %s (%s); a %s turn for %s is not dispatched', used, cap, today, TEAM_TURN_ZONE, origin ?? 'system', nameOf(agent.id))
return { kind: 'reject' }
}, { prepend: true })
}
4 changes: 4 additions & 0 deletions core/test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => {
'effect ds-bot: archive a part once its job ends',
'on agent/status',
'on llm/stream',
// #645: the team turn budget, installed after parts so its gate answers first (it registers
// with prepend) and its spend listener sees turns.js's records.
'on session/event',
'on agent/pre-step (prepend)',
'on llm/stream',
'on agent/status',
'tool request_secret',
Expand Down
3 changes: 2 additions & 1 deletion core/test/legacy-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ test('a team saved with the legacy grok theme opens with the mono theme', async
const { call, stop, home } = open(savedTeam({ theme: 'grok', accent: '#123456' }))
t.after(stop)
const team = (await call('state', {})).value
assert.deepEqual(team.prefs, { theme: 'mono', accent: '#123456' })
// A team saved before #645 existed is seeded with its budget off (0), not the fresh-team 200.
assert.deepEqual(team.prefs, { theme: 'mono', accent: '#123456', turnBudget: 0 })
assert.equal(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).prefs.theme, 'mono')
})

Expand Down
Loading
Loading