Files
goodbuddy/src/main/assistant/supervision-production.ts
T
mesalogo 48d7526b7e feat: configure supervisor model and story graph use per conversation
Add a supervisor text-model selector using existing connections and a per-conversation story graph switch above knowledge settings. Hide the switch when Supervisor is disabled and enforce conversation preferences at local and remote tool execution boundaries.

Release note: Choose a dedicated supervisor model and control whether each conversation can query existing stories and experiences. Enabling graph access does not start a review or automatically insert context.
2026-10-03 19:16:35 +08:00

197 lines
12 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import { defaultSupervisionTimeoutSeconds, defaultSupervisorModelConcurrency } from '../../shared/application-settings-contracts'
import { defaultExperienceMinEvents, defaultStoryThreadEvents, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
import type { ReviewConfiguration } from './supervision-review-store'
import { assignStories } from './supervision-stories'
import { extractExperiences } from './supervision-experiences'
import type { AgentRuntime, RuntimeModelUsageEvent } from '../agent/runtime'
import type { AssistantDatabase } from './assistant-database'
import type { SupervisionModelPool } from './supervision-model-pool'
import type { SuggestionPhraser } from './supervision-suggester'
import { SupervisorService } from './supervisor-service'
type SupervisionModelDependencies = {
database: AssistantDatabase
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>
resolveRuntime: () => Promise<AgentRuntime>
pool: SupervisionModelPool
persistUsage: (event: RuntimeModelUsageEvent) => void
}
/** One bounded, tool-free text call that shares the supervisor concurrency pool. */
async function runSupervisionModel(dependencies: SupervisionModelDependencies, request: {
prompt: string
title: string
instructions: string
timeoutMessage: (seconds: number) => string
timeoutSeconds?: number
signal?: AbortSignal
authorizeTool: (name: string) => Promise<never>
}): Promise<string> {
const { database, getSettings, resolveRuntime, pool, persistUsage } = dependencies
const settings = await getSettings()
pool.setLimit(settings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency)
return pool.run(async signal => {
const timeoutSeconds = request.timeoutSeconds ?? settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
const runtime = await resolveRuntime()
const controller = new AbortController()
const modelSignal = AbortSignal.any([signal, controller.signal, ...(request.signal ? [request.signal] : [])])
modelSignal.throwIfAborted()
let timeoutError: Error | undefined
const timeout = setTimeout(() => {
timeoutError = new Error(request.timeoutMessage(timeoutSeconds))
controller.abort(timeoutError)
}, timeoutSeconds * 1000)
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
let output = '', completed = false
const requestId = randomUUID()
const conversationId = `supervision:${requestId}`
// Hidden task row so model usage can be attributed to supervision.
database.createTask({ id: requestId, conversationId, title: request.title, instructions: request.instructions,
workMode: 'ask', origin: 'assistant', visible: false })
try {
for await (const event of runtime.run({ requestId, conversationId, workMode: 'ask', prompt: request.prompt },
modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' })) {
if (event.type === 'text') {
output += event.delta
if (Buffer.byteLength(output) > responseKiB * 1024) {
controller.abort(new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`))
modelSignal.throwIfAborted()
}
}
if (event.type === 'model-usage') persistUsage(event)
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
if (event.type === 'error') throw new Error(event.message)
if (event.type === 'done') completed = true
}
modelSignal.throwIfAborted()
if (!completed) throw new Error('监督者模型未报告完成')
database.updateTaskStatus(requestId, 'completed')
return output
} catch (error) {
const failure = timeoutError ?? error
try {
database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed',
failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败')
} catch { /* status bookkeeping must not mask the model failure */ }
throw failure
}
finally { clearTimeout(timeout); await runtime.releaseConversation?.(conversationId) }
}, request.signal)
}
function dependencies(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
pool: SupervisionModelPool,
persistUsage?: (event: RuntimeModelUsageEvent) => void
): SupervisionModelDependencies {
return { database, getSettings, resolveRuntime, pool, persistUsage: persistUsage ?? (event => database.upsertModelUsageCall({
requestId: event.requestId, callId: event.callId, runtime: event.runtime, provider: event.provider, model: event.model,
input: event.inputTokens, output: event.outputTokens, cacheRead: event.cacheReadTokens, cacheWrite: event.cacheWriteTokens
})) }
}
export function createProductionSupervisorService(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
pool: SupervisionModelPool,
persistUsage?: (event: RuntimeModelUsageEvent) => void
): SupervisorService {
// The service admits only one execution at a time, including resume and story retry.
let runtime: AgentRuntime | undefined
const model = dependencies(database, getSettings, async () => {
if (!runtime) throw new Error('Supervisor model execution is not active')
return runtime
}, pool, persistUsage)
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
summarize: async request => runSupervisionModel(model, {
title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
timeoutMessage: seconds => `监督者整理模型阶段超过 ${seconds} 秒,已停止本次回顾;未保存结果`, timeoutSeconds: request.timeoutSeconds,
signal: request.signal, authorizeTool: request.authorizeTool,
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
'REVIEW TIME RANGE:', JSON.stringify(request.request.timeRange), 'KNOWN ENTITIES:', JSON.stringify(request.candidates.map((candidate, index) => ({
candidateRef: `known_${index + 1}`, label: candidate.label, description: candidate.description
}))),
'PREVIOUS SUMMARY (background only):', request.previousSummary ?? '',
'CURRENT MEMORY (background only; do not cite as new evidence):', JSON.stringify(request.background ?? []),
'BOUNDED EVIDENCE:', JSON.stringify(request.evidence), 'Return only JSON.'].join('\n\n')
})
}, {
scope: scope => database.resolveReviewScope(scope), summary: request => database.reviewSummary(request.scope, 'supervisor'),
background: request => database.reviewBackground(request.scope),
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
candidates: async request => database.listSupervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
}, { database: () => database.supervisionReviewStore(), withExecution: async operation => {
runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
try { return await operation() }
finally {
const completed = runtime
runtime = undefined
await completed.dispose?.()
}
}, configuration: async () => {
const settings = await getSettings()
return { ...supervisionReviewSettingsSchema.parse(settings?.supervisionReview ?? {}), version: 1,
timeoutSeconds: settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
concurrency: settings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency }
}, stories: (request, config, signal) => organizeSupervisionStories(model, request, config, signal) })
}
/** Story assignment for the review's scope: event text and story summaries only, one bounded call per chunk. */
async function organizeSupervisionStories(model: SupervisionModelDependencies, request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) {
const run = (title: string, prompt: string, modelSignal?: AbortSignal) => runSupervisionModel(model, {
title, instructions: title,
timeoutMessage: seconds => `${title}超时 ${seconds} 秒,已停止;回顾结果已保留`, timeoutSeconds: config.timeoutSeconds,
signal: modelSignal, authorizeTool: async name => { throw new Error(`监督者禁止调用工具 ${name}`) }, prompt
})
const result = await assignStoriesStep(model, request, config, signal)
// Experiences read stories, so they follow assignment; a failure keeps the assigned stories.
try {
const experiences = await extractExperiences(model.database.supervisionExperiences(), (prompt, modelSignal) => run('监督者经验整理', prompt, modelSignal),
{ minEvents: config.experienceMinEvents ?? defaultExperienceMinEvents, batchCharacters: Math.max(4000, config.batchCharacters * 2), signal })
return { ...result, experiences: { status: 'completed' as const, ...experiences } }
} catch (error) {
if (signal.aborted) throw error
return { ...result, experiences: { status: 'failed' as const, error: error instanceof Error ? error.message.slice(0, 2000) : 'Experience extraction failed' } }
}
}
async function assignStoriesStep(model: SupervisionModelDependencies, request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) {
const result = await assignStories(model.database.supervisionStories(), (prompt, modelSignal) => runSupervisionModel(model, {
title: '监督者故事整理', instructions: '把已发布的事件归入故事',
timeoutMessage: seconds => `监督者故事整理超过 ${seconds} 秒,已停止;回顾结果已保留`, timeoutSeconds: config.timeoutSeconds,
signal: modelSignal, authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) }, prompt
}), request.scope, { crossProject: config.crossProject === true, threadEvents: config.storyThreadEvents ?? defaultStoryThreadEvents,
batchCharacters: Math.max(4000, config.batchCharacters * 2), concurrency: config.concurrency, signal })
return { status: 'completed' as const, ...result }
}
/** Phrases rule-selected suggestion candidates in one bounded model call. */
export function createProductionSuggestionPhraser(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
pool: SupervisionModelPool,
persistUsage?: (event: RuntimeModelUsageEvent) => void
): SuggestionPhraser {
return async request => {
const runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
const model = dependencies(database, getSettings, async () => runtime, pool, persistUsage)
try {
return await runSupervisionModel(model, {
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
})
} finally { await runtime.dispose?.() }
}
}