feat: add natural language configuration tools
This commit is contained in:
@@ -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