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()
})
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 = {
runtimeId: 'opencode',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
consumesTrustedInstructions,
getStatus: 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 = {
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
@@ -12104,69 +12114,49 @@ describe('registerIpcHandlers agent terminal state', () => {
updatedAt: '2026-01-01T00:00:00.000Z'
}
const subagentService = {
run: vi.fn(async (input: {
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: '专家结果' }
}),
run: vi.fn(),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
expert
])
const harness = createHarness(runtime, undefined, subagentService, true)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([expert])
vi.mocked(harness.assistantDatabase.getExpert).mockReturnValue(expert)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-smart',
conversationId: 'conversation-role',
prompt: '请做资料分析',
smartRouting: true
...(route === 'manual' ? { expertId: expert.id } : { smartRouting: true })
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')
)
expect(runtime.run).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(subagentService.run).toHaveBeenCalledWith(
expect.objectContaining({ expert, routingMode: 'smart' })
)
expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith(
expect(received).toHaveLength(1)
const request = received[0]!
expect(request.trustedInstructions).toContain('enabled capabilities')
expect(request.trustedInstructions).toContain('"研究专家"')
expect(request.trustedInstructions).toContain('Analyze evidence.')
if (consumesTrustedInstructions) {
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,
'subagent',
expect.objectContaining({ childTaskId, state: 'queued' })
expect.anything()
)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'subagent', state: 'completed' })
expect.objectContaining({ type: 'text', delta: '角色回答' })
)
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 directory = await mkdtemp(join(tmpdir(), 'expert-projects-'))
const globalRoot = join(directory, 'global')
@@ -12215,11 +12205,10 @@ describe('registerIpcHandlers agent terminal state', () => {
for (const [index, projectId] of projectIds.entries()) {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: `project-${index}`, projectId,
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, expertId: route === 'manual' ? expert.id : undefined,
teamMode: route === 'team', smartRouting: route === 'smart' })
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, teamMode: true })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
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 input of requests.filter(input => input.parentRequest.requestId === requestId)) {
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 =>
(['opencode', 'continue'] as const).map(provider => ({ route, provider }))
))('uses managed $provider for $route SSH experts with their profile and parent bindings', async ({ route, provider }) => {
it.each(['opencode', 'continue'] as const)('uses managed %s for SSH expert team members with their profile and parent bindings', async (provider) => {
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-'))
const projectId = crypto.randomUUID()
@@ -12297,14 +12284,13 @@ describe('registerIpcHandlers agent terminal state', () => {
try {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId,
prompt: 'write a file', expertId: route === 'manual' ? expert.id : undefined,
teamMode: route === 'team', smartRouting: route === 'smart' })
prompt: 'write a file', teamMode: true })
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(createLocal).not.toHaveBeenCalled()
expect(binding.save).not.toHaveBeenCalled()
expect(fetcher).toHaveBeenCalledTimes(route === 'team' ? 1 : 0)
expect(fetcher).toHaveBeenCalledTimes(1)
expect(ordinary.run).not.toHaveBeenCalled()
expect(await readdir(globalRoot)).toEqual([])
} 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 () => {
const runtime = {
capability: 'chat',
+46 -83
View File
@@ -282,10 +282,7 @@ import {
supervisionKnowledgeCommitRequestSchema
} from '../shared/supervision-contracts'
import { showDesktopNotificationWhenUnfocused } from './desktop-notification'
import {
SubagentRunError,
type SubagentService
} from './assistant/subagent-service'
import type { SubagentService } from './assistant/subagent-service'
import { routeSubagent } from './assistant/subagent-router'
import {
startEnvironmentChannels
@@ -2699,37 +2696,20 @@ export function registerIpcHandlers(
yield { requestId: request.requestId, type: 'done' }
}
const runSingleExpert = async function* (
request: AgentExecutionRequest,
expert: Awaited<ReturnType<AssistantStoragePort['getExpert']>>,
routingMode: 'manual' | 'smart',
signal: AbortSignal,
executionSpace: ExecutionSpaceDescriptor | undefined,
reason?: string
): AsyncGenerator<RuntimeEvent, void, void> {
if (!subagentService) {
throw new Error('专家子任务服务不可用')
}
const result = await subagentService.run({
parentRequest: request,
executionSpace,
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' }
}
/**
* A selected or smart-routed expert is a role prompt for the conversation's
* own runtime, not a delegated child task: it joins the trusted
* instructions so streaming, tools and the selected runtime stay unchanged.
*/
const expertRolePrompt = (
expert: Awaited<ReturnType<AssistantStoragePort['getExpert']>>
): string =>
[
`Role: act as the specialist "${expert.name}" for this request.`,
expert.systemInstructions
]
.filter(Boolean)
.join('\n\n')
let scheduleQueueTickRunning = false
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<
RuntimeEvent,
void,
@@ -4202,66 +4191,40 @@ export function registerIpcHandlers(
managedSshOperationAccepted = true
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(
capabilityInstruction && selectedRuntime.consumesTrustedInstructions !== true
promptPrefix
? {
...executionRequest,
prompt: `${capabilityInstruction}\n\n${executionRequest.prompt}`
...runtimeRequest,
prompt: `${promptPrefix}\n\n${runtimeRequest.prompt}`
}
: executionRequest,
: runtimeRequest,
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
? runExpertTeam(
executionRequest,
controller.signal,
requestExecutionSpace
)
: executionRequest.expertId && !imageGeneration
? runSingleExpert(
executionRequest,
await assistantDatabase.getExpert(
executionRequest.expertId
),
'manual',
controller.signal,
requestExecutionSpace
)
: runSmartRoute()
: ordinaryStream()
for await (const agentEvent of splitTaggedReasoning(eventStream)) {
if (agentEvent.type === 'done' || agentEvent.type === 'error') activeRequestLease.settling = true
const provenance = remoteSemanticProvenance(agentEvent)
+1 -1
View File
@@ -3035,7 +3035,7 @@ describe('SettingsPanel runtime files', () => {
'自动选择 1 位专家'
)
expect(screen.getByText(/未显式选择专家或团队时/)).toHaveTextContent(
'子专家使用默认文本模型和已启用工具'
'专家提示词作为角色提示词,由当前会话的 Runtime 直接执行'
)
fireEvent.click(smartRouting)
@@ -867,7 +867,7 @@ export const settings = {
'Automatically choose the expert role that best matches the question',
enabled: 'Enable Smart Subagent routing',
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: {
@@ -802,7 +802,7 @@ export const settings = {
description: '按问题内容自动选择最匹配的专家角色',
enabled: '启用 Subagent 智能路由',
help:
'默认关闭。未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型和已启用工具。'
'默认关闭。未显式选择专家或团队时,自动选择 1 位专家;专家提示词作为角色提示词,由当前会话的 Runtime 直接执行。'
}
},
appearance: {