feat: add natural language configuration tools
This commit is contained in:
@@ -579,6 +579,12 @@ describe('ContinueHostAdapter', () => {
|
||||
'note_get',
|
||||
'--allow',
|
||||
'note_search',
|
||||
'--allow',
|
||||
'goodbuddy_config_capabilities',
|
||||
'--allow',
|
||||
'goodbuddy_config_get',
|
||||
'--allow',
|
||||
'goodbuddy_config_plan',
|
||||
'--exclude',
|
||||
'*',
|
||||
'serve',
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from './approval-summary'
|
||||
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
||||
import { readBoundedResponseText } from './bounded-response'
|
||||
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
|
||||
|
||||
const supportedVersion = '1.5.47'
|
||||
const supportedBundleHashes = new Set([
|
||||
@@ -1058,20 +1059,10 @@ export class ContinueHostAdapter {
|
||||
runOptions.workMode === 'ask' &&
|
||||
runOptions.knowledgeCapability
|
||||
) {
|
||||
args.push(
|
||||
'--allow',
|
||||
'knowledge_list',
|
||||
'--allow',
|
||||
'knowledge_search',
|
||||
'--allow',
|
||||
'note_list',
|
||||
'--allow',
|
||||
'note_get',
|
||||
'--allow',
|
||||
'note_search',
|
||||
'--exclude',
|
||||
'*'
|
||||
)
|
||||
for (const toolName of scopedReadToolNames) {
|
||||
args.push('--allow', toolName)
|
||||
}
|
||||
args.push('--exclude', '*')
|
||||
} else if (runOptions.workMode === 'execute') {
|
||||
args.push('--auto')
|
||||
} else if (this.options.mode === 'chat') {
|
||||
|
||||
@@ -78,6 +78,78 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('KnowledgeMcpGateway', () => {
|
||||
it('exposes GoodBuddy config reads in Ask and apply only in Execute', async () => {
|
||||
const { service } = createService()
|
||||
const configService = {
|
||||
getCapabilities: vi.fn(() => ({ server: 'goodbuddy_config' })),
|
||||
getSnapshot: vi.fn(async () => ({ application: {}, skills: [], mcpServers: [] })),
|
||||
plan: vi.fn(async () => ({ planId: 'plan' })),
|
||||
apply: vi.fn(async () => ({ status: 'applied' })),
|
||||
revokeRequest: vi.fn()
|
||||
}
|
||||
const gateway = new KnowledgeMcpGateway(service, {
|
||||
configService: configService as never
|
||||
})
|
||||
gateways.push(gateway)
|
||||
const readToken = gateway.grant(
|
||||
'config-read',
|
||||
[],
|
||||
new AbortController().signal,
|
||||
'none',
|
||||
{ access: 'read', workspacePath: process.cwd() }
|
||||
)!
|
||||
const authorizeApply = vi.fn(async () => true)
|
||||
const writeToken = gateway.grant(
|
||||
'config-write',
|
||||
[],
|
||||
new AbortController().signal,
|
||||
'none',
|
||||
{
|
||||
access: 'write',
|
||||
workspacePath: process.cwd(),
|
||||
authorizeApply
|
||||
}
|
||||
)!
|
||||
|
||||
expect(gateway.getAvailableToolNames(readToken)).toEqual([
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan'
|
||||
])
|
||||
expect(gateway.getAvailableToolNames(writeToken)).toEqual([
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan',
|
||||
'goodbuddy_config_apply'
|
||||
])
|
||||
await gateway.callGoodBuddyConfigTool(
|
||||
readToken,
|
||||
'goodbuddy_config_capabilities',
|
||||
{}
|
||||
)
|
||||
expect(configService.getCapabilities).toHaveBeenCalledWith({})
|
||||
await expect(
|
||||
gateway.callGoodBuddyConfigTool(
|
||||
readToken,
|
||||
'goodbuddy_config_apply',
|
||||
{ planId: crypto.randomUUID() }
|
||||
)
|
||||
).rejects.toThrow('unavailable')
|
||||
await gateway.callGoodBuddyConfigTool(
|
||||
writeToken,
|
||||
'goodbuddy_config_apply',
|
||||
{ planId: crypto.randomUUID() }
|
||||
)
|
||||
expect(configService.apply).toHaveBeenCalledWith(
|
||||
'config-write',
|
||||
expect.any(Object),
|
||||
expect.any(AbortSignal),
|
||||
authorizeApply
|
||||
)
|
||||
gateway.revoke(writeToken)
|
||||
expect(configService.revokeRequest).toHaveBeenCalledWith('config-write')
|
||||
})
|
||||
|
||||
it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => {
|
||||
const { service, searchHybridMany } = createService()
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
|
||||
@@ -12,12 +12,15 @@ import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||
import {
|
||||
knowledgeToolNames,
|
||||
knowledgeScopedDataToolCatalog,
|
||||
goodbuddyConfigReadToolNames,
|
||||
goodbuddyConfigWriteToolNames,
|
||||
magicNoteScopedDataToolCatalog,
|
||||
magicNoteReadToolNames,
|
||||
magicNoteWriteToolNames,
|
||||
maximumScopedToolCount,
|
||||
scopedDataToolByName,
|
||||
scopedReadToolNames,
|
||||
type GoodBuddyConfigToolName,
|
||||
type ScopedDataToolName
|
||||
} from '../../shared/scoped-data-tools'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
@@ -32,6 +35,10 @@ import {
|
||||
magicNotePlainText,
|
||||
validateMagicNoteRichContent
|
||||
} from '../magic-notes/rich-content'
|
||||
import type {
|
||||
GoodBuddyConfigApplyAuthorizer,
|
||||
GoodBuddyConfigService
|
||||
} from '../goodbuddy-config-service'
|
||||
|
||||
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||
const MAX_RESULT_BYTES = 128 * 1024
|
||||
@@ -128,6 +135,9 @@ type Capability = {
|
||||
requestId: string
|
||||
libraryIds: readonly string[]
|
||||
magicNotesAccess: MagicNotesCapabilityAccess
|
||||
configAccess: MagicNotesCapabilityAccess
|
||||
configWorkspacePath?: string
|
||||
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
||||
expiresAt: number
|
||||
signal: AbortSignal
|
||||
references: Map<string, KnowledgeSearchReference>
|
||||
@@ -139,6 +149,7 @@ export type KnowledgeMcpGatewayOptions = {
|
||||
maximumBodyBytes?: number
|
||||
now?: () => number
|
||||
magicNotesDatabase?: MagicNotesDatabase
|
||||
configService?: GoodBuddyConfigService
|
||||
}
|
||||
|
||||
function toMagicNoteToolSummary(
|
||||
@@ -224,6 +235,7 @@ export class KnowledgeMcpGateway {
|
||||
private readonly capabilityTtlMs: number
|
||||
private readonly maximumBodyBytes: number
|
||||
private readonly magicNotesDatabase?: MagicNotesDatabase
|
||||
private readonly configService?: GoodBuddyConfigService
|
||||
private server?: Server
|
||||
private endpoint?: string
|
||||
|
||||
@@ -244,6 +256,7 @@ export class KnowledgeMcpGateway {
|
||||
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
|
||||
this.now = options.now ?? Date.now
|
||||
this.magicNotesDatabase = options.magicNotesDatabase
|
||||
this.configService = options.configService
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -289,14 +302,23 @@ export class KnowledgeMcpGateway {
|
||||
requestId: string,
|
||||
authorizedLibraryIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
magicNotesAccess: MagicNotesCapabilityAccess = 'none'
|
||||
magicNotesAccess: MagicNotesCapabilityAccess = 'none',
|
||||
config?: {
|
||||
access: MagicNotesCapabilityAccess
|
||||
workspacePath: string
|
||||
authorizeApply?: GoodBuddyConfigApplyAuthorizer
|
||||
}
|
||||
): string | undefined {
|
||||
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
||||
? magicNotesAccess
|
||||
: 'none'
|
||||
const effectiveConfigAccess = this.configService
|
||||
? config?.access ?? 'none'
|
||||
: 'none'
|
||||
if (
|
||||
authorizedLibraryIds.length === 0 &&
|
||||
effectiveMagicNotesAccess === 'none'
|
||||
effectiveMagicNotesAccess === 'none' &&
|
||||
effectiveConfigAccess === 'none'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
@@ -311,6 +333,13 @@ export class KnowledgeMcpGateway {
|
||||
requestId,
|
||||
libraryIds,
|
||||
magicNotesAccess: effectiveMagicNotesAccess,
|
||||
configAccess: effectiveConfigAccess,
|
||||
...(effectiveConfigAccess !== 'none'
|
||||
? {
|
||||
configWorkspacePath: config?.workspacePath,
|
||||
authorizeConfigApply: config?.authorizeApply
|
||||
}
|
||||
: {}),
|
||||
expiresAt: this.now() + this.capabilityTtlMs,
|
||||
signal,
|
||||
references: new Map(),
|
||||
@@ -330,6 +359,7 @@ export class KnowledgeMcpGateway {
|
||||
}
|
||||
capability.removeAbortListener()
|
||||
this.capabilities.delete(token)
|
||||
this.configService?.revokeRequest(capability.requestId)
|
||||
}
|
||||
|
||||
drainReferences(
|
||||
@@ -474,10 +504,81 @@ export class KnowledgeMcpGateway {
|
||||
: []),
|
||||
...(capability.magicNotesAccess === 'write'
|
||||
? magicNoteWriteToolNames
|
||||
: []),
|
||||
...(capability.configAccess !== 'none'
|
||||
? goodbuddyConfigReadToolNames
|
||||
: []),
|
||||
...(capability.configAccess === 'write'
|
||||
? goodbuddyConfigWriteToolNames
|
||||
: [])
|
||||
]
|
||||
}
|
||||
|
||||
private requireConfig(
|
||||
token: string,
|
||||
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
||||
): {
|
||||
capability: Capability
|
||||
service: GoodBuddyConfigService
|
||||
workspacePath: string
|
||||
} {
|
||||
const capability = this.getCapability(token)
|
||||
const allowed =
|
||||
capability.configAccess === 'write' ||
|
||||
(requiredAccess === 'read' && capability.configAccess === 'read')
|
||||
if (
|
||||
!allowed ||
|
||||
!this.configService ||
|
||||
!capability.configWorkspacePath
|
||||
) {
|
||||
throw new Error('GoodBuddy configuration capability is unavailable')
|
||||
}
|
||||
return {
|
||||
capability,
|
||||
service: this.configService,
|
||||
workspacePath: capability.configWorkspacePath
|
||||
}
|
||||
}
|
||||
|
||||
async callGoodBuddyConfigTool(
|
||||
token: string,
|
||||
name: GoodBuddyConfigToolName,
|
||||
input: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
const requiredAccess =
|
||||
name === 'goodbuddy_config_apply' ? 'write' : 'read'
|
||||
const { capability, service, workspacePath } =
|
||||
this.requireConfig(token, requiredAccess)
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, capability.signal])
|
||||
: capability.signal
|
||||
effectiveSignal.throwIfAborted()
|
||||
switch (name) {
|
||||
case 'goodbuddy_config_capabilities':
|
||||
return { capabilities: service.getCapabilities(input) }
|
||||
case 'goodbuddy_config_get':
|
||||
return { config: await service.getSnapshot(input) }
|
||||
case 'goodbuddy_config_plan':
|
||||
return {
|
||||
plan: await service.plan(
|
||||
capability.requestId,
|
||||
workspacePath,
|
||||
input
|
||||
)
|
||||
}
|
||||
case 'goodbuddy_config_apply':
|
||||
return {
|
||||
result: await service.apply(
|
||||
capability.requestId,
|
||||
input,
|
||||
effectiveSignal,
|
||||
capability.authorizeConfigApply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireMagicNotes(
|
||||
token: string,
|
||||
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
||||
@@ -686,6 +787,11 @@ export class KnowledgeMcpGateway {
|
||||
return { note: this.deleteMagicNoteEntry(token, input) }
|
||||
case 'note_delete':
|
||||
return this.deleteMagicNote(token, input)
|
||||
case 'goodbuddy_config_capabilities':
|
||||
case 'goodbuddy_config_get':
|
||||
case 'goodbuddy_config_plan':
|
||||
case 'goodbuddy_config_apply':
|
||||
return this.callGoodBuddyConfigTool(token, name, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,7 +856,14 @@ export class KnowledgeMcpGateway {
|
||||
{
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
inputSchema: definition.inputSchema.shape
|
||||
inputSchema: definition.inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: definition.access === 'read',
|
||||
destructiveHint:
|
||||
name === 'goodbuddy_config_apply' ||
|
||||
name === 'note_delete' ||
|
||||
name === 'note_entry_delete'
|
||||
}
|
||||
},
|
||||
async (input: Record<string, unknown>) => ({
|
||||
content: [
|
||||
|
||||
@@ -215,6 +215,9 @@ describe('ModelToolProvider', () => {
|
||||
const createMagicNote = vi.fn(() => ({
|
||||
id: '00000000-0000-4000-8000-000000000701'
|
||||
}))
|
||||
const callGoodBuddyConfigTool = vi.fn(async () => ({
|
||||
capabilities: { server: 'goodbuddy_config' }
|
||||
}))
|
||||
const gateway = {
|
||||
listLibraries,
|
||||
search,
|
||||
@@ -222,6 +225,7 @@ describe('ModelToolProvider', () => {
|
||||
listMagicNotes,
|
||||
getMagicNote,
|
||||
createMagicNote,
|
||||
callGoodBuddyConfigTool,
|
||||
getAvailableToolNames: vi.fn(() => [
|
||||
'knowledge_list',
|
||||
'knowledge_search',
|
||||
@@ -233,7 +237,11 @@ describe('ModelToolProvider', () => {
|
||||
'note_entry_create',
|
||||
'note_entry_update',
|
||||
'note_entry_delete',
|
||||
'note_delete'
|
||||
'note_delete',
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan',
|
||||
'goodbuddy_config_apply'
|
||||
])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const provider = new ModelToolProvider(
|
||||
@@ -255,7 +263,10 @@ describe('ModelToolProvider', () => {
|
||||
'knowledge_search',
|
||||
'note_list',
|
||||
'note_get',
|
||||
'note_search'
|
||||
'note_search',
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan'
|
||||
])
|
||||
expect(
|
||||
JSON.stringify(
|
||||
@@ -306,6 +317,18 @@ describe('ModelToolProvider', () => {
|
||||
expect(getMagicNote).toHaveBeenCalledWith('main-only-token', {
|
||||
noteId: '00000000-0000-4000-8000-000000000701'
|
||||
})
|
||||
await provider.callTool(
|
||||
'goodbuddy_config_capabilities',
|
||||
{},
|
||||
signal,
|
||||
askContext
|
||||
)
|
||||
expect(callGoodBuddyConfigTool).toHaveBeenCalledWith(
|
||||
'main-only-token',
|
||||
'goodbuddy_config_capabilities',
|
||||
{},
|
||||
signal
|
||||
)
|
||||
|
||||
await expect(
|
||||
provider.listTools(
|
||||
@@ -333,7 +356,11 @@ describe('ModelToolProvider', () => {
|
||||
'note_entry_create',
|
||||
'note_entry_update',
|
||||
'note_entry_delete',
|
||||
'note_delete'
|
||||
'note_delete',
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan',
|
||||
'goodbuddy_config_apply'
|
||||
])
|
||||
)
|
||||
await provider.callTool(
|
||||
@@ -375,6 +402,20 @@ describe('ModelToolProvider', () => {
|
||||
allowPermanent: false,
|
||||
description: expect.stringContaining('永久删除')
|
||||
})
|
||||
const configApplyTool = executeTools.find(
|
||||
(tool) => tool.name === 'goodbuddy_config_apply'
|
||||
)!
|
||||
expect(
|
||||
provider.getApproval(
|
||||
configApplyTool,
|
||||
{ planId: '00000000-0000-4000-8000-000000000702' },
|
||||
'{"planId":"00000000-0000-4000-8000-000000000702"}',
|
||||
{ ...askContext, workMode: 'execute' }
|
||||
)
|
||||
).toMatchObject({
|
||||
scopeKey: 'model:goodbuddy-config:apply',
|
||||
allowPermanent: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reserves all scoped data tool slots for Execute', async () => {
|
||||
@@ -394,7 +435,11 @@ describe('ModelToolProvider', () => {
|
||||
'note_entry_create',
|
||||
'note_entry_update',
|
||||
'note_entry_delete',
|
||||
'note_delete'
|
||||
'note_delete',
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan',
|
||||
'goodbuddy_config_apply'
|
||||
])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const context = {
|
||||
@@ -414,7 +459,7 @@ describe('ModelToolProvider', () => {
|
||||
}))
|
||||
|
||||
mocks.client.listTools.mockResolvedValueOnce({
|
||||
tools: createTools(86)
|
||||
tools: createTools(82)
|
||||
})
|
||||
const validProvider = new ModelToolProvider(
|
||||
workspace,
|
||||
@@ -428,7 +473,7 @@ describe('ModelToolProvider', () => {
|
||||
await validProvider.dispose()
|
||||
|
||||
mocks.client.listTools.mockResolvedValueOnce({
|
||||
tools: createTools(87)
|
||||
tools: createTools(83)
|
||||
})
|
||||
const overflowingProvider = new ModelToolProvider(
|
||||
workspace,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isIP } from 'node:net'
|
||||
import { z } from 'zod'
|
||||
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
import {
|
||||
goodbuddyConfigWriteToolNames,
|
||||
magicNoteWriteToolNames,
|
||||
maximumScopedToolCount,
|
||||
scopedDataToolByName,
|
||||
@@ -78,6 +79,9 @@ const webFetchTool = builtinModelTools.find(
|
||||
const magicNoteWriteToolNameSet = new Set<string>(
|
||||
magicNoteWriteToolNames
|
||||
)
|
||||
const goodbuddyConfigWriteToolNameSet = new Set<string>(
|
||||
goodbuddyConfigWriteToolNames
|
||||
)
|
||||
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
|
||||
const scopedToolJsonSchemas = new Map(
|
||||
[...scopedDataToolByName].map(([name, definition]) => {
|
||||
@@ -543,7 +547,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
return [
|
||||
{
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
displayName:
|
||||
'displayName' in definition
|
||||
? definition.displayName
|
||||
: definition.title,
|
||||
description: definition.description,
|
||||
inputSchema,
|
||||
source: 'builtin'
|
||||
@@ -1047,6 +1054,17 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
allowPermanent: false
|
||||
}
|
||||
}
|
||||
if (goodbuddyConfigWriteToolNameSet.has(tool.name)) {
|
||||
return {
|
||||
scopeKey: 'model:goodbuddy-config:apply',
|
||||
title: '允许应用 GoodBuddy 配置计划?',
|
||||
description:
|
||||
'该操作会修改 GoodBuddy 应用偏好或扩展能力。主进程还会显示计划中的具体变更并再次要求确认。',
|
||||
toolName: tool.displayName,
|
||||
argumentSummary,
|
||||
allowPermanent: false
|
||||
}
|
||||
}
|
||||
if (tool.name === 'web_search' || tool.name === 'web_fetch') {
|
||||
return {
|
||||
scopeKey: `model:web:${tool.name}`,
|
||||
@@ -1295,6 +1313,30 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
)
|
||||
)
|
||||
}
|
||||
if (
|
||||
name === 'goodbuddy_config_capabilities' ||
|
||||
name === 'goodbuddy_config_get' ||
|
||||
name === 'goodbuddy_config_plan' ||
|
||||
name === 'goodbuddy_config_apply'
|
||||
) {
|
||||
if (
|
||||
!this.knowledgeGateway ||
|
||||
!context.knowledgeCapabilityToken
|
||||
) {
|
||||
throw new Error('GoodBuddy 配置授权不可用')
|
||||
}
|
||||
return createTextToolResult(
|
||||
boundedJson(
|
||||
await this.knowledgeGateway.callGoodBuddyConfigTool(
|
||||
context.knowledgeCapabilityToken,
|
||||
name,
|
||||
argumentsValue,
|
||||
signal
|
||||
),
|
||||
'GoodBuddy 配置工具结果无法序列化'
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'web_search' || name === 'web_fetch') {
|
||||
try {
|
||||
const binding = (await this.getWebSearchBindings(signal)).get(name)
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { modelProtocolSchema } from '../../shared/contracts'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { ModelAgentRuntime } from './model-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import type {
|
||||
ModelToolCallContext,
|
||||
ModelToolDefinition,
|
||||
ModelToolProviderLike,
|
||||
ModelToolResult
|
||||
} from './model-tool-provider'
|
||||
import { GoodBuddyConfigService } from '../goodbuddy-config-service'
|
||||
import { ApplicationSettingsStore } from '../application-settings-store'
|
||||
import {
|
||||
BrowserProfileService,
|
||||
MemoryBrowserProfileStore
|
||||
} from '../capabilities/browser-profile-service'
|
||||
import {
|
||||
CapabilityService,
|
||||
type CapabilityCipher
|
||||
} from '../capabilities/capability-service'
|
||||
import {
|
||||
goodbuddyConfigToolByName,
|
||||
goodbuddyConfigTools
|
||||
} from '../../shared/goodbuddy-config-tools'
|
||||
|
||||
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY ?? ''
|
||||
const apiKey =
|
||||
process.env.GOODBUDDY_E2E_API_KEY ??
|
||||
process.env.ANTHROPIC_API_KEY ??
|
||||
''
|
||||
const configuredBaseUrl =
|
||||
process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com'
|
||||
const baseUrl = new URL(configuredBaseUrl).origin
|
||||
process.env.GOODBUDDY_E2E_BASE_URL ??
|
||||
process.env.ANTHROPIC_BASE_URL ??
|
||||
'https://api.anthropic.com'
|
||||
const configuredUrl = new URL(configuredBaseUrl)
|
||||
configuredUrl.search = ''
|
||||
configuredUrl.hash = ''
|
||||
const baseUrl = configuredUrl.toString().replace(/\/$/u, '')
|
||||
const modelName =
|
||||
process.env.GOODBUDDY_E2E_MODEL ?? 'claude-sonnet-5'
|
||||
const protocol = modelProtocolSchema
|
||||
.exclude(['openai-images-generations'])
|
||||
.parse(
|
||||
process.env.GOODBUDDY_E2E_PROTOCOL ?? 'anthropic-messages'
|
||||
)
|
||||
const portableRoot = join(
|
||||
process.cwd(),
|
||||
'dist',
|
||||
@@ -33,6 +68,100 @@ async function collectText(
|
||||
return output
|
||||
}
|
||||
|
||||
function textResult(value: unknown): ModelToolResult {
|
||||
const text = JSON.stringify(value)
|
||||
return {
|
||||
parts: [{ type: 'text', text }],
|
||||
contextBytes: Buffer.byteLength(text)
|
||||
}
|
||||
}
|
||||
|
||||
class RealModelConfigToolProvider implements ModelToolProviderLike {
|
||||
readonly calls: string[] = []
|
||||
private planId?: string
|
||||
|
||||
constructor(
|
||||
private readonly service: GoodBuddyConfigService,
|
||||
private readonly workspacePath: string,
|
||||
private readonly requestId: string
|
||||
) {}
|
||||
|
||||
async listTools(
|
||||
context: ModelToolCallContext
|
||||
): Promise<ModelToolDefinition[]> {
|
||||
return goodbuddyConfigTools
|
||||
.filter(
|
||||
(tool) =>
|
||||
context.workMode === 'execute' || tool.access === 'read'
|
||||
)
|
||||
.map((tool) => {
|
||||
const schema = z.toJSONSchema(tool.inputSchema, {
|
||||
target: 'draft-7'
|
||||
}) as Record<string, unknown>
|
||||
Reflect.deleteProperty(schema, '$schema')
|
||||
return {
|
||||
name: tool.name,
|
||||
displayName: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: schema,
|
||||
source: 'builtin'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getApproval() {
|
||||
return {
|
||||
scopeKey: 'real-model-config-test',
|
||||
title: 'Unexpected config write',
|
||||
description: 'Real config discovery test must not apply changes',
|
||||
allowPermanent: false
|
||||
}
|
||||
}
|
||||
|
||||
async callTool(
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>,
|
||||
signal: AbortSignal
|
||||
): Promise<ModelToolResult> {
|
||||
signal.throwIfAborted()
|
||||
this.calls.push(name)
|
||||
const tool = goodbuddyConfigToolByName.get(
|
||||
name as Parameters<typeof goodbuddyConfigToolByName.get>[0]
|
||||
)
|
||||
if (!tool) {
|
||||
throw new Error(`Unexpected tool: ${name}`)
|
||||
}
|
||||
switch (name) {
|
||||
case 'goodbuddy_config_capabilities':
|
||||
return textResult({
|
||||
capabilities: this.service.getCapabilities(argumentsValue)
|
||||
})
|
||||
case 'goodbuddy_config_get':
|
||||
return textResult({
|
||||
config: await this.service.getSnapshot(argumentsValue)
|
||||
})
|
||||
case 'goodbuddy_config_plan': {
|
||||
const plan = await this.service.plan(
|
||||
this.requestId,
|
||||
this.workspacePath,
|
||||
argumentsValue
|
||||
)
|
||||
this.planId = plan.planId
|
||||
return textResult({ plan })
|
||||
}
|
||||
default:
|
||||
throw new Error('Apply is forbidden in the real discovery test')
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation(): Promise<void> {}
|
||||
async dispose(): Promise<void> {}
|
||||
|
||||
getPlannedId(): string | undefined {
|
||||
return this.planId
|
||||
}
|
||||
}
|
||||
|
||||
describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
let workspace = ''
|
||||
|
||||
@@ -57,7 +186,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol: 'anthropic-messages',
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
})
|
||||
|
||||
@@ -82,6 +211,85 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
120_000
|
||||
)
|
||||
|
||||
it(
|
||||
'discovers and plans GoodBuddy configuration through a real model',
|
||||
async () => {
|
||||
const testRoot = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-config-model-e2e-')
|
||||
)
|
||||
const builtinSkillsRoot = join(testRoot, 'builtin-skills')
|
||||
const importedSkillsRoot = join(testRoot, 'imported-skills')
|
||||
await mkdir(builtinSkillsRoot, { recursive: true })
|
||||
const cipher: CapabilityCipher = {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(value),
|
||||
decrypt: (value) => value.toString()
|
||||
}
|
||||
const configService = new GoodBuddyConfigService(
|
||||
new ApplicationSettingsStore(join(testRoot, 'application.json')),
|
||||
new CapabilityService(
|
||||
join(testRoot, 'capabilities.json'),
|
||||
builtinSkillsRoot,
|
||||
importedSkillsRoot,
|
||||
cipher,
|
||||
{
|
||||
browserProfiles: new BrowserProfileService(
|
||||
new MemoryBrowserProfileStore()
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
const requestId = crypto.randomUUID()
|
||||
const toolProvider = new RealModelConfigToolProvider(
|
||||
configService,
|
||||
workspace,
|
||||
requestId
|
||||
)
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key',
|
||||
defaultWorkspace: workspace,
|
||||
toolProvider
|
||||
})
|
||||
|
||||
try {
|
||||
const output = await collectText(
|
||||
runtime.run(
|
||||
{
|
||||
requestId,
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'execute',
|
||||
prompt:
|
||||
'Use GoodBuddy configuration tools. First discover capabilities and examples, then read the sanitized current configuration, then create (but do not apply) a plan that sets checkUpdatesOnStartup to false. Finish with CONFIG_PLAN_OK and the plan risk. Never call apply.'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async (event) =>
|
||||
event.toolName === 'goodbuddy_config_apply'
|
||||
? 'deny'
|
||||
: 'once'
|
||||
)
|
||||
)
|
||||
expect(toolProvider.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan'
|
||||
])
|
||||
)
|
||||
expect(toolProvider.calls).not.toContain('goodbuddy_config_apply')
|
||||
expect(toolProvider.getPlannedId()).toBeDefined()
|
||||
expect(output).toContain('CONFIG_PLAN_OK')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(testRoot, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
120_000
|
||||
)
|
||||
|
||||
it(
|
||||
'cancels an in-flight direct model task',
|
||||
async () => {
|
||||
@@ -89,7 +297,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol: 'anthropic-messages',
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
})
|
||||
const abortController = new AbortController()
|
||||
@@ -140,7 +348,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
baseUrl,
|
||||
modelName,
|
||||
apiKey,
|
||||
protocol: 'anthropic-messages',
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
}
|
||||
})
|
||||
@@ -203,7 +411,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
baseUrl,
|
||||
modelName,
|
||||
apiKey,
|
||||
protocol: 'anthropic-messages',
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user