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.
This commit is contained in:
@@ -101,6 +101,27 @@ it('roundtrips canvas assets and keeps todos stable across geometry edits and co
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists the story graph switch per conversation without schema migration or stale autosave overrides', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Graph preference', updatedAt: 1000, knowledgeRetrievalMode: 'always' as const }
|
||||
const other = { ...header, id: randomUUID() }
|
||||
try {
|
||||
database.saveLocalConversations([{ header, messages: [] }, { header: other, messages: [] }])
|
||||
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
|
||||
database.setConversationStoryGraphEnabled(header.id, false)
|
||||
database.saveLocalConversations([{ header, messages: [] }])
|
||||
database.close()
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.getConversation(header.id)).toMatchObject({ storyGraphEnabled: false, knowledgeRetrievalMode: 'always' })
|
||||
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(false)
|
||||
expect(database.isConversationStoryGraphEnabled(other.id)).toBe(true)
|
||||
database.setConversationStoryGraphEnabled(header.id, true)
|
||||
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
|
||||
expect(database.isConversationStoryGraphEnabled(randomUUID())).toBe(false)
|
||||
expect(() => database.setConversationStoryGraphEnabled(randomUUID(), false)).toThrow('对话不存在')
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
||||
|
||||
@@ -1234,6 +1234,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
workMode: true,
|
||||
knowledgeLibraryIds: true,
|
||||
knowledgeRetrievalMode: true,
|
||||
storyGraphEnabled: true,
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
@@ -1242,7 +1243,7 @@ function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -1260,18 +1261,20 @@ function parseConversationContextState(
|
||||
function serializeConversationContextState(
|
||||
conversation: Pick<
|
||||
ConversationSnapshot,
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
>
|
||||
): string | null {
|
||||
return conversation.workMode !== undefined ||
|
||||
conversation.knowledgeLibraryIds !== undefined ||
|
||||
conversation.knowledgeRetrievalMode !== undefined ||
|
||||
conversation.storyGraphEnabled !== undefined ||
|
||||
conversation.contextMetrics ||
|
||||
conversation.contextCompressionState
|
||||
? JSON.stringify({
|
||||
workMode: conversation.workMode,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState
|
||||
})
|
||||
@@ -3194,6 +3197,22 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
isConversationStoryGraphEnabled(conversationId: string): boolean {
|
||||
const row = this.requireDatabase().prepare(
|
||||
"SELECT context_state_json FROM conversations WHERE id = ? AND status = 'active'"
|
||||
).get(conversationId) as { context_state_json: string | null } | undefined
|
||||
return Boolean(row && parseConversationContextState(row.context_state_json).storyGraphEnabled !== false)
|
||||
}
|
||||
|
||||
setConversationStoryGraphEnabled(conversationId: string, enabled: boolean): void {
|
||||
const result = this.requireDatabase().prepare(
|
||||
`UPDATE conversations
|
||||
SET context_state_json = json_set(COALESCE(context_state_json, '{}'), '$.storyGraphEnabled', json(?))
|
||||
WHERE id = ? AND status = 'active'`
|
||||
).run(JSON.stringify(enabled), conversationId)
|
||||
if (result.changes !== 1) throw new Error('对话不存在')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears references to deleted model connections in every project and
|
||||
* conversation so they fall back to the next layer instead of failing.
|
||||
@@ -3324,7 +3343,7 @@ export class AssistantDatabase {
|
||||
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
||||
const database = this.requireDatabase()
|
||||
const findConversation = database.prepare(
|
||||
'SELECT channel FROM conversations WHERE id = ?'
|
||||
'SELECT channel, context_state_json FROM conversations WHERE id = ?'
|
||||
)
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
@@ -3376,7 +3395,7 @@ export class AssistantDatabase {
|
||||
const { header } = save
|
||||
const existingConversation = findConversation.get(
|
||||
header.id
|
||||
) as { channel: ProjectChannel | null } | undefined
|
||||
) as { channel: ProjectChannel | null; context_state_json: string | null } | undefined
|
||||
const updatedAt = new Date(header.updatedAt).toISOString()
|
||||
if (existingConversation?.channel) {
|
||||
throw new Error('本地对话 ID 与远程对话冲突')
|
||||
@@ -3386,7 +3405,9 @@ export class AssistantDatabase {
|
||||
header.projectId ?? null,
|
||||
serializeRuntimeSelection(header.runtimeSelection),
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
// The explicit switch owns this preference; a queued older header must not undo it.
|
||||
serializeConversationContextState({ ...header,
|
||||
storyGraphEnabled: parseConversationContextState(existingConversation.context_state_json).storyGraphEnabled }),
|
||||
header.title,
|
||||
header.branch?.sourceConversationId ?? null,
|
||||
header.branch?.sourceTitle ?? null,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AgentRuntime } from '../agent/runtime'
|
||||
import type { SupervisionEvidence, SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { createProductionSupervisorService } from './supervision-production'
|
||||
import { createProductionSupervisorService, createProductionSuggestionPhraser } from './supervision-production'
|
||||
import { SupervisionModelPool } from './supervision-model-pool'
|
||||
import { supervisionEntitySchema } from '../../shared/supervision-contracts'
|
||||
|
||||
@@ -12,6 +12,88 @@ const cleanups: Array<() => void> = []
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })
|
||||
const empty = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
|
||||
it('keeps one model across extraction, navigation, stories and experiences, and reads the next selection on retry', async () => {
|
||||
const f = fixture('A'.repeat(1500))
|
||||
let profileId: string | null = 'first'
|
||||
const phases: string[] = []
|
||||
const dispose = vi.fn(async () => {})
|
||||
const resolve = vi.fn(async (selected?: string | null) => ({
|
||||
runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||
run: async function* (input) {
|
||||
expect(selected).toBe('first')
|
||||
profileId = 'next'
|
||||
let output: unknown
|
||||
if (input.prompt.includes('You organise the GoodBuddy')) {
|
||||
phases.push('stories')
|
||||
output = { stories: [{ key: 'new_1', level: 'feature', name: 'Work' }],
|
||||
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_1' }] }
|
||||
} else if (input.prompt.includes('You distil reusable experience')) {
|
||||
phases.push('experiences')
|
||||
output = { experiences: [], applications: [] }
|
||||
} else if (input.prompt.includes('These inputs are navigation summaries')) {
|
||||
phases.push('navigation')
|
||||
output = { summary: 'Combined', changeDigest: '', openItems: [] }
|
||||
} else {
|
||||
phases.push('extraction')
|
||||
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as SupervisionEvidence[]
|
||||
output = { ...empty, events: [{ title: 'Decision', description: '', eventType: 'decision',
|
||||
occurredAt: f.request.timeRange.from, entityIds: [], sourceReferenceIds: [evidence[0]!.id] }] }
|
||||
}
|
||||
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(output) }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
}
|
||||
} as AgentRuntime))
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanups.push(() => pool.dispose())
|
||||
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId,
|
||||
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, experienceMinEvents: 2 } }), resolve, pool)
|
||||
const result = await service.run(f.request)
|
||||
expect(phases).toEqual(['extraction', 'extraction', 'navigation', 'stories', 'experiences'])
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith('first')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
await service.organizeStoriesFor(result.runId!)
|
||||
expect(resolve).toHaveBeenLastCalledWith('next')
|
||||
expect(dispose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases a failed review runtime and uses the current model when continuing saved work', async () => {
|
||||
const f = fixture()
|
||||
let profileId: string | null = 'first'
|
||||
const dispose = vi.fn(async () => {})
|
||||
const resolve = vi.fn(async (selected?: string | null) => ({ runtimeId: 'model', capability: 'chat',
|
||||
requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||
run: async function* (input) {
|
||||
if (selected === 'first') throw new Error('Provider unavailable')
|
||||
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(empty) }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
}
|
||||
} as AgentRuntime))
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanups.push(() => pool.dispose())
|
||||
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId }), resolve, pool)
|
||||
await expect(service.run(f.request)).rejects.toThrow('Provider unavailable')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(service.execution().active).toBe(false)
|
||||
profileId = null
|
||||
await expect(service.resume(f.db.listSupervisionActivity()[0]!.id)).resolves.toMatchObject({ status: 'completed' })
|
||||
expect(resolve.mock.calls).toEqual([['first'], [null]])
|
||||
expect(dispose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses the supervisor selection for suggestions and disposes its runtime on failure', async () => {
|
||||
const f = fixture()
|
||||
const dispose = vi.fn(async () => {})
|
||||
const resolve = vi.fn(async () => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||
run: async function* (input) { yield { type: 'error', requestId: input.requestId, message: 'Provider unavailable' } }
|
||||
} as AgentRuntime))
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanups.push(() => pool.dispose())
|
||||
const phrase = createProductionSuggestionPhraser(f.db, async () => ({ supervisorModelProfileId: 'reviewer' }), resolve, pool)
|
||||
await expect(phrase({ systemInstruction: 'Suggest', outputContract: '{}', candidates: [] })).rejects.toThrow('Provider unavailable')
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith('reviewer')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('production six-leaf merge failure resumes navigation-only JSON and publishes all retained facts', async () => {
|
||||
const f = fixture('A'.repeat(765))
|
||||
const now = Date.parse(f.request.timeRange.from) + 1
|
||||
|
||||
@@ -85,7 +85,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
function dependencies(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SupervisionModelDependencies {
|
||||
@@ -98,11 +98,16 @@ function dependencies(
|
||||
export function createProductionSupervisorService(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SupervisorService {
|
||||
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
|
||||
// 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: '根据有界证据整理监督者回顾',
|
||||
@@ -122,7 +127,15 @@ export function createProductionSupervisorService(
|
||||
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(), configuration: async () => {
|
||||
}, { 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,
|
||||
@@ -163,16 +176,21 @@ async function assignStoriesStep(model: SupervisionModelDependencies, request: S
|
||||
export function createProductionSuggestionPhraser(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SuggestionPhraser {
|
||||
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
|
||||
return async request => 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')
|
||||
})
|
||||
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?.() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export class SupervisorService {
|
||||
private readonly review?: {
|
||||
database: () => SupervisionReviewStore
|
||||
configuration: () => Promise<ReviewConfiguration>
|
||||
withExecution?: (operation: () => Promise<StoredSupervisionResult>) => Promise<StoredSupervisionResult>
|
||||
/** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */
|
||||
stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise<NonNullable<SupervisionReviewProgress['stories']>>
|
||||
}
|
||||
@@ -234,7 +235,7 @@ export class SupervisorService {
|
||||
await this.active.settled.catch(() => undefined)
|
||||
}
|
||||
// Reserve before any asynchronous configuration, collection or resume work.
|
||||
const active = { settled: Promise.resolve().then(operation) }
|
||||
const active = { settled: Promise.resolve().then(() => this.review?.withExecution ? this.review.withExecution(operation) : operation()) }
|
||||
this.active = active
|
||||
try { return await active.settled }
|
||||
finally { if (this.active === active) this.active = undefined }
|
||||
|
||||
Reference in New Issue
Block a user