fix: reuse bundled agent runtime resources

This commit is contained in:
mesalogo
2026-09-07 16:57:59 +08:00
parent d3947c79dd
commit 19086d656e
52 changed files with 1657 additions and 867 deletions
+7 -1
View File
@@ -2,12 +2,13 @@ import { join } from 'node:path'
export type BundledRuntimePaths = {
opencode: string
opencodeConfig?: string
continue: string
deepseekHarness: string
}
export const bundledContinueVersion = '1.5.47'
export const bundledDeepSeekHarnessVersion = '0.1.0-rc.8'
export const bundledDeepSeekHarnessVersion = '0.1.2-rc.1'
export function resolveBundledRuntimePaths(input: {
appPath: string
@@ -27,6 +28,11 @@ export function resolveBundledRuntimePaths(input: {
'opencode',
packagedExecutable
),
opencodeConfig: join(
input.resourcesPath,
'runtimes',
'opencode-config'
),
continue: join(
input.resourcesPath,
'runtimes',
+9 -5
View File
@@ -44,13 +44,13 @@ import {
} from '../execution-space'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
const noSubagentTools: ModelToolProviderLike = {
const noModelTaskTools: ModelToolProviderLike = {
listTools: async () => [],
getApproval: () => {
throw new Error('子专家不允许工具调用')
throw new Error('此模型任务不允许工具调用')
},
callTool: async () => {
throw new Error('子专家不允许工具调用')
throw new Error('此模型任务不允许工具调用')
},
releaseConversation: async () => undefined,
dispose: async () => undefined
@@ -61,6 +61,7 @@ export type AgentCapabilityContext = {
skillPackages?: RuntimeSkillPackage[]
mcpServers?: ResolvedMcpServer[]
continueHostCacheRoot?: string
opencodeSharedCacheRoot?: string
bundledRuntimePaths?: BundledRuntimePaths
continueHostLauncher?: ContinueHostLauncher
deepseekHarnessLauncher?: DeepSeekHarnessRuntimeOptions['launch']
@@ -138,7 +139,7 @@ export function createDefaultModelRuntime(
settings,
currentProfile
),
toolProvider: noSubagentTools
toolProvider: noModelTaskTools
})
}
@@ -162,7 +163,7 @@ export function createModelProfileRuntime(
defaultRuntimeSettings.imageGenerationQuality,
contextCompression: resolveContextCompression(settings, profile),
defaultWorkspace: settings.workspacePath || defaultWorkspace,
toolProvider: noSubagentTools
toolProvider: noModelTaskTools
})
}
@@ -300,6 +301,8 @@ export function createAgentRuntime(
process.env.GOODBUDDY_OPENCODE_BINARY?.trim() ??
'',
bundledBinaryPath: capabilities.bundledRuntimePaths?.opencode,
bundledConfigPath:
capabilities.bundledRuntimePaths?.opencodeConfig,
configPath:
settings?.opencodeConfigPath ??
process.env.GOODBUDDY_OPENCODE_CONFIG?.trim() ??
@@ -307,6 +310,7 @@ export function createAgentRuntime(
modelProfile: settings?.opencodeModelProfile,
skillInstructions: capabilities.skillInstructions,
skillPackages: capabilities.skillPackages,
sharedCacheRoot: capabilities.opencodeSharedCacheRoot,
defaultWorkspace: workspace,
knowledgeGateway: capabilities.knowledgeGateway,
mcpServers: capabilities.mcpServers,
@@ -10,7 +10,7 @@ import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createCanvas } from '@napi-rs/canvas'
import {
CallId,
ToolCallId,
type GenerateOptions,
type StreamChunk
} from '@deepseek-ai/dsh-llm'
@@ -112,7 +112,7 @@ async function* toolCall(
name: string,
argumentsValue: Record<string, unknown>
): AsyncGenerator<StreamChunk> {
const id = CallId(callId)
const id = ToolCallId(callId)
const argumentsText = JSON.stringify(argumentsValue)
yield {
type: 'block-start',
@@ -332,7 +332,7 @@ function createInProcessLaunch(
model: options.model,
supportsImageInput: options.supportsImageInput,
requestHeaders: options.requestHeaders,
harnessVersion: '0.1.0-rc.8',
harnessVersion: '0.1.2-rc.1',
credentialRefs: options.credentialRefs,
skillPackages: options.skillPackages,
extensionPackages: options.extensionPackages,
@@ -159,7 +159,7 @@ function setup(
if (method === 'goodbuddy/handshake') {
return {
controlProtocolVersion: 1,
harnessVersion: '0.1.0-rc.8',
harnessVersion: '0.1.2-rc.1',
acpProtocolVersion: 1,
supports: {
cancellation: true,
@@ -95,7 +95,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.8',
harnessVersion: '0.1.2-rc.1',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [],
extensionPackages: extensions,
@@ -22,7 +22,7 @@ function controlPlane() {
provider: 'goodbuddy',
model: 'deepseek-test',
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.8',
harnessVersion: '0.1.2-rc.1',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: []
@@ -75,7 +75,7 @@ function stubAgentContext() {
provider: 'goodbuddy',
model: 'deepseek-test',
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.8',
harnessVersion: '0.1.2-rc.1',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: [],
@@ -172,7 +172,7 @@ describe('GoodBuddy Harness internal control plane', () => {
model: 'vision-test',
supportsImageInput: true,
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.8',
harnessVersion: '0.1.2-rc.1',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: []
@@ -20,6 +20,10 @@ import {
import {
CredentialProvider,
type CredentialInfo,
type CredentialKey,
type CredentialRecord,
type CredentialRecordEntry,
type CredentialRecordInfo,
type CredentialRef,
type ResolvedCredential
} from '@deepseek-ai/dsh-credentials'
@@ -292,6 +296,40 @@ export class GoodBuddyCredentialProvider extends CredentialProvider {
void ref
throw new Error('GoodBuddy Harness credentials are read-only')
}
async readRecord(
key: CredentialKey
): Promise<CredentialRecord | undefined> {
void key
return undefined
}
async describeRecord(
key: CredentialKey
): Promise<CredentialRecordInfo> {
void key
return { configured: false, writable: false }
}
async listRecords(): Promise<readonly CredentialRecordEntry[]> {
return []
}
async modifyRecord(
key: CredentialKey,
mutate: (
current: CredentialRecord | undefined
) => Promise<CredentialRecord | undefined>
): Promise<CredentialRecord | undefined> {
void key
void mutate
throw new Error('GoodBuddy Harness credentials are read-only')
}
async deleteRecord(key: CredentialKey): Promise<void> {
void key
throw new Error('GoodBuddy Harness credentials are read-only')
}
}
export function createBoundedAcpStream(
+134
View File
@@ -661,6 +661,140 @@ describe('OpenCodeRuntime embedded launcher', () => {
await expect(stat(registrationRoot)).rejects.toThrow()
})
it('reuses shared OpenCode config and content-addressed Skills across projects', async () => {
const sourceRoot = await mkdtemp(
join(tmpdir(), 'goodbuddy-opencode-shared-skill-source-')
)
const sharedCacheRoot = await mkdtemp(
join(tmpdir(), 'goodbuddy-opencode-shared-cache-')
)
const skillDirectory = join(sourceRoot, 'shared-skill')
const bundledConfigPath = join(sourceRoot, 'bundled-config')
await mkdir(skillDirectory, { recursive: true })
await mkdir(
join(
bundledConfigPath,
'node_modules',
'@opencode-ai',
'plugin'
),
{ recursive: true }
)
await writeFile(
join(bundledConfigPath, '.goodbuddy-ready.json'),
'{"version":"1.18.29"}\n',
'utf8'
)
await writeFile(
join(
bundledConfigPath,
'node_modules',
'@opencode-ai',
'plugin',
'package.json'
),
'{"name":"@opencode-ai/plugin","version":"1.18.29"}\n',
'utf8'
)
await writeFile(
join(skillDirectory, 'SKILL.md'),
[
'---',
'name: Shared Skill',
'description: Shared test Skill',
'---',
'',
'# Shared Skill'
].join('\n'),
'utf8'
)
const skillPackages = [
{
id: 'shared-skill',
directory: skillDirectory,
digest: 'shared-skill-digest'
}
]
const firstChild = fakeChild()
const first = dependencies(firstChild)
const firstRuntime = new OpenCodeRuntime(
options({
sharedCacheRoot,
skillPackages,
bundledConfigPath
}),
first.deps
)
const secondChild = fakeChild()
const second = dependencies(secondChild)
const secondRuntime = new OpenCodeRuntime(
options({
sharedCacheRoot,
skillPackages,
bundledConfigPath
}),
second.deps
)
try {
await expect(firstRuntime.testConnection()).resolves.toMatchObject({
available: true
})
const firstEnvironment = first.spawnMock.mock.calls[0]?.[2]?.env
const firstConfigDirectory =
firstEnvironment?.OPENCODE_CONFIG_DIR ?? ''
const firstConfig = JSON.parse(
firstEnvironment?.OPENCODE_CONFIG_CONTENT ?? '{}'
) as { skills?: { paths?: string[] } }
const firstSkillsRoot = firstConfig.skills?.paths?.[0] ?? ''
expect(firstConfigDirectory).toBe(
join(sharedCacheRoot, 'config', 'opencode')
)
expect(firstEnvironment?.XDG_CONFIG_HOME).toBe(
join(sharedCacheRoot, 'config')
)
await expect(
readFile(
join(
firstConfigDirectory,
'node_modules',
'@opencode-ai',
'plugin',
'package.json'
),
'utf8'
)
).resolves.toContain('1.18.29')
expect(firstEnvironment?.XDG_CACHE_HOME).toBe(
join(sharedCacheRoot, 'cache')
)
expect(firstEnvironment).toMatchObject({
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: '1'
})
await firstRuntime.dispose()
await expect(
readFile(join(firstSkillsRoot, 'shared-skill', 'SKILL.md'), 'utf8')
).resolves.toContain('name: shared-skill')
await expect(secondRuntime.testConnection()).resolves.toMatchObject({
available: true
})
const secondEnvironment = second.spawnMock.mock.calls[0]?.[2]?.env
const secondConfig = JSON.parse(
secondEnvironment?.OPENCODE_CONFIG_CONTENT ?? '{}'
) as { skills?: { paths?: string[] } }
expect(secondEnvironment?.OPENCODE_CONFIG_DIR).toBe(
firstConfigDirectory
)
expect(secondConfig.skills?.paths?.[0]).toBe(firstSkillsRoot)
} finally {
await firstRuntime.dispose()
await secondRuntime.dispose()
await rm(sourceRoot, { recursive: true, force: true })
await rm(sharedCacheRoot, { recursive: true, force: true })
}
})
it('injects an independent model profile without persisting its key', async () => {
const child = fakeChild()
const { deps, spawnMock } = dependencies(child)
+155 -10
View File
@@ -9,13 +9,17 @@ import {
import spawn from 'cross-spawn'
import { createHash, randomBytes } from 'node:crypto'
import {
cp,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
import type {
AgentQuestionAnswer,
@@ -173,6 +177,9 @@ type OpenCodeSkillRegistration = {
skillsRoot: string
}
const sharedSkillPreparations = new Map<string, Promise<string>>()
const sharedConfigPreparations = new Map<string, Promise<void>>()
const executePermissionRules: PermissionRuleset = [
{ permission: '*', pattern: '*', action: 'allow' }
]
@@ -499,11 +506,13 @@ export type OpenCodeRuntimeOptions = {
embedded: boolean
binaryPath: string
bundledBinaryPath?: string
bundledConfigPath?: string
configPath: string
defaultWorkspace: string
modelProfile?: ResolvedModelProfile
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
sharedCacheRoot?: string
knowledgeGateway?: KnowledgeMcpGateway
mcpServers?: ResolvedMcpServer[]
customization?: RuntimeCustomizationSettings['opencode']
@@ -851,21 +860,101 @@ export class OpenCodeRuntime implements AgentRuntime {
return ids
}
private async normalizeSkillPackages(skillsRoot: string): Promise<void> {
for (const skill of this.options.skillPackages ?? []) {
await normalizeOpenCodeSkillManifest(
join(skillsRoot, skill.id),
skill.id
)
}
}
private async prepareSharedSkills(sharedRoot: string): Promise<string> {
const packages = this.options.skillPackages ?? []
const fingerprint = createHash('sha256')
.update(
packages
.map((skill) => ({
id: skill.id,
digest: skill.digest ?? resolve(skill.directory)
}))
.sort((left, right) => left.id.localeCompare(right.id))
.map((skill) => `${skill.id}\0${skill.digest}`)
.join('\0')
)
.digest('hex')
const cacheRoot = join(sharedRoot, 'skills')
const target = join(cacheRoot, fingerprint)
const existing = sharedSkillPreparations.get(target)
if (existing) {
return existing
}
const preparation = (async () => {
try {
if ((await stat(target)).isDirectory()) {
return target
}
} catch {
// The immutable Skill snapshot has not been prepared yet.
}
await mkdir(cacheRoot, { recursive: true, mode: 0o700 })
const stagingRoot = await mkdtemp(join(cacheRoot, '.staging-'))
try {
const staged = await stageRuntimeSkillPackages(
stagingRoot,
packages,
'OpenCode'
)
await this.normalizeSkillPackages(staged)
try {
await rename(staged, target)
} catch (error) {
try {
if ((await stat(target)).isDirectory()) {
return target
}
} catch {
// Preserve the original rename failure below.
}
throw error
}
return target
} finally {
await rm(stagingRoot, { recursive: true, force: true })
}
})()
sharedSkillPreparations.set(target, preparation)
try {
return await preparation
} catch (error) {
sharedSkillPreparations.delete(target)
throw error
}
}
private async createSkillRegistration(): Promise<OpenCodeSkillRegistration> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-'))
const configDirectory = join(root, 'config')
try {
const sharedRoot = this.options.sharedCacheRoot?.trim()
if (sharedRoot) {
const configDirectory = join(
resolve(sharedRoot),
'config',
'opencode'
)
await this.prepareSharedConfig(configDirectory)
const skillsRoot = await this.prepareSharedSkills(
resolve(sharedRoot)
)
return { root, configDirectory, skillsRoot }
}
const configDirectory = join(root, 'config')
const skillsRoot = await stageRuntimeSkillPackages(
configDirectory,
this.options.skillPackages ?? [],
'OpenCode'
)
for (const skill of this.options.skillPackages ?? []) {
await normalizeOpenCodeSkillManifest(
join(skillsRoot, skill.id),
skill.id
)
}
await this.normalizeSkillPackages(skillsRoot)
return { root, configDirectory, skillsRoot }
} catch (error) {
await rm(root, { recursive: true, force: true })
@@ -873,6 +962,57 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
private async prepareSharedConfig(
configDirectory: string
): Promise<void> {
const bundledConfigPath = this.options.bundledConfigPath?.trim()
if (!bundledConfigPath) {
await mkdir(configDirectory, { recursive: true, mode: 0o700 })
return
}
const target = resolve(configDirectory)
const existing = sharedConfigPreparations.get(target)
if (existing) {
return existing
}
const preparation = (async () => {
const source = resolve(bundledConfigPath)
const markerName = '.goodbuddy-ready.json'
const sourceMarker = await readFile(join(source, markerName), 'utf8')
try {
if (
(await readFile(join(target, markerName), 'utf8')) ===
sourceMarker
) {
return
}
} catch {
// Install the bundled dependency tree below.
}
await mkdir(dirname(target), { recursive: true, mode: 0o700 })
const staging = `${target}.staging-${process.pid}-${randomBytes(6).toString('hex')}`
await rm(staging, { recursive: true, force: true })
try {
await cp(source, staging, {
recursive: true,
errorOnExist: true,
force: false
})
await rm(target, { recursive: true, force: true })
await rename(staging, target)
} finally {
await rm(staging, { recursive: true, force: true })
}
})()
sharedConfigPreparations.set(target, preparation)
try {
await preparation
} catch (error) {
sharedConfigPreparations.delete(target)
throw error
}
}
private async inspectEmbeddedConfiguration(
validatePath = false
): Promise<{
@@ -957,12 +1097,17 @@ export class OpenCodeRuntime implements AgentRuntime {
env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = '1'
env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1'
env.OPENCODE_DISABLE_EXTERNAL_SKILLS = '1'
env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = '1'
env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1'
env.OPENCODE_DISABLE_MODELS_FETCH = '1'
env.OPENCODE_DISABLE_PROJECT_CONFIG = '1'
env.OPENCODE_DISABLE_SHARE = '1'
env.XDG_CACHE_HOME = join(registration.root, 'xdg-cache')
env.XDG_CONFIG_HOME = join(registration.root, 'xdg-config')
env.XDG_CACHE_HOME = this.options.sharedCacheRoot?.trim()
? join(resolve(this.options.sharedCacheRoot), 'cache')
: join(registration.root, 'xdg-cache')
env.XDG_CONFIG_HOME = this.options.sharedCacheRoot?.trim()
? dirname(registration.configDirectory)
: join(registration.root, 'xdg-config')
env.XDG_DATA_HOME = join(registration.root, 'xdg-data')
env.XDG_STATE_HOME = join(registration.root, 'xdg-state')
const skillConfig = createOpenCodeSkillConfig(
+2 -2
View File
@@ -215,7 +215,7 @@ describe('runtime discovery', () => {
binaryPath: '',
bundledPath: bundledScript,
bundledValidation: 'canonical-file',
bundledVersion: '0.1.0-rc.8',
bundledVersion: '0.1.2-rc.1',
binaryNames: [],
label: 'GoodBuddy DeepSeek Harness Host'
})
@@ -223,7 +223,7 @@ describe('runtime discovery', () => {
expect(detection).toMatchObject({
available: true,
path: await realpath(bundledScript),
version: '0.1.0-rc.8',
version: '0.1.2-rc.1',
source: 'bundled'
})
expect(detection.detail).toContain('内置')