feat: apply selected experts as role prompts on the conversation runtime

This commit is contained in:
mesalogo
2026-10-08 16:23:08 +08:00
parent 13b58627c0
commit 1a59c2d880
5 changed files with 90 additions and 207 deletions
+41 -121
View File
@@ -12083,16 +12083,26 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('routes eligible requests through the persisted smart expert service and publishes child events', async () => { it.each([
{ route: 'manual', consumesTrustedInstructions: true },
{ route: 'smart', consumesTrustedInstructions: true },
{ route: 'manual', consumesTrustedInstructions: false }
] as const)('applies a $route expert as a role prompt on the conversation runtime (system channel: $consumesTrustedInstructions)', async ({ route, consumesTrustedInstructions }) => {
const received: Array<{ requestId: string; prompt: string; trustedInstructions?: string }> = []
const runtime = { const runtime = {
runtimeId: 'opencode',
capability: 'chat', capability: 'chat',
requiresToolApproval: false, requiresToolApproval: false,
supportsToolExecution: true, supportsToolExecution: true,
consumesTrustedInstructions,
getStatus: vi.fn(), getStatus: vi.fn(),
dispose: vi.fn(), dispose: vi.fn(),
run: vi.fn() async *run(request: { requestId: string; prompt: string; trustedInstructions?: string }) {
received.push(request)
yield { requestId: request.requestId, type: 'text', delta: '角色回答' }
yield { requestId: request.requestId, type: 'done' }
}
} }
const childTaskId = '00000000-0000-4000-8000-000000000099'
const expert = { const expert = {
id: '00000000-0000-4000-8000-000000000001', id: '00000000-0000-4000-8000-000000000001',
name: '研究专家', name: '研究专家',
@@ -12104,69 +12114,49 @@ describe('registerIpcHandlers agent terminal state', () => {
updatedAt: '2026-01-01T00:00:00.000Z' updatedAt: '2026-01-01T00:00:00.000Z'
} }
const subagentService = { const subagentService = {
run: vi.fn(async (input: { run: vi.fn(),
parentRequest: { requestId: string }
onEvent: (event: Record<string, unknown>) => void
}) => {
for (const state of ['queued', 'running', 'completed']) {
input.onEvent({
requestId: input.parentRequest.requestId,
type: 'subagent',
childTaskId,
expertId: expert.id,
expertName: expert.name,
routingMode: 'smart',
state
})
}
return { childTaskId, output: '专家结果' }
}),
cancelAll: vi.fn(), cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined) dispose: vi.fn(async () => undefined)
} }
const harness = createHarness( const harness = createHarness(runtime, undefined, subagentService, true)
runtime, vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([expert])
undefined, vi.mocked(harness.assistantDatabase.getExpert).mockReturnValue(expert)
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
expert
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), { harness.handler?.(trustedEvent(harness.webContents), {
requestId, requestId,
conversationId: 'conversation-smart', conversationId: 'conversation-role',
prompt: '请做资料分析', prompt: '请做资料分析',
smartRouting: true ...(route === 'manual' ? { expertId: expert.id } : { smartRouting: true })
}) })
await vi.waitFor(() => await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')
requestId,
'completed'
)
) )
expect(runtime.run).not.toHaveBeenCalled() expect(received).toHaveLength(1)
expect(harness.getPolicySettings).toHaveBeenCalledOnce() const request = received[0]!
expect(harness.getResolvedSettings).not.toHaveBeenCalled() expect(request.trustedInstructions).toContain('enabled capabilities')
expect(subagentService.run).toHaveBeenCalledWith( expect(request.trustedInstructions).toContain('"研究专家"')
expect.objectContaining({ expert, routingMode: 'smart' }) expect(request.trustedInstructions).toContain('Analyze evidence.')
) if (consumesTrustedInstructions) {
expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith( expect(request.prompt).toBe('请做资料分析')
} else {
expect(request.prompt).toMatch(/enabled capabilities[\s\S]*Analyze evidence\.\n\n请做资料分析$/u)
}
expect(subagentService.run).not.toHaveBeenCalled()
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
requestId, requestId,
'subagent', 'subagent',
expect.objectContaining({ childTaskId, state: 'queued' }) expect.anything()
) )
expect(harness.webContents.send).toHaveBeenCalledWith( expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent, ipcChannels.agentEvent,
expect.objectContaining({ type: 'subagent', state: 'completed' }) expect.objectContaining({ type: 'text', delta: '角色回答' })
) )
await harness.dispose() await harness.dispose()
}) })
it.each(['manual', 'team', 'smart'] as const)('binds %s expert writes to each request project instead of the global workspace', async (route) => { it('binds expert team writes to each request project instead of the global workspace', async () => {
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime') const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
const directory = await mkdtemp(join(tmpdir(), 'expert-projects-')) const directory = await mkdtemp(join(tmpdir(), 'expert-projects-'))
const globalRoot = join(directory, 'global') const globalRoot = join(directory, 'global')
@@ -12215,11 +12205,10 @@ describe('registerIpcHandlers agent terminal state', () => {
for (const [index, projectId] of projectIds.entries()) { for (const [index, projectId] of projectIds.entries()) {
const requestId = crypto.randomUUID() const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: `project-${index}`, projectId, await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: `project-${index}`, projectId,
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, expertId: route === 'manual' ? expert.id : undefined, prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, teamMode: true })
teamMode: route === 'team', smartRouting: route === 'smart' })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
const files = await readdir(roots[index]!) const files = await readdir(roots[index]!)
expect(files).toHaveLength(route === 'team' ? 2 : 1) expect(files).toHaveLength(2)
for (const file of files) expect(await readFile(join(roots[index]!, file), 'utf8')).toBe(index === 0 ? 'alpha' : 'beta') for (const file of files) expect(await readFile(join(roots[index]!, file), 'utf8')).toBe(index === 0 ? 'alpha' : 'beta')
for (const input of requests.filter(input => input.parentRequest.requestId === requestId)) { for (const input of requests.filter(input => input.parentRequest.requestId === requestId)) {
expect(input.executionSpace).toBe(getRuntime.mock.calls[index]?.[1]) expect(input.executionSpace).toBe(getRuntime.mock.calls[index]?.[1])
@@ -12236,9 +12225,7 @@ describe('registerIpcHandlers agent terminal state', () => {
} }
}) })
it.each((['manual', 'team', 'smart'] as const).flatMap(route => it.each(['opencode', 'continue'] as const)('uses managed %s for SSH expert team members with their profile and parent bindings', async (provider) => {
(['opencode', 'continue'] as const).map(provider => ({ route, provider }))
))('uses managed $provider for $route SSH experts with their profile and parent bindings', async ({ route, provider }) => {
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime') const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-')) const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-'))
const projectId = crypto.randomUUID() const projectId = crypto.randomUUID()
@@ -12297,14 +12284,13 @@ describe('registerIpcHandlers agent terminal state', () => {
try { try {
const requestId = crypto.randomUUID() const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId, await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId,
prompt: 'write a file', expertId: route === 'manual' ? expert.id : undefined, prompt: 'write a file', teamMode: true })
teamMode: route === 'team', smartRouting: route === 'smart' })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
expect(factory).toHaveBeenCalledTimes(route === 'team' ? 2 : 1) expect(factory).toHaveBeenCalledTimes(2)
expect(createRemote).toHaveBeenCalledWith({ provider, profileId: expert.modelProfileId }, executionSpace) expect(createRemote).toHaveBeenCalledWith({ provider, profileId: expert.modelProfileId }, executionSpace)
expect(createLocal).not.toHaveBeenCalled() expect(createLocal).not.toHaveBeenCalled()
expect(binding.save).not.toHaveBeenCalled() expect(binding.save).not.toHaveBeenCalled()
expect(fetcher).toHaveBeenCalledTimes(route === 'team' ? 1 : 0) expect(fetcher).toHaveBeenCalledTimes(1)
expect(ordinary.run).not.toHaveBeenCalled() expect(ordinary.run).not.toHaveBeenCalled()
expect(await readdir(globalRoot)).toEqual([]) expect(await readdir(globalRoot)).toEqual([])
} finally { } finally {
@@ -12375,72 +12361,6 @@ describe('registerIpcHandlers agent terminal state', () => {
} }
) )
it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const subagentService = {
run: vi.fn((input: { signal: AbortSignal }) => {
markStarted()
return new Promise((_resolve, reject) => {
input.signal.addEventListener(
'abort',
() => reject(input.signal.reason),
{ once: true }
)
})
}),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-cancel-smart',
prompt: '请做资料分析',
smartRouting: true
})
await started
harness.cancelHandler?.(trustedEvent(harness.webContents), requestId)
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'cancelled',
'请求已取消'
)
)
expect(runtime.run).not.toHaveBeenCalled()
await harness.dispose()
})
it('allows ordinary text requests on runtimes without tool execution', async () => { it('allows ordinary text requests on runtimes without tool execution', async () => {
const runtime = { const runtime = {
capability: 'chat', capability: 'chat',
+46 -83
View File
@@ -282,10 +282,7 @@ import {
supervisionKnowledgeCommitRequestSchema supervisionKnowledgeCommitRequestSchema
} from '../shared/supervision-contracts' } from '../shared/supervision-contracts'
import { showDesktopNotificationWhenUnfocused } from './desktop-notification' import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
import { import type { SubagentService } from './assistant/subagent-service'
SubagentRunError,
type SubagentService
} from './assistant/subagent-service'
import { routeSubagent } from './assistant/subagent-router' import { routeSubagent } from './assistant/subagent-router'
import { import {
startEnvironmentChannels startEnvironmentChannels
@@ -2699,37 +2696,20 @@ export function registerIpcHandlers(
yield { requestId: request.requestId, type: 'done' } yield { requestId: request.requestId, type: 'done' }
} }
const runSingleExpert = async function* ( /**
request: AgentExecutionRequest, * A selected or smart-routed expert is a role prompt for the conversation's
expert: Awaited<ReturnType<AssistantStoragePort['getExpert']>>, * own runtime, not a delegated child task: it joins the trusted
routingMode: 'manual' | 'smart', * instructions so streaming, tools and the selected runtime stay unchanged.
signal: AbortSignal, */
executionSpace: ExecutionSpaceDescriptor | undefined, const expertRolePrompt = (
reason?: string expert: Awaited<ReturnType<AssistantStoragePort['getExpert']>>
): AsyncGenerator<RuntimeEvent, void, void> { ): string =>
if (!subagentService) { [
throw new Error('专家子任务服务不可用') `Role: act as the specialist "${expert.name}" for this request.`,
} expert.systemInstructions
const result = await subagentService.run({ ]
parentRequest: request, .filter(Boolean)
executionSpace, .join('\n\n')
expert,
routingMode,
reason,
signal,
onEvent: async (event) =>
(await publishSubagentEvent(request.requestId, event)),
onModelUsage: persistModelUsage
})
if (result.output) {
yield {
requestId: request.requestId,
type: 'text',
delta: result.output
}
}
yield { requestId: request.requestId, type: 'done' }
}
let scheduleQueueTickRunning = false let scheduleQueueTickRunning = false
const queueDueSchedules = async (): Promise<void> => { const queueDueSchedules = async (): Promise<void> => {
@@ -4192,6 +4172,15 @@ export function registerIpcHandlers(
) )
} }
} }
// A selected or smart-routed expert is a role prompt for the
// conversation's own runtime, not a delegated child task.
const roleExpert =
executionRequest.teamMode || imageGeneration
? undefined
: executionRequest.expertId
? await assistantDatabase.getExpert(executionRequest.expertId)
: smartRoute?.expert
const rolePrompt = roleExpert ? expertRolePrompt(roleExpert) : ''
const ordinaryStream = (): AsyncGenerator< const ordinaryStream = (): AsyncGenerator<
RuntimeEvent, RuntimeEvent,
void, void,
@@ -4202,66 +4191,40 @@ export function registerIpcHandlers(
managedSshOperationAccepted = true managedSshOperationAccepted = true
markManagedSshAccepted() markManagedSshAccepted()
} }
const runtimeRequest: AgentExecutionRequest = rolePrompt
? {
...executionRequest,
trustedInstructions: [
executionRequest.trustedInstructions,
rolePrompt
]
.filter(Boolean)
.join('\n\n')
}
: executionRequest
// Runtimes without a system channel get the capability and role
// instructions ahead of the prompt.
const promptPrefix =
selectedRuntime.consumesTrustedInstructions !== true
? [capabilityInstruction, rolePrompt].filter(Boolean).join('\n\n')
: ''
return selectedRuntime.run( return selectedRuntime.run(
capabilityInstruction && selectedRuntime.consumesTrustedInstructions !== true promptPrefix
? { ? {
...executionRequest, ...runtimeRequest,
prompt: `${capabilityInstruction}\n\n${executionRequest.prompt}` prompt: `${promptPrefix}\n\n${runtimeRequest.prompt}`
} }
: executionRequest, : runtimeRequest,
controller.signal controller.signal
) )
} }
const runSmartRoute = async function* (): AsyncGenerator<
RuntimeEvent,
void,
void
> {
if (!smartRoute) {
yield* ordinaryStream()
return
}
try {
yield* runSingleExpert(
executionRequest,
smartRoute.expert,
'smart',
controller.signal,
requestExecutionSpace,
`匹配 ${smartRoute.matches} 个关键词,得分 ${smartRoute.score}`
)
} catch (error) {
if (controller.signal.aborted) {
throw error
}
if (error instanceof SubagentRunError && error.output) {
yield {
requestId: request.requestId,
type: 'text',
delta: error.output
}
throw error
}
yield* ordinaryStream()
}
}
const eventStream = executionRequest.teamMode const eventStream = executionRequest.teamMode
? runExpertTeam( ? runExpertTeam(
executionRequest, executionRequest,
controller.signal, controller.signal,
requestExecutionSpace requestExecutionSpace
) )
: executionRequest.expertId && !imageGeneration : ordinaryStream()
? runSingleExpert(
executionRequest,
await assistantDatabase.getExpert(
executionRequest.expertId
),
'manual',
controller.signal,
requestExecutionSpace
)
: runSmartRoute()
for await (const agentEvent of splitTaggedReasoning(eventStream)) { for await (const agentEvent of splitTaggedReasoning(eventStream)) {
if (agentEvent.type === 'done' || agentEvent.type === 'error') activeRequestLease.settling = true if (agentEvent.type === 'done' || agentEvent.type === 'error') activeRequestLease.settling = true
const provenance = remoteSemanticProvenance(agentEvent) const provenance = remoteSemanticProvenance(agentEvent)
+1 -1
View File
@@ -3035,7 +3035,7 @@ describe('SettingsPanel runtime files', () => {
'自动选择 1 位专家' '自动选择 1 位专家'
) )
expect(screen.getByText(/未显式选择专家或团队时/)).toHaveTextContent( expect(screen.getByText(/未显式选择专家或团队时/)).toHaveTextContent(
'子专家使用默认文本模型和已启用工具' '专家提示词作为角色提示词,由当前会话的 Runtime 直接执行'
) )
fireEvent.click(smartRouting) fireEvent.click(smartRouting)
@@ -867,7 +867,7 @@ export const settings = {
'Automatically choose the expert role that best matches the question', 'Automatically choose the expert role that best matches the question',
enabled: 'Enable Smart Subagent routing', enabled: 'Enable Smart Subagent routing',
help: help:
'Off by default. When no expert or team is explicitly selected, GoodBuddy chooses one expert. The Subagent uses the default text model and enabled tools.' 'Off by default. When no expert or team is explicitly selected, GoodBuddy chooses one expert. Its prompt is applied as a role prompt on the conversation\'s current Runtime.'
} }
}, },
appearance: { appearance: {
@@ -802,7 +802,7 @@ export const settings = {
description: '按问题内容自动选择最匹配的专家角色', description: '按问题内容自动选择最匹配的专家角色',
enabled: '启用 Subagent 智能路由', enabled: '启用 Subagent 智能路由',
help: help:
'默认关闭。未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型和已启用工具。' '默认关闭。未显式选择专家或团队时,自动选择 1 位专家;专家提示词作为角色提示词,由当前会话的 Runtime 直接执行。'
} }
}, },
appearance: { appearance: {