chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions

This commit is contained in:
lofyer
2026-08-06 22:47:13 +08:00
parent 8d00e6371d
commit b8fc7bc86e
114 changed files with 22916 additions and 1560 deletions
+167 -3
View File
@@ -24,7 +24,35 @@ async function createDatabase(): Promise<AssistantDatabase> {
}
describe('AssistantDatabase', () => {
it('migrates existing databases to schema version 7', async () => {
it('rejects a newer unsupported schema without changing its version', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-future-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const future = new DatabaseSync(databasePath)
future.exec('PRAGMA user_version = 99;')
future.close()
const downgraded = new AssistantDatabase(databasePath)
expect(() => downgraded.initialize('C:\\Workspace')).toThrow(
'不支持助理数据库版本 99'
)
const unchanged = new DatabaseSync(databasePath)
expect(
(
unchanged.prepare('PRAGMA user_version').get() as {
user_version: number
}
).user_version
).toBe(99)
unchanged.close()
})
it('migrates existing databases to schema version 8', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -52,7 +80,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(7)
).toBe(8)
expect(
current
.prepare(
@@ -125,7 +153,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(7)
).toBe(8)
expect(
current
.prepare(
@@ -242,6 +270,83 @@ describe('AssistantDatabase', () => {
database.close()
})
it('roundtrips expert model profiles and tolerates malformed model policies', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-expert-model-policy-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const firstModelProfileId =
'00000000-0000-4000-8000-000000000401'
const secondModelProfileId =
'00000000-0000-4000-8000-000000000402'
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
const expert = database.createExpert({
name: '模型绑定专家',
description: '验证模型策略持久化',
systemInstructions: 'Use the assigned model connection.',
modelProfileId: firstModelProfileId,
routingKeywords: ['模型绑定']
})
expect(expert.modelProfileId).toBe(firstModelProfileId)
expect(
database.listExperts().find((item) => item.id === expert.id)
).toMatchObject({
modelProfileId: firstModelProfileId,
routingKeywords: ['模型绑定']
})
const updated = database.updateExpert(expert.id, {
name: expert.name,
description: expert.description,
systemInstructions: expert.systemInstructions,
modelProfileId: secondModelProfileId,
routingKeywords: expert.routingKeywords
})
expect(updated.modelProfileId).toBe(secondModelProfileId)
database.close()
const persisted = new DatabaseSync(databasePath)
expect(
JSON.parse(
(
persisted
.prepare(
'SELECT model_policy_json FROM experts WHERE id = ?'
)
.get(expert.id) as { model_policy_json: string }
).model_policy_json
)
).toEqual({ modelProfileId: secondModelProfileId })
expect(
(
persisted.prepare('PRAGMA table_info(experts)').all() as Array<{
name: string
}>
).some((column) => column.name === 'model_profile_id')
).toBe(false)
persisted
.prepare(
'UPDATE experts SET model_policy_json = ? WHERE id = ?'
)
.run('{malformed-json', expert.id)
persisted.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const recoveredExpert = reopened
.listExperts()
.find((item) => item.id === expert.id)
reopened.close()
expect(recoveredExpert).toMatchObject({
id: expert.id,
routingKeywords: ['模型绑定']
})
expect(recoveredExpert?.modelProfileId).toBeUndefined()
})
it('persists task lifecycle and events', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
@@ -472,6 +577,10 @@ describe('AssistantDatabase', () => {
{
id: conversationId,
projectId: project.id,
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
title: '发布讨论',
updatedAt: 1_775_000_000_000,
messages: [
@@ -530,6 +639,10 @@ describe('AssistantDatabase', () => {
expect.objectContaining({
id: conversationId,
projectId: project.id,
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
messages: [
expect.objectContaining({
role: 'user',
@@ -570,6 +683,57 @@ describe('AssistantDatabase', () => {
database.close()
})
it('rebinds persisted conversations whose model profile was removed', async () => {
const database = await createDatabase()
const removedProfileId =
'00000000-0000-4000-8000-000000000291'
const defaultProfileId =
'00000000-0000-4000-8000-000000000292'
const runtimeProfileId =
'00000000-0000-4000-8000-000000000293'
database.replaceConversations(
([
['model', removedProfileId],
['opencode', removedProfileId],
['continue', removedProfileId],
['model', runtimeProfileId]
] as const).map(([provider, profileId], index) => ({
id: `00000000-0000-4000-8000-00000000030${index}`,
runtimeSelection: { provider, profileId },
title: `对话 ${index}`,
updatedAt: index + 1,
messages: []
}))
)
expect(
database.repairConversationRuntimeSelections({
modelProfiles: [
{ id: defaultProfileId },
{ id: runtimeProfileId }
],
defaultModelProfileId: defaultProfileId,
opencodeModelSource: {
kind: 'profile',
profileId: runtimeProfileId
},
continueModelSource: { kind: 'platform' }
})
).toBe(3)
expect(
database
.listConversations()
.sort((left, right) => left.title.localeCompare(right.title))
.map((conversation) => conversation.runtimeSelection)
).toEqual([
{ provider: 'model', profileId: defaultProfileId },
{ provider: 'opencode', profileId: runtimeProfileId },
{ provider: 'continue' },
{ provider: 'model', profileId: runtimeProfileId }
])
database.close()
})
it('durably interrupts active tool metadata during startup recovery', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-conversation-recovery-')
+131 -5
View File
@@ -30,6 +30,12 @@ import {
type ComputerControlErrorCode,
type ComputerControlRisk
} from '../../shared/computer-control-contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
type RuntimeSelectionRepairSettings
} from '../../shared/runtime-selection-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
@@ -65,6 +71,7 @@ type TaskRow = {
type ConversationRow = {
id: string
project_id: string | null
runtime_selection_json: string | null
title: string
updated_at: string
}
@@ -89,6 +96,22 @@ type MessageMetadata = {
attachments?: ConversationSnapshot['messages'][number]['attachments']
}
function parseRuntimeSelection(value: string | null):
| ConversationSnapshot['runtimeSelection']
| undefined {
if (!value) {
return undefined
}
try {
const parsed = agentRuntimeSelectionSchema.safeParse(
JSON.parse(value)
)
return parsed.success ? parsed.data : undefined
} catch {
return undefined
}
}
type ArtifactRow = {
id: string
project_id: string | null
@@ -133,6 +156,7 @@ type ExpertRow = {
description: string
system_instructions: string
capability_policy_json: string
model_policy_json: string
enabled: number
created_at: string
updated_at: string
@@ -341,6 +365,7 @@ function toSchedule(row: ScheduleRow): AssistantSchedule {
function toExpert(row: ExpertRow): AssistantExpert {
let routingKeywords: string[]
let modelProfileId: string | undefined
try {
const policy = JSON.parse(row.capability_policy_json) as {
routingKeywords?: unknown
@@ -356,11 +381,24 @@ function toExpert(row: ExpertRow): AssistantExpert {
} catch {
routingKeywords = []
}
try {
const policy = JSON.parse(row.model_policy_json) as {
modelProfileId?: unknown
}
modelProfileId = expertCreateSchema
.pick({ modelProfileId: true })
.parse({
modelProfileId: policy.modelProfileId
}).modelProfileId
} catch {
modelProfileId = undefined
}
return {
id: row.id,
name: row.name,
description: row.description,
systemInstructions: row.system_instructions,
...(modelProfileId ? { modelProfileId } : {}),
routingKeywords,
enabled: row.enabled === 1,
createdAt: row.created_at,
@@ -825,7 +863,7 @@ export class AssistantDatabase {
const database = this.requireDatabase()
const conversations = database
.prepare(
`SELECT id, project_id, title, updated_at
`SELECT id, project_id, runtime_selection_json, title, updated_at
FROM conversations
WHERE status = 'active'
ORDER BY updated_at DESC
@@ -843,6 +881,9 @@ export class AssistantDatabase {
return conversations.map((conversation) => ({
id: conversation.id,
projectId: conversation.project_id ?? undefined,
runtimeSelection: parseRuntimeSelection(
conversation.runtime_selection_json
),
title: conversation.title,
updatedAt: Date.parse(conversation.updated_at),
messages: (
@@ -874,6 +915,53 @@ export class AssistantDatabase {
}))
}
repairConversationRuntimeSelections(
settings: RuntimeSelectionRepairSettings
): number {
const database = this.requireDatabase()
const conversations = database
.prepare(
`SELECT id, runtime_selection_json
FROM conversations
WHERE runtime_selection_json IS NOT NULL`
)
.all() as Array<{
id: string
runtime_selection_json: string
}>
const update = database.prepare(
`UPDATE conversations
SET runtime_selection_json = ?
WHERE id = ?`
)
let repaired = 0
database.exec('BEGIN IMMEDIATE')
try {
for (const conversation of conversations) {
const current = parseRuntimeSelection(
conversation.runtime_selection_json
)
if (!current) {
continue
}
const next = repairAgentRuntimeSelection(current, settings)
if (
agentRuntimeSelectionKey(next) ===
agentRuntimeSelectionKey(current)
) {
continue
}
update.run(JSON.stringify(next), conversation.id)
repaired += 1
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return repaired
}
replaceConversations(
conversations: ConversationSnapshot[]
): void {
@@ -883,8 +971,9 @@ export class AssistantDatabase {
database.exec('DELETE FROM messages; DELETE FROM conversations;')
const insertConversation = database.prepare(
`INSERT INTO conversations
(id, project_id, work_mode, title, status, created_at, updated_at)
VALUES (?, ?, 'ask', ?, 'active', ?, ?)`
(id, project_id, runtime_selection_json, work_mode, title, status,
created_at, updated_at)
VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?)`
)
const insertMessage = database.prepare(
`INSERT INTO messages
@@ -897,6 +986,9 @@ export class AssistantDatabase {
insertConversation.run(
conversation.id,
conversation.projectId ?? null,
conversation.runtimeSelection
? JSON.stringify(conversation.runtimeSelection)
: null,
conversation.title,
updatedAt,
updatedAt
@@ -2618,7 +2710,7 @@ export class AssistantDatabase {
(id, name, description, system_instructions,
capability_policy_json, model_policy_json, enabled,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, '{}', 1, ?, ?)`
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`
)
.run(
id,
@@ -2628,6 +2720,9 @@ export class AssistantDatabase {
JSON.stringify({
routingKeywords: normalized.routingKeywords
}),
JSON.stringify({
modelProfileId: normalized.modelProfileId
}),
now,
now
)
@@ -2644,6 +2739,7 @@ export class AssistantDatabase {
`UPDATE experts
SET name = ?, description = ?, system_instructions = ?,
capability_policy_json = ?,
model_policy_json = ?,
updated_at = ?
WHERE id = ? AND enabled = 1`
)
@@ -2654,6 +2750,9 @@ export class AssistantDatabase {
JSON.stringify({
routingKeywords: normalized.routingKeywords
}),
JSON.stringify({
modelProfileId: normalized.modelProfileId
}),
new Date().toISOString(),
expertId
)
@@ -2730,7 +2829,12 @@ export class AssistantDatabase {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version >= 7) {
if (version.user_version > 8) {
throw new Error(
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
)
}
if (version.user_version === 8) {
return
}
if (version.user_version < 1) {
@@ -2750,6 +2854,7 @@ export class AssistantDatabase {
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
runtime_selection_json TEXT,
work_mode TEXT NOT NULL DEFAULT 'ask'
CHECK(work_mode IN ('ask', 'plan', 'execute')),
title TEXT NOT NULL,
@@ -3123,6 +3228,27 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 8) {
const conversationColumns = new Set(
(
database.prepare('PRAGMA table_info(conversations)').all() as Array<{
name: string
}>
).map((column) => column.name)
)
database.exec('BEGIN IMMEDIATE')
try {
if (!conversationColumns.has('runtime_selection_json')) {
database.exec(
'ALTER TABLE conversations ADD COLUMN runtime_selection_json TEXT'
)
}
database.exec('PRAGMA user_version = 8; COMMIT;')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
+16 -18
View File
@@ -59,7 +59,7 @@ const summary = {
}
describe('AssistantDatabase heartbeat persistence', () => {
it('migrates v2 to v3 without changing existing schedules', async () => {
it('migrates a v2 database without changing existing schedules', async () => {
const { database, path } = await createDatabase()
const schedule = database.createSchedule({
title: 'Existing schedule',
@@ -85,25 +85,23 @@ describe('AssistantDatabase heartbeat persistence', () => {
})
])
const check = new DatabaseSync(path)
expect(
(
check.prepare('PRAGMA user_version').get() as {
user_version: number
}
).user_version
).toBe(7)
expect(
(
check
.prepare(
`SELECT COUNT(*) AS count FROM sqlite_master
WHERE type = 'table' AND name LIKE 'heartbeat_%'`
)
.get() as { count: number }
).count
).toBe(3)
const version = (
check.prepare('PRAGMA user_version').get() as {
user_version: number
}
).user_version
const heartbeatTableCount = (
check
.prepare(
`SELECT COUNT(*) AS count FROM sqlite_master
WHERE type = 'table' AND name LIKE 'heartbeat_%'`
)
.get() as { count: number }
).count
check.close()
migrated.close()
expect(version).toBe(8)
expect(heartbeatTableCount).toBe(3)
})
it('claims one scheduled run durably and advances local recurrence', async () => {
@@ -1,6 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setIntranetCompatibilityReader } from '../intranet-compatibility-policy'
import { RemoteDelegationService } from './remote-delegation-service'
beforeEach(() => {
setIntranetCompatibilityReader(() => false)
})
afterEach(() => {
setIntranetCompatibilityReader(() => true)
})
describe('RemoteDelegationService', () => {
it('polls a public HTTPS endpoint and posts a bounded result', async () => {
const transport = vi
@@ -22,7 +31,7 @@ describe('RemoteDelegationService', () => {
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
transport,
onTask
})
@@ -66,7 +75,7 @@ describe('RemoteDelegationService', () => {
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
transport,
onTask
})
@@ -122,7 +131,7 @@ describe('RemoteDelegationService', () => {
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
transport,
onTask,
outbox
@@ -140,7 +149,7 @@ describe('RemoteDelegationService', () => {
const service = new RemoteDelegationService({
endpoint: 'https://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '203.0.113.10', family: 4 }],
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
transport: async (_url, _address, _token, _method, signal) => {
observedSignal = signal
await new Promise<void>((_resolve, reject) => {
@@ -174,4 +183,94 @@ describe('RemoteDelegationService', () => {
await expect(service.pollOnce()).rejects.toThrow('私有或不安全网络')
})
it('allows pinned HTTP private endpoints in compatibility mode', async () => {
setIntranetCompatibilityReader(() => true)
const transport = vi.fn(async () => ({ status: 204, body: '' }))
const service = new RemoteDelegationService({
endpoint: 'http://delegate.internal',
token: 'test-token',
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
transport,
onTask: vi.fn()
})
await service.pollOnce()
expect(transport).toHaveBeenCalledWith(
expect.objectContaining({
protocol: 'http:',
pathname: '/goodbuddy/tasks/next'
}),
{ address: '10.20.30.40', family: 4 },
'test-token',
'GET',
expect.any(AbortSignal)
)
})
it('requires HTTPS for public endpoints even in compatibility mode', async () => {
setIntranetCompatibilityReader(() => true)
const transport = vi.fn()
const service = new RemoteDelegationService({
endpoint: 'http://delegate.example',
token: 'test-token',
lookup: async () => [{ address: '1.1.1.1', family: 4 }],
transport,
onTask: vi.fn()
})
await expect(service.pollOnce()).rejects.toThrow(
'HTTP 远程委派仅允许解析到内网地址'
)
expect(transport).not.toHaveBeenCalled()
})
it('keeps unsafe endpoints and mixed DNS answers blocked in compatibility mode', async () => {
setIntranetCompatibilityReader(() => true)
expect(
() =>
new RemoteDelegationService({
endpoint: 'http://metadata.google.internal',
token: 'test-token',
onTask: vi.fn()
})
).toThrow('元数据')
expect(
() =>
new RemoteDelegationService({
endpoint: 'http://user:secret@delegate.internal',
token: 'test-token',
onTask: vi.fn()
})
).toThrow('无凭据')
const mixed = new RemoteDelegationService({
endpoint: 'http://delegate.internal',
token: 'test-token',
lookup: async () => [
{ address: '10.20.30.40', family: 4 },
{ address: '1.1.1.1', family: 4 }
],
transport: vi.fn(),
onTask: vi.fn()
})
await expect(mixed.pollOnce()).rejects.toThrow('不安全网络')
})
it('re-applies strict transport policy after compatibility mode is disabled', async () => {
setIntranetCompatibilityReader(() => true)
const transport = vi.fn()
const service = new RemoteDelegationService({
endpoint: 'http://delegate.internal',
token: 'test-token',
lookup: async () => [{ address: '10.20.30.40', family: 4 }],
transport,
onTask: vi.fn()
})
setIntranetCompatibilityReader(() => false)
await expect(service.pollOnce()).rejects.toThrow('HTTPS')
expect(transport).not.toHaveBeenCalled()
})
})
+73 -10
View File
@@ -1,7 +1,13 @@
import { lookup as dnsLookup } from 'node:dns/promises'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { isIP } from 'node:net'
import { z } from 'zod'
import { isPublicAddress } from '../knowledge/url-importer'
import { isIntranetCompatibilityEnabled } from '../intranet-compatibility-policy'
import {
isIntranetAddress,
isPublicAddress
} from '../knowledge/url-importer'
const remoteTaskSchema = z
.object({
@@ -52,17 +58,39 @@ type RemoteDelegationOptions = {
}
}
const BLOCKED_REMOTE_HOSTS = new Set([
'instance-data',
'instance-data.ec2.internal',
'metadata',
'metadata.aws.internal',
'metadata.google.internal'
])
function normalizeEndpoint(input: string): URL {
const url = new URL(input.trim())
if (
url.protocol !== 'https:' ||
(
url.protocol !== 'https:' &&
(
url.protocol !== 'http:' ||
!isIntranetCompatibilityEnabled()
)
) ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '' && url.pathname !== '/')
) {
throw new Error('远程委派地址必须是无凭据和路径的 HTTPS origin')
throw new Error(
isIntranetCompatibilityEnabled()
? '远程委派地址必须是无凭据和路径的 HTTP(S) origin'
: '远程委派地址必须是无凭据和路径的 HTTPS origin'
)
}
const hostname = url.hostname.toLowerCase().replace(/\.$/u, '')
if (BLOCKED_REMOTE_HOSTS.has(hostname)) {
throw new Error('远程委派地址不允许访问云元数据服务')
}
return url
}
@@ -88,7 +116,7 @@ function defaultTransport(
settled = true
reject(error)
}
const request = httpsRequest(
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
url,
{
method,
@@ -103,7 +131,9 @@ function defaultTransport(
lookup: (_hostname, _options, callback) => {
callback(null, address.address, address.family)
},
servername: url.hostname,
...(url.protocol === 'https:'
? { servername: url.hostname }
: {}),
signal
},
(response) => {
@@ -187,7 +217,7 @@ export class RemoteDelegationService {
const controller = new AbortController()
this.activeRequest = controller
try {
const address = await this.resolvePublicAddress()
const address = await this.resolveAddress()
const durablePending = this.options.outbox?.listPending()[0]
const memoryPending = this.pendingResults.entries().next().value
const pending = durablePending
@@ -295,12 +325,45 @@ export class RemoteDelegationService {
}
}
private async resolvePublicAddress(): Promise<ResolvedAddress> {
private async resolveAddress(): Promise<ResolvedAddress> {
if (
this.endpoint.protocol === 'http:' &&
!isIntranetCompatibilityEnabled()
) {
throw new Error('远程委派地址必须使用 HTTPS')
}
const addresses = await this.lookup(this.endpoint.hostname)
const address = addresses.find((candidate) =>
isPublicAddress(candidate.address)
const addressTypes = addresses.map((candidate) =>
candidate.family !== isIP(candidate.address)
? 'blocked'
: isPublicAddress(candidate.address)
? 'public'
: isIntranetAddress(candidate.address)
? 'intranet'
: 'blocked'
)
if (!address || addresses.some((candidate) => !isPublicAddress(candidate.address))) {
const address = addresses[0]
const compatibilityEnabled = isIntranetCompatibilityEnabled()
const plaintextOutsideIntranet =
this.endpoint.protocol === 'http:' &&
addressTypes.some((addressType) => addressType !== 'intranet')
if (
!address ||
addressTypes.includes('blocked') ||
new Set(addressTypes).size !== 1 ||
plaintextOutsideIntranet ||
(
!compatibilityEnabled &&
addressTypes.some((addressType) => addressType !== 'public')
)
) {
if (
plaintextOutsideIntranet &&
!addressTypes.includes('blocked') &&
new Set(addressTypes).size === 1
) {
throw new Error('HTTP 远程委派仅允许解析到内网地址')
}
throw new Error('远程委派地址解析到私有或不安全网络')
}
return address
@@ -107,4 +107,57 @@ describe('SubagentService', () => {
)
await service.dispose()
})
it('uses an expert model profile and falls back to the default runtime', async () => {
const calls: string[] = []
const createRuntime = (label: string): AgentRuntime =>
({
run: async function* (request: AgentExecutionRequest) {
calls.push(label)
yield {
requestId: request.requestId,
type: 'text',
delta: label
} as const
yield { requestId: request.requestId, type: 'done' } as const
},
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
}) as unknown as AgentRuntime
const defaultRuntime = createRuntime('default')
const profileRuntime = createRuntime('profile')
const profileId = '00000000-0000-4000-8000-000000000002'
const service = new SubagentService(
defaultRuntime,
database() as never,
new SubagentScheduler({ timeoutMs: 1_000 }),
new Map([[profileId, profileRuntime]])
)
const selected = await service.run({
parentRequest,
expert: { ...expert, modelProfileId: profileId },
routingMode: 'manual',
signal: new AbortController().signal,
onEvent: vi.fn()
})
const fallback = await service.run({
parentRequest: {
...parentRequest,
requestId: '00000000-0000-4000-8000-000000000011'
},
expert: {
...expert,
modelProfileId: '00000000-0000-4000-8000-000000000099'
},
routingMode: 'manual',
signal: new AbortController().signal,
onEvent: vi.fn()
})
expect(selected.output).toBe('profile')
expect(fallback.output).toBe('default')
expect(calls).toEqual(['profile', 'default'])
await service.dispose()
})
})
+38 -6
View File
@@ -45,24 +45,53 @@ export class SubagentService {
constructor(
private runtime: AgentRuntime,
private readonly database: AssistantDatabase,
private readonly scheduler = new SubagentScheduler()
private readonly scheduler = new SubagentScheduler(),
private profileRuntimes: ReadonlyMap<string, AgentRuntime> =
new Map()
) {}
async replaceRuntime(runtime: AgentRuntime): Promise<void> {
if (runtime === this.runtime) {
await this.replaceRuntimes(runtime, new Map())
}
async replaceRuntimes(
runtime: AgentRuntime,
profileRuntimes: ReadonlyMap<string, AgentRuntime>
): Promise<void> {
const nextProfiles = new Map(profileRuntimes)
if (
runtime === this.runtime &&
nextProfiles.size === this.profileRuntimes.size &&
[...nextProfiles].every(
([profileId, profileRuntime]) =>
this.profileRuntimes.get(profileId) === profileRuntime
)
) {
return
}
this.scheduler.cancelAll(new Error('默认模型设置已更改'))
const previous = this.runtime
const previous = new Set([
this.runtime,
...this.profileRuntimes.values()
])
this.runtime = runtime
this.profileRuntimes = nextProfiles
await this.scheduler.waitForIdle()
await previous.dispose()
const retained = new Set([runtime, ...nextProfiles.values()])
await Promise.allSettled(
[...previous]
.filter((candidate) => !retained.has(candidate))
.map((candidate) => candidate.dispose())
)
}
async dispose(): Promise<void> {
this.scheduler.dispose()
await this.scheduler.waitForIdle()
await this.runtime.dispose()
await Promise.allSettled(
[...new Set([this.runtime, ...this.profileRuntimes.values()])]
.map((runtime) => runtime.dispose())
)
}
cancelAll(reason: string): void {
@@ -149,7 +178,10 @@ export class SubagentService {
started = true
this.database.updateTaskStatus(childTaskId, 'running')
this.emit(input, { childTaskId, state: 'running' })
const runtime = this.runtime
const runtime =
(input.expert.modelProfileId
? this.profileRuntimes.get(input.expert.modelProfileId)
: undefined) ?? this.runtime
let output = ''
let completed = false
try {