feat: expand secure assistant workflows

Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 10:04:59 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6ef1795b81
commit b3fdf96962
82 changed files with 17608 additions and 825 deletions
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { safeToolArgumentSummary } from './approval-summary'
describe('safeToolArgumentSummary', () => {
it('redacts nested sensitive fields', () => {
expect(
safeToolArgumentSummary({
command: 'deploy',
options: {
apiKey: 'secret-value',
nested: { authorization: 'Bearer token-value' }
}
})
).toBe(
'{"command":"deploy","options":{"apiKey":"[REDACTED]","nested":{"authorization":"[REDACTED]"}}}'
)
})
it('redacts secrets in tool previews and bounds output', () => {
expect(
safeToolArgumentSummary(
{},
[{ content: 'curl -H "Authorization: Bearer secret-token"' }],
80
)
).not.toContain('secret-token')
})
})
+70
View File
@@ -0,0 +1,70 @@
const sensitiveKey = /token|secret|password|api.?key|authorization/iu
function redactValue(
value: unknown,
seen: WeakSet<object>,
depth = 0
): unknown {
if (depth > 8) {
return '[TRUNCATED]'
}
if (!value || typeof value !== 'object') {
return value
}
if (seen.has(value)) {
return '[CIRCULAR]'
}
seen.add(value)
if (Array.isArray(value)) {
return value
.slice(0, 100)
.map((item) => redactValue(item, seen, depth + 1))
}
return Object.fromEntries(
Object.entries(value)
.slice(0, 100)
.map(([key, item]) => [
key,
sensitiveKey.test(key)
? '[REDACTED]'
: redactValue(item, seen, depth + 1)
])
)
}
export function redactSensitiveText(value: string): string {
return value
.replace(
/\bAuthorization\b(\s*[:=]\s*)Bearer\s+\S+/giu,
'Authorization$1[REDACTED]'
)
.replace(/\bBearer\s+\S+/giu, 'Bearer [REDACTED]')
.replace(
/\b(api[-_ ]?key|token|secret|password|authorization)\b(\s*[:=]\s*|\s+)(["']?)[^\s"',}]+/giu,
'$1$2[REDACTED]'
)
}
export function safeToolArgumentSummary(
toolArguments: Record<string, unknown>,
preview?: unknown[],
maximum = 1_000
): string {
const previewText = preview
?.slice(0, 100)
.flatMap((item) => {
if (!item || typeof item !== 'object') {
return []
}
const content = (item as Record<string, unknown>).content
return typeof content === 'string' ? [content] : []
})
.join(' ')
.trim()
if (previewText) {
return redactSensitiveText(previewText).slice(0, maximum)
}
return JSON.stringify(
redactValue(toolArguments, new WeakSet())
).slice(0, maximum)
}
+138 -2
View File
@@ -186,7 +186,25 @@ describe('ContinueHostAdapter', () => {
content: 'HOST_LAUNCH_OK'
}
}
]
],
usage:
stateRequests === 1
? {
promptTokens: 20,
completionTokens: 10,
promptTokensDetails: {
cachedTokens: 5,
cacheWriteTokens: 2
}
}
: {
promptTokens: 19,
completionTokens: 9,
promptTokensDetails: {
cachedTokens: 4,
cacheWriteTokens: 1
}
}
},
isProcessing: false,
messageQueueLength: 0,
@@ -210,13 +228,25 @@ describe('ContinueHostAdapter', () => {
name: '独立模型',
baseUrl: 'https://model.example',
modelName: 'private-model',
protocol: 'anthropic-messages',
authentication: 'api-key',
apiKey: 'private-key'
}
})
await expect(
adapter.run('hello', new AbortController().signal, async () => 'deny')
).resolves.toBe('HOST_LAUNCH_OK')
).resolves.toEqual({
text: 'HOST_LAUNCH_OK',
usage: {
provider: 'anthropic',
model: 'private-model',
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
})
expect(launch?.entryPath).toContain('host-v2')
expect(launch?.args).toEqual([
'--config',
@@ -255,4 +285,110 @@ describe('ContinueHostAdapter', () => {
expect(launch?.env.ANTHROPIC_API_KEY).toBe('private-key')
expect(existsSync(generatedConfigPath)).toBe(false)
})
it('generates an OpenAI config without a fake key for Ollama', async () => {
const distribution = await createDistribution()
let generatedConfig = ''
let launchedEnvironment: NodeJS.ProcessEnv | undefined
const launchHost: ContinueHostLauncher = (_entryPath, args, options) => {
const configIndex = args.indexOf('--config')
generatedConfig = readFileSync(args[configIndex + 1] ?? '', 'utf8')
launchedEnvironment = options.env
return {
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}
}
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: {
history:
stateRequests === 1
? []
: [
{
message: {
role: 'assistant',
content: 'OLLAMA_OK'
}
}
],
usage:
stateRequests === 1
? {
promptTokens: 100,
completionTokens: 20,
promptTokensDetails: {
cachedTokens: 10,
cacheWriteTokens: 3
}
}
: {
promptTokens: 131,
completionTokens: 29,
promptTokensDetails: {
cachedTokens: 23,
cacheWriteTokens: 7
}
}
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null
})
}
return Response.json({})
})
)
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost,
modelProfile: {
id: '00000000-0000-4000-8000-000000000012',
name: 'Ollama',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
})
await expect(
adapter.run('hello', new AbortController().signal, async () => 'deny')
).resolves.toEqual({
text: 'OLLAMA_OK',
usage: {
provider: 'openai',
model: 'qwen3',
inputTokens: 31,
outputTokens: 9,
cacheReadTokens: 13,
cacheWriteTokens: 4
}
})
expect(JSON.parse(generatedConfig)).toMatchObject({
models: [
{
provider: 'openai',
apiBase: 'http://127.0.0.1:11434/v1',
model: 'qwen3'
}
]
})
expect(generatedConfig).not.toContain('apiKey')
expect(launchedEnvironment).not.toHaveProperty('OPENAI_API_KEY')
expect(launchedEnvironment).not.toHaveProperty('ANTHROPIC_API_KEY')
})
})
+121 -53
View File
@@ -31,6 +31,8 @@ import {
import { getAvailableLoopbackPort } from './loopback-port'
import { buildRuntimeEnvironment } from './process-environment'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import { createOpenAIApiBaseUrl } from './openai-endpoint'
import { safeToolArgumentSummary } from './approval-summary'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -47,9 +49,27 @@ const utilityBootstrap = [
''
].join('\n')
const tokenCountSchema = z
.number()
.int()
.min(0)
.max(Number.MAX_SAFE_INTEGER)
const sessionUsageSchema = z.object({
promptTokens: tokenCountSchema,
completionTokens: tokenCountSchema,
promptTokensDetails: z
.object({
cachedTokens: tokenCountSchema.optional(),
cacheWriteTokens: tokenCountSchema.optional()
})
.optional()
})
const stateSchema = z.object({
session: z.object({
history: z.array(z.unknown()).max(5_000)
history: z.array(z.unknown()).max(5_000),
usage: sessionUsageSchema.optional()
}),
isProcessing: z.boolean(),
messageQueueLength: z.number().int().min(0),
@@ -70,6 +90,20 @@ type PreparedHost = {
version: string
}
export type ContinueHostUsage = {
provider: string
model: string
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
export type ContinueHostRunResult = {
text: string
usage?: ContinueHostUsage
}
export type ContinueHostAdapterOptions = {
binaryPath: string
configPath: string
@@ -171,41 +205,10 @@ function delay(milliseconds: number, signal: AbortSignal): Promise<void> {
})
}
function safeArgumentSummary(
toolArguments: Record<string, unknown>,
preview?: unknown[]
function extractAssistantText(
history: unknown[],
startIndex: number
): string {
const previewText = preview
?.flatMap((item) => {
if (!item || typeof item !== 'object') {
return []
}
const value = item as Record<string, unknown>
return typeof value.content === 'string' ? [value.content] : []
})
.join(' ')
.trim()
if (previewText) {
return previewText
.replace(/\bBearer\s+\S+/giu, 'Bearer [REDACTED]')
.replace(
/\b(api[-_ ]?key|token|secret|password|authorization)\b(\s*[:=]\s*|\s+)(["']?)[^\s"',}]+/giu,
'$1$2[REDACTED]'
)
.slice(0, 1_000)
}
const redacted = Object.fromEntries(
Object.entries(toolArguments).map(([key, value]) => [
key,
/token|secret|password|api.?key|authorization/iu.test(key)
? '[REDACTED]'
: value
])
)
return JSON.stringify(redacted).slice(0, 1_000)
}
function extractAssistantText(history: unknown[], startIndex: number): string {
for (const item of history.slice(startIndex).reverse()) {
if (!item || typeof item !== 'object') {
continue
@@ -226,6 +229,41 @@ function extractAssistantText(history: unknown[], startIndex: number): string {
return ''
}
function subtractTokenCount(completed: number, initial: number): number {
return Math.max(0, completed - initial)
}
function extractUsageDelta(
initial: ContinueHostState['session']['usage'],
completed: ContinueHostState['session']['usage'],
fallbackProvider: string,
fallbackModel?: string
): ContinueHostUsage | undefined {
if (!initial || !completed) {
return undefined
}
return {
provider: fallbackProvider,
model: fallbackModel ?? 'unknown',
inputTokens: subtractTokenCount(
completed.promptTokens,
initial.promptTokens
),
outputTokens: subtractTokenCount(
completed.completionTokens,
initial.completionTokens
),
cacheReadTokens: subtractTokenCount(
completed.promptTokensDetails?.cachedTokens ?? 0,
initial.promptTokensDetails?.cachedTokens ?? 0
),
cacheWriteTokens: subtractTokenCount(
completed.promptTokensDetails?.cacheWriteTokens ?? 0,
initial.promptTokensDetails?.cacheWriteTokens ?? 0
)
}
}
export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>()
private preparation?: Promise<PreparedHost>
@@ -440,13 +478,32 @@ export class ContinueHostAdapter {
prompt: string,
signal: AbortSignal,
authorize: RuntimeAuthorizer
): Promise<string> {
): Promise<ContinueHostRunResult> {
signal.throwIfAborted()
let generatedConfigPath: string | undefined
if (this.options.modelProfile) {
if (!this.options.modelProfile.apiKey) {
if (
this.options.modelProfile.authentication === 'api-key' &&
!this.options.modelProfile.apiKey
) {
throw new Error('Continue 独立模型连接尚未配置 API Key')
}
const anthropic =
this.options.modelProfile.protocol === 'anthropic-messages'
const modelConfig: Record<string, unknown> = {
name: this.options.modelProfile.name,
provider: anthropic ? 'anthropic' : 'openai',
model: this.options.modelProfile.modelName,
apiBase: anthropic
? createAnthropicApiBaseUrl(this.options.modelProfile.baseUrl)
: createOpenAIApiBaseUrl(this.options.modelProfile.baseUrl),
roles: ['chat']
}
if (this.options.modelProfile.authentication === 'api-key') {
modelConfig.apiKey = anthropic
? '${{ secrets.ANTHROPIC_API_KEY }}'
: '${{ secrets.OPENAI_API_KEY }}'
}
await mkdir(this.options.cacheRoot, { recursive: true })
generatedConfigPath = join(
this.options.cacheRoot,
@@ -458,18 +515,7 @@ export class ContinueHostAdapter {
name: 'GoodBuddy Runtime',
version: '1.0.0',
schema: 'v1',
models: [
{
name: this.options.modelProfile.name,
provider: 'anthropic',
model: this.options.modelProfile.modelName,
apiKey: '${{ secrets.ANTHROPIC_API_KEY }}',
apiBase: createAnthropicApiBaseUrl(
this.options.modelProfile.baseUrl
),
roles: ['chat']
}
]
models: [modelConfig]
}),
{ encoding: 'utf8', mode: 0o600, flag: 'wx' }
)
@@ -515,8 +561,19 @@ export class ContinueHostAdapter {
OTEL_METRICS_EXPORTER: '',
OTEL_LOG_USER_PROMPTS: '0'
})
if (this.options.modelProfile?.apiKey) {
environment.ANTHROPIC_API_KEY = this.options.modelProfile.apiKey
if (this.options.modelProfile) {
delete environment.ANTHROPIC_API_KEY
delete environment.OPENAI_API_KEY
}
if (
this.options.modelProfile?.authentication === 'api-key' &&
this.options.modelProfile.apiKey
) {
environment[
this.options.modelProfile.protocol === 'anthropic-messages'
? 'ANTHROPIC_API_KEY'
: 'OPENAI_API_KEY'
] = this.options.modelProfile.apiKey
}
let child: ContinueHostChild
try {
@@ -615,7 +672,7 @@ export class ContinueHostAdapter {
title: `Continue 请求调用 ${pending.toolName}`,
description: '仅在你选择允许后,Continue 才会执行此工具调用。',
toolName: pending.toolName,
argumentSummary: safeArgumentSummary(
argumentSummary: safeToolArgumentSummary(
pending.toolArgs,
pending.toolCallPreview
),
@@ -649,7 +706,18 @@ export class ContinueHostAdapter {
if (!text) {
throw new Error('Continue 宿主未返回最终回复')
}
return text
const usage = extractUsageDelta(
initialState.session.usage,
state.session.usage,
this.options.modelProfile
? this.options.modelProfile.protocol ===
'anthropic-messages'
? 'anthropic'
: 'openai'
: 'continue',
this.options.modelProfile?.modelName
)
return { text, ...(usage ? { usage } : {}) }
}
await delay(150, signal)
}
+45 -4
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent } from '../../shared/contracts'
import type { RuntimeEvent } from './runtime'
const mocks = vi.hoisted(() => ({
detectRuntimeBinary: vi.fn(),
@@ -31,8 +31,8 @@ function createRuntime(): ContinueAgentRuntime {
async function collectEvents(
runtime: ContinueAgentRuntime
): Promise<AgentEvent[]> {
const events: AgentEvent[] = []
): Promise<RuntimeEvent[]> {
const events: RuntimeEvent[] = []
for await (const event of runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
@@ -60,7 +60,9 @@ describe('ContinueAgentRuntime', () => {
entryPath: 'C:\\safe\\continue-host\\dist\\cn.js',
version: '1.5.47'
})
mocks.runHost.mockResolvedValue('Continue response')
mocks.runHost.mockResolvedValue({
text: 'Continue response'
})
})
it('does not launch the CLI for an already-cancelled request', async () => {
@@ -88,6 +90,8 @@ describe('ContinueAgentRuntime', () => {
expect(mocks.detectRuntimeBinary).toHaveBeenCalledWith({
binaryPath: '',
bundledPath: undefined,
bundledValidation: 'canonical-file',
binaryNames: ['cn'],
label: 'Continue CLI'
})
@@ -101,6 +105,42 @@ describe('ContinueAgentRuntime', () => {
type: 'text',
delta: 'Continue response'
})
expect(events).not.toContainEqual(
expect.objectContaining({ type: 'model-usage' })
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('emits one request-scoped host usage event at the end', async () => {
mocks.runHost.mockResolvedValue({
text: 'Continue response',
usage: {
provider: 'openai',
model: 'qwen3',
inputTokens: 31,
outputTokens: 9,
cacheReadTokens: 13,
cacheWriteTokens: 4
}
})
const events = await collectEvents(createRuntime())
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'model-usage',
callId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
runtime: 'continue',
provider: 'openai',
model: 'qwen3',
inputTokens: 31,
outputTokens: 9,
cacheReadTokens: 13,
cacheWriteTokens: 4
}
])
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
@@ -187,6 +227,7 @@ describe('ContinueAgentRuntime', () => {
id: 'continue',
label: 'Continue CLI',
available: false,
supportsToolExecution: true,
detail: '未自动检测到 Continue CLI,请配置绝对二进制路径'
})
const stream = runtime.run(
+42 -7
View File
@@ -1,5 +1,4 @@
import type {
AgentEvent,
AgentRuntimeStatus,
RuntimeSettings,
RuntimeBinaryDetection
@@ -7,7 +6,8 @@ import type {
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeAuthorizer
RuntimeAuthorizer,
RuntimeEvent
} from './runtime'
import { detectRuntimeBinary } from './runtime-discovery'
import type { ResolvedModelProfile } from '../runtime-settings-store'
@@ -22,6 +22,7 @@ export type ContinueRuntimeOptions = {
bundledBinaryPath?: string
configPath: string
mode: RuntimeSettings['continueMode']
runtimeSandboxMode?: RuntimeSettings['runtimeSandboxMode']
defaultWorkspace: string
hostCacheRoot: string
skillInstructions?: string
@@ -89,6 +90,7 @@ function buildContinuePrompt(request: AgentExecutionRequest): string {
export class ContinueAgentRuntime implements AgentRuntime {
readonly requiresToolApproval = false
readonly supportsToolExecution = true
private detection?: Promise<RuntimeBinaryDetection>
private hostAdapter?: ReturnType<
NonNullable<ContinueRuntimeOptions['createHostAdapter']>
@@ -100,6 +102,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
this.detection ??= detectRuntimeBinary({
binaryPath: this.options.binaryPath,
bundledPath: this.options.bundledBinaryPath,
bundledValidation: 'canonical-file',
binaryNames: ['cn'],
label: 'Continue CLI'
})
@@ -124,6 +127,16 @@ export class ContinueAgentRuntime implements AgentRuntime {
}
async getStatus(): Promise<AgentRuntimeStatus> {
if (this.options.runtimeSandboxMode === 'strict') {
return {
id: 'continue',
label: 'Continue CLI',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail:
'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode'
}
}
const detection = await this.getDetection()
if (detection.available && detection.path) {
try {
@@ -133,6 +146,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
id: 'continue',
label: 'Continue CLI',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail:
error instanceof Error
? error.message
@@ -144,8 +158,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
id: 'continue',
label: 'Continue CLI',
available: detection.available,
supportsToolExecution: this.supportsToolExecution,
detail: detection.available
? `${detection.detail};宿主逐工具审批`
? `${detection.detail};宿主逐工具审批;未启用 OS 进程沙箱`
: detection.detail
}
}
@@ -154,8 +169,13 @@ export class ContinueAgentRuntime implements AgentRuntime {
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<AgentEvent, void, void> {
): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted()
if (this.options.runtimeSandboxMode === 'strict') {
throw new Error(
'Continue 宿主暂不支持严格 OS 沙箱,请改用自动模式或嵌入式 OpenCode'
)
}
if (request.images?.length) {
throw new Error('Continue Runtime 暂不支持图片上下文,请切换到视觉模型')
}
@@ -189,19 +209,34 @@ export class ContinueAgentRuntime implements AgentRuntime {
if (!authorize) {
throw new Error('Continue 工具审批服务不可用')
}
const text = await this.getHostAdapter(binaryPath).run(
const result = await this.getHostAdapter(binaryPath).run(
conversationContext,
signal,
authorize
)
if (!text) {
if (!result.text) {
throw new Error('Continue CLI 未返回内容')
}
yield {
requestId: request.requestId,
type: 'text',
delta: text
delta: result.text
}
if (result.usage) {
const usage = result.usage
yield {
requestId: request.requestId,
type: 'model-usage',
callId: request.requestId,
runtime: 'continue',
provider: usage.provider.slice(0, 100),
model: usage.model.slice(0, 500),
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens
}
}
yield {
requestId: request.requestId,
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { createAgentRuntime } from './create-runtime'
function settings(
overrides: Partial<ResolvedRuntimeSettings> = {}
): ResolvedRuntimeSettings {
return {
provider: 'model',
modelBaseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
modelProtocol: 'openai-chat-completions',
modelAuthentication: 'none',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'off',
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434',
knowledgeEmbeddingModel: 'nomic-embed-text',
workspacePath: process.cwd(),
toolApproval: 'always',
...overrides
}
}
describe('createAgentRuntime model compatibility', () => {
it('creates an available direct runtime for a no-auth model', async () => {
const runtime = createAgentRuntime(process.cwd(), settings())
await expect(runtime.getStatus()).resolves.toMatchObject({
id: 'model',
available: true,
detail: expect.stringContaining('OpenAI Chat Completions')
})
await runtime.dispose()
})
it('keeps OpenCode independent profiles Anthropic API-key only', () => {
expect(() =>
createAgentRuntime(
process.cwd(),
settings({
provider: 'opencode',
opencodeModelProfile: {
id: '00000000-0000-4000-8000-000000000031',
name: 'OpenAI profile',
baseUrl: 'https://api.example/v1',
modelName: 'model',
protocol: 'openai-chat-completions',
authentication: 'api-key',
apiKey: 'secret'
}
})
)
).toThrow('OpenCode 独立模型连接仅支持')
})
it('marks direct image runtimes and rejects them for Continue', async () => {
const imageSettings = settings({
modelBaseUrl: 'https://bigtoken.ai/v1',
modelName: 'gpt-image-2',
modelProtocol: 'openai-images-generations',
modelAuthentication: 'api-key',
apiKey: 'secret'
})
const runtime = createAgentRuntime(process.cwd(), imageSettings)
await expect(runtime.getStatus()).resolves.toMatchObject({
capability: 'image-generation'
})
await runtime.dispose()
expect(() =>
createAgentRuntime(
process.cwd(),
settings({
provider: 'continue',
continueModelProfile: {
id: '00000000-0000-4000-8000-000000000032',
name: 'Image profile',
baseUrl: 'https://bigtoken.ai/v1',
modelName: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key',
apiKey: 'secret'
}
})
)
).toThrow('Continue 不支持图像生成模型连接')
})
})
+33 -1
View File
@@ -8,6 +8,7 @@ import { defaultRuntimeSettings } from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BundledRuntimePaths } from './bundled-runtimes'
import type { ContinueHostLauncher } from './continue-host-adapter'
import { resolveRuntimeSandbox } from './runtime-sandbox'
export type AgentCapabilityContext = {
skillInstructions?: string
@@ -29,8 +30,17 @@ export function createAgentRuntime(
process.env.GOODBUDDY_OPENCODE_EMBEDDED === 'true'
const workspace = settings?.workspacePath || defaultWorkspace
const provider = settings?.provider ?? 'auto'
const sandboxMode =
settings?.runtimeSandboxMode ??
defaultRuntimeSettings.runtimeSandboxMode
if (provider === 'continue') {
if (
settings?.continueModelProfile?.protocol ===
'openai-images-generations'
) {
throw new Error('Continue 不支持图像生成模型连接')
}
return new ContinueAgentRuntime({
binaryPath:
settings?.continueBinaryPath ??
@@ -43,6 +53,7 @@ export function createAgentRuntime(
process.env.GOODBUDDY_CONTINUE_CONFIG?.trim() ??
'',
mode: settings?.continueMode ?? defaultRuntimeSettings.continueMode,
runtimeSandboxMode: sandboxMode,
modelProfile: settings?.continueModelProfile,
skillInstructions: capabilities.skillInstructions,
defaultWorkspace: workspace,
@@ -55,6 +66,15 @@ export function createAgentRuntime(
}
if (provider === 'opencode' || (provider === 'auto' && (baseUrl || embedded))) {
if (
settings?.opencodeModelProfile &&
(settings.opencodeModelProfile.protocol !== 'anthropic-messages' ||
settings.opencodeModelProfile.authentication !== 'api-key')
) {
throw new Error(
'OpenCode 独立模型连接仅支持需要 API Key 的 Anthropic Messages 协议'
)
}
return new OpenCodeRuntime({
baseUrl,
embedded,
@@ -70,6 +90,7 @@ export function createAgentRuntime(
modelProfile: settings?.opencodeModelProfile,
skillInstructions: capabilities.skillInstructions,
mcpServers: capabilities.mcpServers,
sandbox: resolveRuntimeSandbox(sandboxMode),
defaultWorkspace: workspace
})
}
@@ -78,7 +99,14 @@ export function createAgentRuntime(
settings?.apiKey ||
process.env.GOODBUDDY_MODEL_API_KEY?.trim() ||
process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
if (provider === 'model' || (provider === 'auto' && modelApiKey)) {
const modelAuthentication =
settings?.modelAuthentication ??
defaultRuntimeSettings.modelAuthentication
if (
provider === 'model' ||
(provider === 'auto' &&
(modelAuthentication === 'none' || modelApiKey))
) {
return new ModelAgentRuntime({
apiKey: modelApiKey ?? '',
baseUrl:
@@ -91,6 +119,10 @@ export function createAgentRuntime(
process.env.GOODBUDDY_MODEL_NAME?.trim() ||
process.env.GOODBUDDY_BIGTOKEN_MODEL?.trim() ||
defaultRuntimeSettings.modelName,
protocol:
settings?.modelProtocol ??
defaultRuntimeSettings.modelProtocol,
authentication: modelAuthentication,
skillInstructions: capabilities.skillInstructions
})
}
+414 -1
View File
@@ -4,7 +4,18 @@ import { ModelAgentRuntime } from './model-runtime'
function createEventStream(text: string): string {
return [
'event: message_start',
'data: {"type":"message_start","message":{"id":"message-1"}}',
`data: ${JSON.stringify({
type: 'message_start',
message: {
id: 'message-1',
model: 'claude-sonnet-provider',
usage: {
input_tokens: 23,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 7
}
}
})}`,
'',
'event: content_block_delta',
`data: ${JSON.stringify({
@@ -12,6 +23,12 @@ function createEventStream(text: string): string {
delta: { type: 'text_delta', text }
})}`,
'',
'event: message_delta',
`data: ${JSON.stringify({
type: 'message_delta',
usage: { output_tokens: 11 }
})}`,
'',
'event: message_stop',
'data: {"type":"message_stop"}',
'',
@@ -30,6 +47,8 @@ describe('ModelAgentRuntime', () => {
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
@@ -54,6 +73,8 @@ describe('ModelAgentRuntime', () => {
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
skillInstructions: '# 文档写作',
fetcher
})
@@ -91,6 +112,21 @@ describe('ModelAgentRuntime', () => {
delta: '真实模型回答'
})
)
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed125',
type: 'model-usage',
callId: 'message-1',
runtime: 'model',
provider: 'anthropic',
model: 'claude-sonnet-provider',
inputTokens: 23,
outputTokens: 11,
cacheReadTokens: 7,
cacheWriteTokens: 5
}
])
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
@@ -108,6 +144,8 @@ describe('ModelAgentRuntime', () => {
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
@@ -126,4 +164,379 @@ describe('ModelAgentRuntime', () => {
await expect(consume()).rejects.toThrow('意外中断')
})
it('redacts credentials from provider error messages', async () => {
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'claude-sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher: vi.fn<typeof fetch>(async () =>
Response.json(
{
error: {
message:
'upstream failed Authorization: Bearer secret-token'
}
},
{ status: 502 }
)
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
}
}
await expect(consume()).rejects.toThrow(
'upstream failed Authorization: [REDACTED]'
)
})
it('uses OpenAI Chat Completions SSE and omits auth for Ollama', async () => {
const stream = [
`data: ${JSON.stringify({
choices: [{ delta: { content: '本机回答' } }]
})}`,
'',
`data: ${JSON.stringify({
id: 'chatcmpl-provider-1',
model: 'qwen3-provider',
choices: [],
usage: {
prompt_tokens: 31,
completion_tokens: 9,
total_tokens: 40,
prompt_tokens_details: { cached_tokens: 13 },
cache_write_tokens: 4
}
})}`,
'',
'data: [DONE]',
'',
''
].join('\n')
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(stream, {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher
})
const events = []
for await (const event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed127',
conversationId: 'conversation-3',
prompt: '你好'
},
new AbortController().signal
)) {
events.push(event)
}
const [input, init] = fetcher.mock.calls[0] ?? []
expect(input?.toString()).toBe(
'http://127.0.0.1:11434/v1/chat/completions'
)
expect(init?.headers).toEqual({
'content-type': 'application/json'
})
expect(JSON.parse(init?.body as string)).toMatchObject({
model: 'qwen3',
stream: true,
stream_options: {
include_usage: true
},
messages: [
expect.objectContaining({ role: 'system' }),
expect.objectContaining({ role: 'user', content: '你好' })
]
})
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '本机回答'
})
)
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed127',
type: 'model-usage',
callId: 'chatcmpl-provider-1',
runtime: 'model',
provider: 'openai',
model: 'qwen3-provider',
inputTokens: 31,
outputTokens: 9,
cacheReadTokens: 13,
cacheWriteTokens: 4,
reportedTotalTokens: 40
}
])
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('generates a bounded image through the BigToken-compatible endpoint', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00
]).toString('base64')
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json({
id: 'image-provider-1',
model: 'gpt-image-provider',
usage: {
input_tokens: 17,
output_tokens: 29,
total_tokens: 46
},
data: [{ b64_json: png }]
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai/v1',
model: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key',
fetcher
})
const events = []
for await (const event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
conversationId: 'conversation-image',
prompt: '一只在窗边睡觉的猫'
},
new AbortController().signal
)) {
events.push(event)
}
const [input, init] = fetcher.mock.calls[0] ?? []
expect(input?.toString()).toBe(
'https://bigtoken.ai/v1/images/generations'
)
expect(init?.headers).toEqual({
authorization: 'Bearer test-key',
'content-type': 'application/json'
})
expect(JSON.parse(init?.body as string)).toEqual({
model: 'gpt-image-2',
prompt: '一只在窗边睡觉的猫',
n: 1,
response_format: 'b64_json'
})
expect(events).toContainEqual(
expect.objectContaining({
type: 'generated-image',
mimeType: 'image/png',
data: png
})
)
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
type: 'model-usage',
callId: 'image-provider-1',
runtime: 'model',
provider: 'openai',
model: 'gpt-image-provider',
inputTokens: 17,
outputTokens: 29,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reportedTotalTokens: 46
}
])
expect(events.findIndex((event) => event.type === 'model-usage')).toBeLessThan(
events.findIndex((event) => event.type === 'generated-image')
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('rejects remote image URLs instead of fetching provider output', async () => {
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai/v1',
model: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key',
fetcher: vi.fn<typeof fetch>(async () =>
Response.json({
data: [{ url: 'https://untrusted.example/image.png' }]
})
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed129',
conversationId: 'conversation-image-url',
prompt: '测试图片'
},
new AbortController().signal
)) {
void _event
}
}
await expect(consume()).rejects.toThrow('未返回 base64 图片')
})
it('accepts a bounded inline image data URL from compatible gateways', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00
]).toString('base64')
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai/v1',
model: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key',
fetcher: vi.fn<typeof fetch>(async () =>
Response.json({
data: [{ url: `data:image/png;base64,${png}` }]
})
)
})
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: '测试内联图片'
},
new AbortController().signal
)) {
events.push(event)
}
expect(events).toContainEqual(
expect.objectContaining({
type: 'generated-image',
mimeType: 'image/png',
data: png
})
)
})
it.each([
{
body: JSON.stringify({
error: {
message:
'upstream unavailable Authorization: Bearer secret-token'
}
}),
headers: {
'content-type': 'application/json',
'x-request-id': 'image-request-502'
},
expected:
'upstream unavailable Authorization: [REDACTED]HTTP 502,请求 ID image-request-502'
},
{
body: '<html>Bad Gateway</html>',
headers: { 'content-type': 'text/html' },
expected: '图像生成请求失败(HTTP 502'
},
{
body: JSON.stringify({
error: '模型接口请求失败(HTTP 502'
}),
headers: { 'content-type': 'application/json' },
expected:
'上游图像服务暂时不可用,请稍后重试或联系服务商(HTTP 502)'
}
])(
'retains HTTP status for image gateway failures',
async ({ body, headers, expected }) => {
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai/v1',
model: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key',
fetcher: vi.fn<typeof fetch>(async () =>
new Response(body, { status: 502, headers })
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: '测试网关错误'
},
new AbortController().signal
)) {
void _event
}
}
await expect(consume()).rejects.toThrow(expected)
}
)
it.runIf(
process.env.GOODBUDDY_BIGTOKEN_IMAGE_INTEGRATION === '1'
)(
'generates a real synthetic image with BigToken gpt-image-2',
async () => {
const apiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY
if (!apiKey) {
throw new Error('GOODBUDDY_BIGTOKEN_API_KEY is required')
}
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl: 'https://bigtoken.ai/v1',
model: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key'
})
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt:
'A simple solid blue circle centered on a plain white background.'
},
new AbortController().signal
)) {
events.push(event)
}
expect(events).toContainEqual(
expect.objectContaining({
type: 'generated-image',
mimeType: expect.stringMatching(/^image\//u)
})
)
await runtime.dispose()
},
180_000
)
})
+576 -59
View File
@@ -1,19 +1,27 @@
import type {
AgentEvent,
AgentRuntimeStatus
AgentRuntimeStatus,
ModelAuthentication,
ModelProtocol
} from '../../shared/contracts'
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
import {
createOpenAIChatCompletionsUrl,
createOpenAIImagesGenerationsUrl
} from './openai-endpoint'
import type {
AgentExecutionRequest,
AgentRuntime
AgentRuntime,
RuntimeEvent,
RuntimeModelUsageEvent
} from './runtime'
import { redactSensitiveText } from './approval-summary'
type ConversationMessage = {
role: 'user' | 'assistant'
content: string
}
type ApiMessage = {
type AnthropicApiMessage = {
role: 'user' | 'assistant'
content:
| string
@@ -33,10 +41,29 @@ type ApiMessage = {
>
}
type ModelUsageUpdate = {
callId?: string
model?: string
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
reportedTotalTokens?: number
}
type ModelUsageAccumulator = ModelUsageUpdate & {
reported: boolean
}
const maxGeneratedImageBytes = 3_900_000
const maxImageResponseBytes = 5_300_000
export type ModelRuntimeOptions = {
apiKey: string
apiKey?: string
baseUrl: string
model: string
protocol: ModelProtocol
authentication: ModelAuthentication
skillInstructions?: string
fetcher?: typeof fetch
}
@@ -46,18 +73,27 @@ function getErrorMessage(value: unknown): string | undefined {
return undefined
}
const error = 'error' in value ? value.error : undefined
if (typeof error === 'string') {
return redactSensitiveText(error).slice(0, 1_000)
}
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message
return redactSensitiveText(error.message).slice(0, 1_000)
}
if (
'message' in value &&
typeof value.message === 'string'
) {
return redactSensitiveText(value.message).slice(0, 1_000)
}
return undefined
}
function getTextDelta(value: unknown): string | undefined {
function getAnthropicTextDelta(value: unknown): string | undefined {
if (
!value ||
typeof value !== 'object' ||
@@ -80,18 +116,282 @@ function getTextDelta(value: unknown): string | undefined {
return undefined
}
function parseStreamBlock(block: string): {
function getOpenAITextDelta(value: unknown): string | undefined {
if (
!value ||
typeof value !== 'object' ||
!('choices' in value) ||
!Array.isArray(value.choices)
) {
return undefined
}
const first = value.choices[0]
if (
!first ||
typeof first !== 'object' ||
!('delta' in first) ||
!first.delta ||
typeof first.delta !== 'object' ||
!('content' in first.delta) ||
typeof first.delta.content !== 'string'
) {
return undefined
}
return first.delta.content
}
function getRecord(
value: unknown
): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object'
? value as Record<string, unknown>
: undefined
}
function getSafeTokenCount(value: unknown): number | undefined {
return Number.isSafeInteger(value) && (value as number) >= 0
? value as number
: undefined
}
function getProviderIdentifier(value: unknown): string | undefined {
return typeof value === 'string' &&
value.length > 0 &&
value.length <= 512
? value
: undefined
}
function getUsageUpdate(
value: unknown,
protocol: 'anthropic' | 'openai'
): ModelUsageUpdate {
const event = getRecord(value)
if (!event) {
return {}
}
let metadata = event
let usage: Record<string, unknown> | undefined
if (protocol === 'anthropic') {
if (event.type === 'message_start') {
metadata = getRecord(event.message) ?? event
usage = getRecord(metadata.usage)
} else if (event.type === 'message_delta') {
usage = getRecord(event.usage)
}
} else {
usage = getRecord(event.usage)
}
const promptDetails =
protocol === 'openai'
? getRecord(usage?.prompt_tokens_details)
: undefined
return {
callId: getProviderIdentifier(metadata.id),
model: getProviderIdentifier(metadata.model),
inputTokens: getSafeTokenCount(
protocol === 'anthropic'
? usage?.input_tokens
: usage?.prompt_tokens ?? usage?.input_tokens
),
outputTokens: getSafeTokenCount(
protocol === 'anthropic'
? usage?.output_tokens
: usage?.completion_tokens ?? usage?.output_tokens
),
cacheReadTokens: getSafeTokenCount(
protocol === 'anthropic'
? usage?.cache_read_input_tokens
: usage?.cache_read_tokens ?? promptDetails?.cached_tokens
),
cacheWriteTokens: getSafeTokenCount(
protocol === 'anthropic'
? usage?.cache_creation_input_tokens
: usage?.cache_write_tokens
),
reportedTotalTokens: getSafeTokenCount(usage?.total_tokens)
}
}
function applyUsageUpdate(
accumulator: ModelUsageAccumulator,
update: ModelUsageUpdate
): void {
for (const key of [
'callId',
'model',
'inputTokens',
'outputTokens',
'cacheReadTokens',
'cacheWriteTokens',
'reportedTotalTokens'
] as const) {
const value = update[key]
if (value !== undefined) {
Object.assign(accumulator, { [key]: value })
if (
key !== 'callId' &&
key !== 'model'
) {
accumulator.reported = true
}
}
}
}
function createUsageEvent(
requestId: string,
provider: 'anthropic' | 'openai',
fallbackModel: string,
usage: ModelUsageAccumulator
): RuntimeModelUsageEvent | undefined {
if (!usage.reported) {
return undefined
}
return {
requestId,
type: 'model-usage',
callId: (usage.callId ?? requestId).slice(0, 256),
runtime: 'model',
provider,
model: (usage.model ?? fallbackModel).slice(0, 500),
inputTokens: usage.inputTokens ?? 0,
outputTokens: usage.outputTokens ?? 0,
cacheReadTokens: usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
...(usage.reportedTotalTokens === undefined
? {}
: { reportedTotalTokens: usage.reportedTotalTokens })
}
}
async function readBoundedText(
response: Response,
maxBytes: number
): Promise<string> {
if (!response.body) {
throw new Error('模型接口未返回响应内容')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => undefined)
throw new Error('图像生成响应超过安全限制')
}
chunks.push(value)
}
} finally {
reader.releaseLock()
}
return Buffer.concat(chunks, total).toString('utf8')
}
function parseGeneratedImage(value: unknown): {
data: string
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
} {
if (
!value ||
typeof value !== 'object' ||
!('data' in value) ||
!Array.isArray(value.data) ||
value.data.length !== 1
) {
throw new Error('图像生成接口返回格式无效')
}
const first = value.data[0]
if (
!first ||
typeof first !== 'object'
) {
throw new Error('图像生成接口未返回 base64 图片')
}
const inlineUrl =
'url' in first && typeof first.url === 'string'
? /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/u.exec(
first.url
)
: undefined
const encoded =
'b64_json' in first && typeof first.b64_json === 'string'
? first.b64_json
: inlineUrl?.[1]
if (!encoded) {
throw new Error('图像生成接口未返回 base64 图片')
}
if (
encoded.length === 0 ||
encoded.length > maxImageResponseBytes ||
encoded.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)
) {
throw new Error('图像生成接口返回了无效图片数据')
}
const data = Buffer.from(encoded, 'base64')
if (
data.length === 0 ||
data.length > maxGeneratedImageBytes ||
data.toString('base64') !== encoded
) {
throw new Error('图像生成图片无效或超过安全限制')
}
if (
data.length >= 8 &&
data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return { data: encoded, mimeType: 'image/png' }
}
if (
data.length >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { data: encoded, mimeType: 'image/jpeg' }
}
if (
data.length >= 12 &&
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
data.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { data: encoded, mimeType: 'image/webp' }
}
throw new Error('图像生成接口返回了不支持的图片格式')
}
function parseStreamBlock(
block: string,
protocol: ModelProtocol
): {
delta?: string
stopped: boolean
usage?: ModelUsageUpdate
} {
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (!data || data === '[DONE]') {
if (!data) {
return { stopped: false }
}
if (data === '[DONE]') {
return {
stopped: protocol === 'openai-chat-completions'
}
}
let event: unknown
try {
event = JSON.parse(data)
@@ -103,8 +403,16 @@ function parseStreamBlock(block: string): {
throw new Error(error.slice(0, 1_000))
}
return {
delta: getTextDelta(event),
delta:
protocol === 'anthropic-messages'
? getAnthropicTextDelta(event)
: getOpenAITextDelta(event),
usage: getUsageUpdate(
event,
protocol === 'anthropic-messages' ? 'anthropic' : 'openai'
),
stopped:
protocol === 'anthropic-messages' &&
event !== null &&
typeof event === 'object' &&
'type' in event &&
@@ -114,6 +422,7 @@ function parseStreamBlock(block: string): {
export class ModelAgentRuntime implements AgentRuntime {
readonly requiresToolApproval = false
readonly supportsToolExecution = false
private readonly conversations = new Map<string, ConversationMessage[]>()
private readonly fetcher: typeof fetch
@@ -121,36 +430,85 @@ export class ModelAgentRuntime implements AgentRuntime {
this.fetcher = options.fetcher ?? fetch
}
get capability(): 'chat' | 'image-generation' {
return this.options.protocol === 'openai-images-generations'
? 'image-generation'
: 'chat'
}
private isConfigured(): boolean {
return (
this.options.authentication === 'none' ||
Boolean(this.options.apiKey)
)
}
private getEndpoint(): URL {
if (this.options.protocol === 'anthropic-messages') {
return createAnthropicMessagesUrl(this.options.baseUrl)
}
return this.options.protocol === 'openai-images-generations'
? createOpenAIImagesGenerationsUrl(this.options.baseUrl)
: createOpenAIChatCompletionsUrl(this.options.baseUrl)
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'content-type': 'application/json'
}
if (
this.options.authentication === 'api-key' &&
this.options.apiKey
) {
if (this.options.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
headers['x-api-key'] = this.options.apiKey
} else {
headers.authorization = `Bearer ${this.options.apiKey}`
}
} else if (this.options.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
}
return headers
}
async getStatus(): Promise<AgentRuntimeStatus> {
const imageGeneration = this.capability === 'image-generation'
return {
id: 'model',
label: this.options.model,
available: Boolean(this.options.apiKey),
detail: `Anthropic Messages 兼容模型接口 · ${this.options.baseUrl}`
available: this.isConfigured(),
supportsToolExecution: this.supportsToolExecution,
detail: `${imageGeneration
? 'OpenAI Images Generations'
: this.options.protocol === 'anthropic-messages'
? 'Anthropic Messages'
: 'OpenAI Chat Completions'
} 兼容模型接口 · ${this.options.baseUrl}`,
capability: imageGeneration ? 'image-generation' : 'chat'
}
}
async testConnection(): Promise<AgentRuntimeStatus> {
if (!this.options.apiKey) {
if (!this.isConfigured()) {
return this.getStatus()
}
const response = await this.fetcher(
createAnthropicMessagesUrl(this.options.baseUrl),
{
method: 'POST',
headers: {
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
'x-api-key': this.options.apiKey
},
body: JSON.stringify({
model: this.options.model,
max_tokens: 1,
stream: false,
messages: [{ role: 'user', content: 'Reply OK.' }]
})
if (this.options.protocol === 'openai-images-generations') {
return {
...(await this.getStatus()),
detail: `已识别图像生成配置,发送提示词时执行实际生成验证 · ${this.options.baseUrl}`
}
)
}
const response = await this.fetcher(this.getEndpoint(), {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({
model: this.options.model,
max_tokens: 1,
stream: false,
messages: [{ role: 'user', content: 'Reply OK.' }]
})
})
if (!response.ok) {
let detail: string | undefined
try {
@@ -168,16 +526,19 @@ export class ModelAgentRuntime implements AgentRuntime {
id: 'model',
label: this.options.model,
available: true,
supportsToolExecution: this.supportsToolExecution,
detail: `已验证模型接口连接 · ${this.options.baseUrl}`
}
}
private getMessages(request: AgentExecutionRequest): ApiMessage[] {
private getAnthropicMessages(
request: AgentExecutionRequest
): AnthropicApiMessage[] {
const history =
request.history && request.history.length > 0
? request.history
: this.conversations.get(request.conversationId) ?? []
const content: ApiMessage['content'] =
const content: AnthropicApiMessage['content'] =
request.images && request.images.length > 0
? [
...request.images.map((image) => ({
@@ -203,6 +564,36 @@ export class ModelAgentRuntime implements AgentRuntime {
]
}
private getOpenAIMessages(
request: AgentExecutionRequest,
system: string
): Array<Record<string, unknown>> {
const history =
request.history && request.history.length > 0
? request.history
: this.conversations.get(request.conversationId) ?? []
const userContent =
request.images && request.images.length > 0
? [
{
type: 'text',
text: request.prompt
},
...request.images.map((image) => ({
type: 'image_url',
image_url: {
url: `data:${image.mediaType};base64,${image.data}`
}
}))
]
: request.prompt
return [
{ role: 'system', content: system },
...history.slice(-20),
{ role: 'user', content: userContent }
]
}
private saveConversation(
conversationId: string,
messages: ConversationMessage[]
@@ -227,13 +618,110 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
private async *runImageGeneration(
request: AgentExecutionRequest,
signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> {
if (request.images?.length) {
throw new Error('当前图像生成接口暂不支持参考图或图片编辑')
}
yield {
requestId: request.requestId,
type: 'status',
message: `${this.options.model} 正在生成图片`
}
const imageRequest = {
model: this.options.model,
prompt: request.prompt.slice(0, 100_000),
n: 1,
response_format: 'b64_json'
}
const response = await this.fetcher(this.getEndpoint(), {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(imageRequest),
signal
})
const responseText = await readBoundedText(
response,
response.ok ? maxImageResponseBytes : 128 * 1024
)
if (!response.ok) {
let errorPayload: unknown
try {
errorPayload = responseText.trim()
? JSON.parse(responseText)
: undefined
} catch {
errorPayload = undefined
}
const requestId = [
response.headers.get('x-request-id'),
response.headers.get('cf-ray')
].find(
(candidate) =>
candidate &&
candidate.length <= 128 &&
/^[\w.-]+$/u.test(candidate)
)
const providerMessage = getErrorMessage(errorPayload)
const publicMessage =
response.status === 502 &&
providerMessage?.includes('模型接口请求失败')
? '上游图像服务暂时不可用,请稍后重试或联系服务商'
: providerMessage
? redactSensitiveText(providerMessage).slice(0, 1_000)
: '图像生成请求失败'
throw new Error(
`${publicMessage}HTTP ${response.status}${
requestId ? `,请求 ID ${requestId}` : ''
}`
)
}
let payload: unknown
try {
payload = JSON.parse(responseText)
} catch {
throw new Error('图像生成接口返回了无效 JSON')
}
const image = parseGeneratedImage(payload)
const usage = {
reported: false
} satisfies ModelUsageAccumulator
applyUsageUpdate(usage, getUsageUpdate(payload, 'openai'))
const usageEvent = createUsageEvent(
request.requestId,
'openai',
this.options.model,
usage
)
if (usageEvent) {
yield usageEvent
}
yield {
requestId: request.requestId,
type: 'generated-image',
mimeType: image.mimeType,
data: image.data,
title: request.prompt.split(/\r?\n/u, 1)[0]!.slice(0, 120)
}
yield {
requestId: request.requestId,
type: 'done'
}
}
async *run(
request: AgentExecutionRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
if (!this.options.apiKey) {
): AsyncGenerator<RuntimeEvent, void, void> {
if (!this.isConfigured()) {
throw new Error('请先在设置中配置模型接口 API Key')
}
if (this.options.protocol === 'openai-images-generations') {
yield* this.runImageGeneration(request, signal)
return
}
yield {
requestId: request.requestId,
@@ -241,31 +729,40 @@ export class ModelAgentRuntime implements AgentRuntime {
message: `${this.options.model} 正在思考`
}
const messages = this.getMessages(request)
const response = await this.fetcher(
createAnthropicMessagesUrl(this.options.baseUrl),
{
method: 'POST',
headers: {
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
'x-api-key': this.options.apiKey
},
body: JSON.stringify({
model: this.options.model,
max_tokens: 4096,
stream: true,
system: [
'You are GoodBuddy, a secure desktop assistant. Answer clearly in the language used by the user. Never claim to have used desktop tools unless a tool result was provided.',
this.options.skillInstructions
]
.filter(Boolean)
.join('\n\n'),
messages
}),
signal
}
)
const system = [
'You are GoodBuddy, a secure desktop assistant. Answer clearly in the language used by the user. Never claim to have used desktop tools unless a tool result was provided.',
this.options.skillInstructions
]
.filter(Boolean)
.join('\n\n')
const anthropic = this.options.protocol === 'anthropic-messages'
const messages = anthropic
? this.getAnthropicMessages(request)
: this.getOpenAIMessages(request, system)
const response = await this.fetcher(this.getEndpoint(), {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(
anthropic
? {
model: this.options.model,
max_tokens: 4096,
stream: true,
system,
messages
}
: {
model: this.options.model,
max_tokens: 4096,
stream: true,
stream_options: {
include_usage: true
},
messages
}
),
signal
})
if (!response.ok) {
let detail: string | undefined
@@ -289,6 +786,9 @@ export class ModelAgentRuntime implements AgentRuntime {
let answer = ''
let receivedStop = false
let streamEnded = false
const usage = {
reported: false
} satisfies ModelUsageAccumulator
try {
while (!receivedStop) {
@@ -311,7 +811,10 @@ export class ModelAgentRuntime implements AgentRuntime {
}
for (const block of blocks) {
const parsed = parseStreamBlock(block)
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
}
const { delta } = parsed
if (delta) {
answer += delta
@@ -354,6 +857,15 @@ export class ModelAgentRuntime implements AgentRuntime {
{ role: 'assistant', content: answer }
])
const usageEvent = createUsageEvent(
request.requestId,
anthropic ? 'anthropic' : 'openai',
this.options.model,
usage
)
if (usageEvent) {
yield usageEvent
}
yield {
requestId: request.requestId,
type: 'done'
@@ -363,4 +875,9 @@ export class ModelAgentRuntime implements AgentRuntime {
async dispose(): Promise<void> {
this.conversations.clear()
}
releaseConversation(conversationId: string): Promise<void> {
this.conversations.delete(conversationId)
return Promise.resolve()
}
}
+15
View File
@@ -0,0 +1,15 @@
export function createOpenAIApiBaseUrl(baseUrl: string): string {
const url = new URL(baseUrl)
url.pathname = url.pathname.replace(/\/+$/u, '')
url.search = ''
url.hash = ''
return url.toString().replace(/\/$/u, '')
}
export function createOpenAIChatCompletionsUrl(baseUrl: string): URL {
return new URL(`${createOpenAIApiBaseUrl(baseUrl)}/chat/completions`)
}
export function createOpenAIImagesGenerationsUrl(baseUrl: string): URL {
return new URL(`${createOpenAIApiBaseUrl(baseUrl)}/images/generations`)
}
+746 -25
View File
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { resolve } from 'node:path'
import { PassThrough } from 'node:stream'
import type { createOpencodeClient } from '@opencode-ai/sdk'
import type { createOpencodeClient } from '@opencode-ai/sdk/v2'
import type spawn from 'cross-spawn'
import { describe, expect, it, vi } from 'vitest'
import {
@@ -101,6 +101,126 @@ function dependencies(
}
}
function permissionEvent(
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
id: 'event-1',
type: 'permission.asked',
properties: {
id: 'permission-1',
sessionID: 'session-1',
permission: 'bash',
patterns: ['npm test'],
metadata: { command: 'npm test' },
always: ['npm test'],
...overrides
}
}
}
function runClient(events: Record<string, unknown>[]) {
const callOrder: string[] = []
const permissionReply = vi.fn().mockResolvedValue({
data: true,
error: undefined
})
const client = {
session: {
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
create: vi.fn().mockResolvedValue({
data: { id: 'session-1' },
error: undefined
}),
update: vi.fn().mockResolvedValue({
data: { id: 'session-1' },
error: undefined
}),
promptAsync: vi.fn().mockImplementation(async () => {
callOrder.push('prompt')
return { data: true, error: undefined }
}),
abort: vi.fn().mockResolvedValue({
data: true,
error: undefined
}),
delete: vi.fn().mockResolvedValue({
data: true,
error: undefined
})
},
event: {
subscribe: vi.fn().mockImplementation(async () => {
callOrder.push('subscribe')
return {
stream: (async function* () {
for (const event of events) {
yield event
}
})()
}
})
},
permission: {
reply: permissionReply
},
mcp: {
add: vi.fn().mockResolvedValue({ data: true, error: undefined }),
disconnect: vi
.fn()
.mockResolvedValue({ data: true, error: undefined })
},
tool: {
ids: vi.fn().mockResolvedValue({
data: ['read', 'write', 'bash', 'task'],
error: undefined
})
}
} as unknown as ReturnType<typeof createOpencodeClient>
return {
client,
callOrder,
permissionReply,
session: client.session,
event: client.event,
tool: client.tool
}
}
function embeddedRuntime(
client: ReturnType<typeof createOpencodeClient>
): OpenCodeRuntime {
const child = fakeChild()
const { deps } = dependencies(child, {
createClient: vi.fn(
() => client
) as unknown as typeof createOpencodeClient
})
setTimeout(() => {
stdoutOf(child).write(
'opencode server listening on http://127.0.0.1:4010\n'
)
}, 0)
return new OpenCodeRuntime(options(), deps)
}
async function collectRun(runtime: OpenCodeRuntime, workMode: 'ask' | 'plan' | 'execute' = 'execute', authorize?: Parameters<OpenCodeRuntime['run']>[2]) {
const events = []
for await (const event of runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode
},
new AbortController().signal,
authorize
)) {
events.push(event)
}
return events
}
describe('OpenCodeRuntime embedded launcher', () => {
it('uses the detected binary and passes an absolute config path only through env', async () => {
const serverChild = fakeChild(314)
@@ -165,10 +285,50 @@ describe('OpenCodeRuntime embedded launcher', () => {
})
})
)
expect(createClient).toHaveBeenCalledWith({
const clientOptions = (
createClient.mock.calls as unknown as Array<
[
{
baseUrl?: string
directory?: string
headers?: Record<string, string>
}
]
>
)[0]?.[0] as
| {
baseUrl?: string
directory?: string
headers?: Record<string, string>
}
| undefined
expect(clientOptions).toMatchObject({
baseUrl: 'http://127.0.0.1:43210',
directory: process.cwd()
directory: process.cwd(),
headers: {
Authorization: expect.stringMatching(/^Basic /u)
}
})
const spawnOptions = (
spawnMock.mock.calls as unknown as Array<
[string, string[], { env?: NodeJS.ProcessEnv }]
>
)[0]?.[2] as
| { env?: NodeJS.ProcessEnv }
| undefined
expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe(
'goodbuddy'
)
expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy()
expect(
Buffer.from(
clientOptions?.headers?.Authorization?.slice(6) ?? '',
'base64'
).toString()
).toBe(
`goodbuddy:${spawnOptions?.env?.OPENCODE_SERVER_PASSWORD}`
)
expect(runtime.requiresToolApproval).toBe(false)
await runtime.dispose()
@@ -200,7 +360,9 @@ describe('OpenCodeRuntime embedded launcher', () => {
name: '独立模型',
baseUrl: 'https://model.example',
modelName: 'private-model',
apiKey: 'private-key'
apiKey: 'private-key',
protocol: 'anthropic-messages',
authentication: 'api-key'
}
}),
deps
@@ -261,9 +423,14 @@ describe('OpenCodeRuntime embedded launcher', () => {
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
| { env?: NodeJS.ProcessEnv }
| undefined
for (const name of isolatedNames) {
expect(spawnOptions?.env).not.toHaveProperty(name)
}
expect(spawnOptions?.env?.OPENCODE_CONFIG).toBeUndefined()
expect(spawnOptions?.env?.OPENCODE_CONFIG_CONTENT).toBeUndefined()
expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe(
'goodbuddy'
)
expect(
spawnOptions?.env?.OPENCODE_SERVER_PASSWORD
).not.toBe('must-not-be-inherited')
expect(spawnOptions?.env).toMatchObject({
DO_NOT_TRACK: '1',
OPENCODE_DISABLE_AUTOUPDATE: '1',
@@ -390,6 +557,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
baseUrl: 'http://127.0.0.1:4096',
directory: process.cwd()
})
expect(runtime.requiresToolApproval).toBe(true)
})
it('loads assigned Skills and MCP servers before prompting', async () => {
@@ -465,28 +633,27 @@ describe('OpenCodeRuntime embedded launcher', () => {
}
expect(mcpAdd).toHaveBeenCalledWith({
body: {
name: 'goodbuddy-d2ef774b-146c-4467-a909-6feb112a9c2c',
config: {
type: 'local',
command: ['node', 'server.js'],
enabled: true,
timeout: 10_000
}
name: 'goodbuddy-d2ef774b-146c-4467-a909-6feb112a9c2c',
config: {
type: 'local',
command: ['node', 'server.js'],
enabled: true,
timeout: 10_000
},
query: { directory: process.cwd() }
directory: process.cwd()
})
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
body: {
system: '# 文档写作',
tools: {
read: false,
write: false,
'goodbuddy-mcp': false
},
parts: [{ type: 'text', text: 'test' }]
}
system: '# 文档写作',
tools: {
read: false,
write: false,
'goodbuddy-mcp': false
},
parts: [{ type: 'text', text: 'test' }]
}),
expect.objectContaining({
signal: expect.any(AbortSignal)
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
@@ -494,3 +661,557 @@ describe('OpenCodeRuntime embedded launcher', () => {
expect(mcpDisconnect).toHaveBeenCalledOnce()
})
})
describe('OpenCodeRuntime embedded permission mediation', () => {
it('subscribes before prompting and replies once for a session approval', async () => {
const {
client,
callOrder,
permissionReply,
session
} = runClient([
permissionEvent({ sessionID: 'unrelated-session' }),
permissionEvent(),
permissionEvent(),
{
id: 'event-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
messageID: 'message-1',
partID: 'part-1',
field: 'text',
delta: 'approved output'
}
},
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
const authorize = vi.fn().mockResolvedValue('session')
const events = await collectRun(runtime, 'execute', authorize)
expect(callOrder).toEqual(['subscribe', 'prompt'])
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'ask' },
{ permission: 'task', pattern: '*', action: 'deny' }
]
})
expect(authorize).toHaveBeenCalledOnce()
expect(authorize).toHaveBeenCalledWith({
scopeKey: 'opencode:bash',
title: 'OpenCode 请求调用 bash',
description: '仅在你选择允许后,OpenCode 才会执行此工具调用。',
toolName: 'bash',
argumentSummary: JSON.stringify({
patterns: ['npm test'],
metadata: { command: 'npm test' }
}),
allowPermanent: false
})
expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
directory: process.cwd(),
reply: 'once'
})
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: 'approved output'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
it('uses one tool scope for different requests while preserving their summaries', async () => {
const { client } = runClient([
permissionEvent(),
permissionEvent({
id: 'permission-2',
patterns: ['npm run lint'],
metadata: { command: 'npm run lint' },
always: ['npm run lint']
}),
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
const authorize = vi.fn().mockResolvedValue('session')
await collectRun(runtime, 'execute', authorize)
expect(authorize).toHaveBeenCalledTimes(2)
expect(authorize.mock.calls.map(([request]) => request)).toEqual([
expect.objectContaining({
scopeKey: 'opencode:bash',
argumentSummary: JSON.stringify({
patterns: ['npm test'],
metadata: { command: 'npm test' }
})
}),
expect.objectContaining({
scopeKey: 'opencode:bash',
argumentSummary: JSON.stringify({
patterns: ['npm run lint'],
metadata: { command: 'npm run lint' }
})
})
])
await runtime.dispose()
})
it('fails the run when a tool reports an error before session idle', async () => {
const { client, session } = runClient([
{
id: 'event-tool-error',
type: 'message.part.updated',
properties: {
sessionID: 'session-1',
part: {
id: 'part-1',
callID: 'call-1',
type: 'tool',
tool: 'write',
state: { status: 'error' }
}
}
},
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
await expect(collectRun(runtime)).rejects.toThrow(
'OpenCode 工具执行失败'
)
expect(session.abort).toHaveBeenCalledOnce()
await runtime.dispose()
})
it('surfaces a rejected async prompt instead of reporting success', async () => {
const { client, session } = runClient([
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
vi.mocked(session.promptAsync).mockResolvedValueOnce({
data: undefined,
error: {
data: {
message:
'prompt rejected Authorization: Bearer secret-token'
}
}
} as never)
const runtime = embeddedRuntime(client)
await expect(collectRun(runtime)).rejects.toThrow(
'prompt rejected Authorization: [REDACTED]'
)
await runtime.dispose()
})
it('deletes an ephemeral OpenCode session when released', async () => {
const { client, session } = runClient([
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
await collectRun(runtime)
await runtime.releaseConversation('conversation-1')
expect(session.delete).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd()
})
await runtime.dispose()
})
it.each(['deny', 'permanent'] as const)(
'rejects an OpenCode permission after a %s decision',
async (decision) => {
const { client, permissionReply } = runClient([
permissionEvent(),
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
await collectRun(
runtime,
'execute',
vi.fn().mockResolvedValue(decision)
)
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
directory: process.cwd(),
reply: 'reject'
})
await runtime.dispose()
}
)
it('ignores unrelated requests and rejects bounded malformed requests without prompting', async () => {
const { client, permissionReply } = runClient([
permissionEvent({ sessionID: 'unrelated-session' }),
permissionEvent({
patterns: Array.from({ length: 33 }, () => '*')
}),
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
const authorize = vi.fn().mockResolvedValue('once')
await collectRun(runtime, 'execute', authorize)
expect(authorize).not.toHaveBeenCalled()
expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
directory: process.cwd(),
reply: 'reject'
})
await runtime.dispose()
})
it('fails closed when the OpenCode permission reply fails', async () => {
const { client, permissionReply, session } = runClient([
permissionEvent()
])
permissionReply.mockResolvedValue({
data: false,
error: { message: 'secret server error' }
})
const runtime = embeddedRuntime(client)
await expect(
collectRun(
runtime,
'execute',
vi.fn().mockResolvedValue('once')
)
).rejects.toThrow('OpenCode 权限回复失败')
expect(session.abort).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd()
})
await runtime.dispose()
})
it('rejects a pending permission and aborts the session on cancellation', async () => {
const { client, permissionReply, session } = runClient([
permissionEvent()
])
const runtime = embeddedRuntime(client)
const controller = new AbortController()
const authorize = vi.fn(
() =>
new Promise<never>((_resolve, reject) => {
controller.signal.addEventListener(
'abort',
() => reject(new Error('cancelled')),
{ once: true }
)
})
)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode: 'execute'
},
controller.signal,
authorize
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const pending = stream.next()
await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce())
controller.abort()
await expect(pending).rejects.toThrow('cancelled')
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
directory: process.cwd(),
reply: 'reject'
})
expect(session.abort).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd()
})
await runtime.dispose()
})
it.each(['ask', 'plan'] as const)(
'uses deny-all session rules and hard tool disable in %s mode',
async (workMode) => {
const { client, session, tool } = runClient([
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
await collectRun(runtime, workMode)
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(tool.ids).toHaveBeenCalledWith({
directory: process.cwd()
})
expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
read: false,
write: false,
bash: false,
task: false
}
}),
expect.anything()
)
await runtime.dispose()
}
)
it('updates reused sessions when the work mode changes', async () => {
const { client, session } = runClient([
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
await collectRun(runtime, 'execute')
await collectRun(runtime, 'ask')
expect(session.update).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
await runtime.dispose()
})
it('leaves external sessions unmodified for the controller whole-run gate', async () => {
const { client, session, permissionReply } = runClient([
permissionEvent(),
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
{
createClient: vi.fn(
() => client
) as unknown as typeof createOpencodeClient
}
)
const authorize = vi.fn().mockResolvedValue('once')
await collectRun(runtime, 'execute', authorize)
expect(runtime.requiresToolApproval).toBe(true)
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd()
})
expect(authorize).not.toHaveBeenCalled()
expect(permissionReply).not.toHaveBeenCalled()
await runtime.dispose()
})
})
describe('OpenCodeRuntime model usage', () => {
it('emits one provider-reported usage event for each terminal assistant message', async () => {
const assistantMessage = {
id: 'message-assistant-1',
sessionID: 'session-1',
role: 'assistant',
time: {
created: 1,
completed: 2
},
parentID: 'message-user-1',
modelID: 'claude-sonnet-provider',
providerID: 'anthropic',
mode: 'build',
agent: 'build',
path: {
cwd: process.cwd(),
root: process.cwd()
},
cost: 0.01,
tokens: {
total: 42,
input: 23,
output: 11,
reasoning: 3,
cache: {
read: 7,
write: 5
}
}
}
const { client } = runClient([
{
id: 'event-incomplete',
type: 'message.updated',
properties: {
sessionID: 'session-1',
info: {
...assistantMessage,
time: { created: 1 }
}
}
},
{
id: 'event-terminal',
type: 'message.updated',
properties: {
sessionID: 'session-1',
info: assistantMessage
}
},
{
id: 'event-terminal-duplicate',
type: 'message.updated',
properties: {
sessionID: 'session-1',
info: assistantMessage
}
},
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
const events = await collectRun(runtime)
expect(
events.filter((event) => event.type === 'model-usage')
).toEqual([
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'model-usage',
callId: 'message-assistant-1',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet-provider',
inputTokens: 23,
outputTokens: 11,
cacheReadTokens: 7,
cacheWriteTokens: 5,
reportedTotalTokens: 42
}
])
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
it('ignores assistant usage from another session', async () => {
const { client } = runClient([
{
id: 'event-unrelated-usage',
type: 'message.updated',
properties: {
sessionID: 'session-2',
info: {
id: 'message-assistant-2',
sessionID: 'session-2',
role: 'assistant',
time: {
created: 1,
completed: 2
},
parentID: 'message-user-2',
modelID: 'unrelated-model',
providerID: 'unrelated-provider',
mode: 'build',
agent: 'build',
path: {
cwd: process.cwd(),
root: process.cwd()
},
cost: 0,
tokens: {
input: 100,
output: 50,
reasoning: 0,
cache: {
read: 0,
write: 0
}
}
}
}
},
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
const events = await collectRun(runtime)
expect(
events.filter((event) => event.type === 'model-usage')
).toEqual([])
await runtime.dispose()
})
})
+473 -71
View File
@@ -1,34 +1,205 @@
import {
createOpencodeClient,
type OpencodeClient
} from '@opencode-ai/sdk'
type AssistantMessage,
type OpencodeClient,
type PermissionRequest,
type PermissionRuleset
} from '@opencode-ai/sdk/v2'
import spawn from 'cross-spawn'
import { randomBytes } from 'node:crypto'
import { resolve } from 'node:path'
import type {
AgentEvent,
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntimeStatus } from '../../shared/contracts'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import type {
AgentExecutionRequest,
AgentRuntime
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent,
RuntimeModelUsageEvent
} from './runtime'
import { detectRuntimeBinary } from './runtime-discovery'
import { getAvailableLoopbackPort } from './loopback-port'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { buildRuntimeEnvironment } from './process-environment'
import {
buildBubblewrapLaunch,
type RuntimeSandboxResolution
} from './runtime-sandbox'
import {
redactSensitiveText,
safeToolArgumentSummary
} from './approval-summary'
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
const STARTUP_TIMEOUT_MS = 10_000
const MAX_PERMISSION_NAME_LENGTH = 128
const MAX_PERMISSION_PATTERNS = 32
const MAX_PERMISSION_PATTERN_LENGTH = 1_024
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_PERMISSION_SUMMARY_LENGTH = 2_000
const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
type SpawnedProcess = ReturnType<typeof spawn>
type OpenCodeServer = {
url: string
authorization: string
close: () => Promise<void>
}
const executePermissionRules: PermissionRuleset = [
{ permission: '*', pattern: '*', action: 'ask' },
{ permission: 'task', pattern: '*', action: 'deny' }
]
const readOnlyPermissionRules: PermissionRuleset = [
{ permission: '*', pattern: '*', action: 'deny' }
]
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
function opencodeErrorMessage(value: unknown, fallback: string): string {
if (!isRecord(value)) {
return fallback
}
if (typeof value.message === 'string' && value.message.trim()) {
return redactSensitiveText(value.message).slice(0, 1_000)
}
if (
isRecord(value.data) &&
typeof value.data.message === 'string' &&
value.data.message.trim()
) {
return redactSensitiveText(value.data.message).slice(0, 1_000)
}
return fallback
}
function byteLengthWithin(value: string, maximum: number): boolean {
return Buffer.byteLength(value) <= maximum
}
function areBoundedPatterns(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.length <= MAX_PERMISSION_PATTERNS &&
value.every(
(pattern) =>
typeof pattern === 'string' &&
pattern.length <= MAX_PERMISSION_PATTERN_LENGTH
) &&
byteLengthWithin(
value.join('\0'),
MAX_PERMISSION_PATTERNS_BYTES
)
)
}
function parsePermissionRequest(
properties: unknown,
sessionId: string
): PermissionRequest | undefined {
if (!isRecord(properties) || properties.sessionID !== sessionId) {
return undefined
}
const { id, permission, patterns, metadata, always, tool } =
properties
if (
typeof id !== 'string' ||
id.length === 0 ||
id.length > MAX_PERMISSION_NAME_LENGTH ||
typeof permission !== 'string' ||
permission.length === 0 ||
permission.length > MAX_PERMISSION_NAME_LENGTH ||
!areBoundedPatterns(patterns) ||
!isRecord(metadata) ||
!areBoundedPatterns(always) ||
(tool !== undefined &&
(!isRecord(tool) ||
typeof tool.messageID !== 'string' ||
typeof tool.callID !== 'string'))
) {
throw new Error('OpenCode 权限请求格式无效')
}
let serializedMetadata: string
try {
serializedMetadata = JSON.stringify(metadata)
} catch {
throw new Error('OpenCode 权限请求元数据无效')
}
if (
!byteLengthWithin(
serializedMetadata,
MAX_PERMISSION_METADATA_BYTES
)
) {
throw new Error('OpenCode 权限请求元数据超过安全限制')
}
return properties as PermissionRequest
}
function permissionArgumentSummary(
request: PermissionRequest
): string {
return safeToolArgumentSummary(
{
patterns: request.patterns,
metadata: request.metadata
},
undefined,
MAX_PERMISSION_SUMMARY_LENGTH
)
}
function permissionScopeKey(request: PermissionRequest): string {
return `opencode:${request.permission}`
}
function isSafeTokenCount(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0
}
function createUsageEvent(
requestId: string,
message: AssistantMessage
): RuntimeModelUsageEvent | undefined {
const { tokens } = message
if (
message.time.completed === undefined ||
!isSafeTokenCount(tokens.input) ||
!isSafeTokenCount(tokens.output) ||
!isSafeTokenCount(tokens.cache.read) ||
!isSafeTokenCount(tokens.cache.write) ||
(tokens.total !== undefined && !isSafeTokenCount(tokens.total))
) {
return undefined
}
return {
requestId,
type: 'model-usage',
callId: message.id.slice(0, 256),
runtime: 'opencode',
provider: message.providerID.slice(0, 100),
model: message.modelID.slice(0, 500),
inputTokens: tokens.input,
outputTokens: tokens.output,
cacheReadTokens: tokens.cache.read,
cacheWriteTokens: tokens.cache.write,
...(tokens.total === undefined
? {}
: { reportedTotalTokens: tokens.total })
}
}
export type OpenCodeRuntimeDependencies = {
spawn: typeof spawn
detectBinary: (
@@ -51,6 +222,7 @@ export type OpenCodeRuntimeOptions = {
modelProfile?: ResolvedModelProfile
skillInstructions?: string
mcpServers?: ResolvedMcpServer[]
sandbox?: RuntimeSandboxResolution
}
async function defaultDetectBinary(
@@ -104,7 +276,10 @@ function parseListeningUrl(output: string): string | undefined {
}
export class OpenCodeRuntime implements AgentRuntime {
readonly requiresToolApproval = true
get requiresToolApproval(): boolean {
return !this.usesEmbeddedPermissionMediation()
}
readonly supportsToolExecution = true
private client?: OpencodeClient
private clientInitialization?: Promise<OpencodeClient>
private server?: OpenCodeServer
@@ -133,6 +308,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
private usesEmbeddedPermissionMediation(): boolean {
return this.options.embedded && !this.options.baseUrl
}
private terminate(child: SpawnedProcess): void {
if (child.exitCode !== null) {
return
@@ -195,6 +374,12 @@ export class OpenCodeRuntime implements AgentRuntime {
delete env.OPENCODE_CONFIG_CONTENT
delete env.OPENCODE_SERVER_PASSWORD
delete env.OPENCODE_SERVER_USERNAME
const serverPassword = randomBytes(32).toString('base64url')
const authorization = `Basic ${Buffer.from(
`${EMBEDDED_SERVER_USERNAME}:${serverPassword}`
).toString('base64')}`
env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME
env.OPENCODE_SERVER_PASSWORD = serverPassword
env.DO_NOT_TRACK = '1'
env.OPENCODE_DISABLE_AUTOUPDATE = '1'
env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1'
@@ -224,15 +409,36 @@ export class OpenCodeRuntime implements AgentRuntime {
} else if (this.options.configPath.trim()) {
env.OPENCODE_CONFIG = resolve(this.options.configPath)
}
const serverArgs = [
'serve',
'--hostname=127.0.0.1',
`--port=${port}`
]
const sandbox = this.options.sandbox
if (
sandbox?.status.mode === 'strict' &&
!sandbox.status.available
) {
throw new Error(sandbox.status.detail)
}
const launch =
sandbox?.status.available && sandbox.binaryPath
? buildBubblewrapLaunch({
binaryPath: sandbox.binaryPath,
command: binaryPath,
args: serverArgs,
workspace: this.options.defaultWorkspace,
readOnlyPaths: this.options.configPath.trim()
? [resolve(this.options.configPath)]
: [],
platform: this.dependencies.platform
})
: { command: binaryPath, args: serverArgs }
return new Promise<OpenCodeServer>((resolveServer, reject) => {
const child = this.dependencies.spawn(
binaryPath,
[
'serve',
'--hostname=127.0.0.1',
`--port=${port}`
],
launch.command,
launch.args,
{
cwd: this.options.defaultWorkspace,
env,
@@ -281,6 +487,7 @@ export class OpenCodeRuntime implements AgentRuntime {
stderr?.resume()
resolveServer({
url,
authorization,
close: async () => {
const exited = this.waitForExit(child)
this.terminate(child)
@@ -368,7 +575,14 @@ export class OpenCodeRuntime implements AgentRuntime {
this.client = this.dependencies.createClient({
baseUrl,
directory: this.options.defaultWorkspace
directory: this.options.defaultWorkspace,
...(this.server
? {
headers: {
Authorization: this.server.authorization
}
}
: {})
})
return this.client
}
@@ -377,7 +591,7 @@ export class OpenCodeRuntime implements AgentRuntime {
try {
const client = await this.getClient()
const response = await client.session.list({
query: { directory: this.options.defaultWorkspace }
directory: this.options.defaultWorkspace
})
if (response.error) {
@@ -388,8 +602,11 @@ export class OpenCodeRuntime implements AgentRuntime {
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: this.supportsToolExecution,
detail: this.server
? '由 GoodBuddy 管理本机 OpenCode 进程'
? this.options.sandbox
? `由 GoodBuddy 管理本机 OpenCode 进程;${this.options.sandbox.status.detail}`
: '由 GoodBuddy 管理本机 OpenCode 进程'
: `已连接 ${this.options.baseUrl}`
}
} catch (error) {
@@ -397,6 +614,7 @@ export class OpenCodeRuntime implements AgentRuntime {
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail: error instanceof Error ? error.message : 'OpenCode 不可用'
}
}
@@ -405,7 +623,8 @@ export class OpenCodeRuntime implements AgentRuntime {
private async getSessionId(
client: OpencodeClient,
request: AgentExecutionRequest,
directory: string
directory: string,
permission?: PermissionRuleset
): Promise<{ id: string; created: boolean }> {
const current = this.sessions.get(request.conversationId)
if (current) {
@@ -419,8 +638,9 @@ export class OpenCodeRuntime implements AgentRuntime {
}
const creation = client.session
.create({
body: { title: 'GoodBuddy 对话' },
query: { directory }
title: 'GoodBuddy 对话',
directory,
...(permission ? { permission } : {})
})
.then((response) => {
if (!response.data) {
@@ -477,8 +697,9 @@ export class OpenCodeRuntime implements AgentRuntime {
timeout: 10_000
}
const response = await client.mcp.add({
body: { name, config },
query: { directory: this.options.defaultWorkspace }
name,
config,
directory: this.options.defaultWorkspace
})
if (response.error) {
throw new Error(`OpenCode 无法加载 MCP Server${server.name}`)
@@ -490,8 +711,9 @@ export class OpenCodeRuntime implements AgentRuntime {
async *run(
request: AgentExecutionRequest,
signal: AbortSignal
): AsyncGenerator<AgentEvent, void, void> {
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted()
if (request.images?.length) {
throw new Error('OpenCode Runtime 暂不支持图片上下文,请切换到视觉模型')
@@ -499,10 +721,15 @@ export class OpenCodeRuntime implements AgentRuntime {
const client = await this.getClient(signal)
await this.configureCapabilities(client)
const directory = this.options.defaultWorkspace
const permission = this.usesEmbeddedPermissionMediation()
? request.workMode === 'execute'
? executePermissionRules
: readOnlyPermissionRules
: undefined
let disabledTools: Record<string, boolean> | undefined
if (request.workMode !== 'execute') {
const tools = await client.tool.ids({
query: { directory }
directory
})
if (tools.error || !tools.data) {
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
@@ -511,8 +738,23 @@ export class OpenCodeRuntime implements AgentRuntime {
tools.data.map((toolId) => [toolId, false])
)
}
const session = await this.getSessionId(client, request, directory)
const session = await this.getSessionId(
client,
request,
directory,
permission
)
const sessionId = session.id
if (!session.created && permission) {
const update = await client.session.update({
sessionID: sessionId,
directory,
permission
})
if (update.error || !update.data) {
throw new Error('OpenCode 会话权限配置失败')
}
}
yield {
requestId: request.requestId,
@@ -521,14 +763,13 @@ export class OpenCodeRuntime implements AgentRuntime {
}
const subscription = await client.event.subscribe({
query: { directory },
signal
})
directory
}, { signal })
const abortSession = (): void => {
void client.session.abort({
path: { id: sessionId },
query: { directory }
sessionID: sessionId,
directory
}).catch(() => undefined)
}
signal.addEventListener('abort', abortSession, { once: true })
@@ -544,69 +785,204 @@ export class OpenCodeRuntime implements AgentRuntime {
].join('\n')
: request.prompt
const prompt = client.session.promptAsync({
body: {
model: this.options.modelProfile
? {
providerID: 'anthropic',
modelID: this.options.modelProfile.modelName
}
: undefined,
system: this.options.skillInstructions || undefined,
...(disabledTools ? { tools: disabledTools } : {}),
parts: [{ type: 'text', text: promptText }]
},
path: { id: sessionId },
query: { directory },
signal
})
sessionID: sessionId,
directory,
model: this.options.modelProfile
? {
providerID: 'anthropic',
modelID: this.options.modelProfile.modelName
}
: undefined,
system: this.options.skillInstructions || undefined,
...(disabledTools ? { tools: disabledTools } : {}),
parts: [{ type: 'text', text: promptText }]
}, { signal })
prompt.catch(() => undefined)
const repliedPermissionIds = new Set<string>()
const reportedMessageIds = new Set<string>()
const toolStates = new Map<
string,
'pending' | 'running' | 'completed' | 'failed'
>()
for await (const event of subscription.stream) {
if (
event.type === 'message.part.updated' &&
event.properties.part.sessionID === sessionId
event.type === 'message.updated' &&
event.properties.sessionID === sessionId &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === 'assistant' &&
!reportedMessageIds.has(event.properties.info.id)
) {
const { part, delta } = event.properties
if (part.type === 'text' && delta) {
yield {
requestId: request.requestId,
type: 'text',
delta
}
} else if (part.type === 'tool') {
const usage = createUsageEvent(
request.requestId,
event.properties.info
)
if (usage) {
reportedMessageIds.add(event.properties.info.id)
yield usage
}
}
if (
event.type === 'message.part.delta' &&
event.properties.sessionID === sessionId &&
event.properties.field === 'text' &&
event.properties.delta
) {
yield {
requestId: request.requestId,
type: 'text',
delta: event.properties.delta
}
}
if (
event.type === 'message.part.updated' &&
event.properties.sessionID === sessionId
) {
const { part } = event.properties
if (part.type === 'tool') {
const callId = (part.callID || part.id).slice(0, 256)
const toolName = part.tool.slice(0, 200)
const state =
part.state.status === 'error' ? 'failed' : part.state.status
toolStates.set(callId, state)
yield {
requestId: request.requestId,
type: 'tool',
name: part.tool,
callId,
name: toolName,
state,
summary: `OpenCode 工具:${part.tool}`
summary: `OpenCode 工具:${toolName}`
}
}
}
if (
this.usesEmbeddedPermissionMediation() &&
event.type === 'permission.asked'
) {
const properties = event.properties as unknown
if (
isRecord(properties) &&
typeof properties.sessionID === 'string' &&
properties.sessionID !== sessionId
) {
continue
}
let permissionRequest: PermissionRequest
try {
const parsed = parsePermissionRequest(properties, sessionId)
if (!parsed) {
throw new Error('OpenCode 权限请求格式无效')
}
permissionRequest = parsed
} catch (error) {
if (
isRecord(properties) &&
typeof properties.id === 'string' &&
properties.id.length > 0 &&
properties.id.length <= MAX_PERMISSION_NAME_LENGTH &&
!repliedPermissionIds.has(properties.id)
) {
repliedPermissionIds.add(properties.id)
const rejection = await client.permission.reply({
requestID: properties.id,
directory,
reply: 'reject'
})
if (rejection.error || rejection.data !== true) {
throw new Error('OpenCode 权限拒绝回复失败', {
cause: error
})
}
continue
}
throw error
}
if (repliedPermissionIds.has(permissionRequest.id)) {
continue
}
repliedPermissionIds.add(permissionRequest.id)
let decision: Awaited<ReturnType<NonNullable<typeof authorize>>>
try {
decision = authorize
? await authorize({
scopeKey: permissionScopeKey(permissionRequest),
title: `OpenCode 请求调用 ${permissionRequest.permission}`,
description:
'仅在你选择允许后,OpenCode 才会执行此工具调用。',
toolName: permissionRequest.permission,
argumentSummary:
permissionArgumentSummary(permissionRequest),
allowPermanent: false
})
: 'deny'
} catch (error) {
const rejection = await client.permission.reply({
requestID: permissionRequest.id,
directory,
reply: 'reject'
})
if (rejection.error || rejection.data !== true) {
throw new Error('OpenCode 权限拒绝回复失败', {
cause: error
})
}
throw error
}
const reply =
decision === 'once' || decision === 'session'
? 'once'
: 'reject'
const response = await client.permission.reply({
requestID: permissionRequest.id,
directory,
reply
})
if (response.error || response.data !== true) {
throw new Error('OpenCode 权限回复失败')
}
}
if (
event.type === 'session.error' &&
event.properties.sessionID === sessionId
) {
const error = event.properties.error
const message =
error &&
typeof error.data === 'object' &&
error.data &&
'message' in error.data &&
typeof error.data.message === 'string'
? error.data.message
: 'OpenCode 执行失败'
throw new Error(message)
throw new Error(
opencodeErrorMessage(error, 'OpenCode 执行失败')
)
}
if (
event.type === 'session.idle' &&
event.properties.sessionID === sessionId
) {
await prompt
const promptResult = await prompt
if (promptResult.error) {
throw new Error(
opencodeErrorMessage(
promptResult.error,
'OpenCode 提交请求失败'
)
)
}
const unsuccessfulTool = [...toolStates.entries()].find(
([, state]) => state !== 'completed'
)
if (unsuccessfulTool) {
const [callId, state] = unsuccessfulTool
throw new Error(
state === 'failed'
? `OpenCode 工具执行失败(${callId.slice(0, 128)}`
: `OpenCode 工具未完成(${callId.slice(0, 128)}`
)
}
yield {
requestId: request.requestId,
type: 'done',
@@ -616,8 +992,19 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
await prompt
const promptResult = await prompt
if (promptResult.error) {
throw new Error(
opencodeErrorMessage(
promptResult.error,
'OpenCode 提交请求失败'
)
)
}
throw new Error('OpenCode 事件流意外结束')
} catch (error) {
abortSession()
throw error
} finally {
signal.removeEventListener('abort', abortSession)
}
@@ -636,13 +1023,14 @@ export class OpenCodeRuntime implements AgentRuntime {
this.client = undefined
this.clientInitialization = undefined
this.capabilityInitialization = undefined
this.sessions.clear()
this.sessionInitializations.clear()
await Promise.all(
[...this.configuredMcpNames].map((name) =>
client?.mcp
.disconnect({
path: { name },
query: { directory: this.options.defaultWorkspace }
name,
directory: this.options.defaultWorkspace
})
.catch(() => undefined)
)
@@ -650,4 +1038,18 @@ export class OpenCodeRuntime implements AgentRuntime {
this.configuredMcpNames.clear()
await server?.close()
}
async releaseConversation(conversationId: string): Promise<void> {
const sessionId = this.sessions.get(conversationId)
this.sessions.delete(conversationId)
if (!sessionId || !this.client) {
return
}
await this.client.session
.delete({
sessionID: sessionId,
directory: this.options.defaultWorkspace
})
.catch(() => undefined)
}
}
+49 -3
View File
@@ -16,7 +16,8 @@ class TestRuntime implements AgentRuntime {
constructor(
private readonly delayed = false,
readonly requiresToolApproval = false,
private readonly invokeToolAuthorization = false
private readonly invokeToolAuthorization = false,
readonly supportsToolExecution = true
) {
this.started = new Promise((resolve) => {
this.markStarted = resolve
@@ -28,6 +29,7 @@ class TestRuntime implements AgentRuntime {
id: 'model',
label: 'Test',
available: true,
supportsToolExecution: this.supportsToolExecution,
detail: 'Test runtime'
})
}
@@ -66,7 +68,7 @@ class TestRuntime implements AgentRuntime {
}
describe('AgentRuntimeController', () => {
it('suppresses retired runtime events and disposes it after requests exit', async () => {
it('fails retired runtime requests and disposes them after exit', async () => {
const previous = new TestRuntime(true, true)
const next = new TestRuntime()
const controller = new AgentRuntimeController(previous)
@@ -92,7 +94,9 @@ describe('AgentRuntimeController', () => {
const replacement = controller.replace(next)
previous.finish()
await expect(pendingEvent).resolves.toMatchObject({ done: true })
await expect(pendingEvent).rejects.toThrow(
'Runtime 已切换,当前请求已中断'
)
await replacement
expect(previous.dispose).toHaveBeenCalledOnce()
await expect(controller.getStatus()).resolves.toMatchObject({
@@ -121,4 +125,46 @@ describe('AgentRuntimeController', () => {
expect(authorize).not.toHaveBeenCalled()
}
)
it('forwards per-tool authorization without adding a whole-run gate', async () => {
const runtime = new TestRuntime(false, false, true)
const controller = new AgentRuntimeController(runtime)
const authorize = vi.fn(async () => 'session' as const)
const stream = controller.run(
{
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b10',
conversationId: 'conversation-4',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal,
authorize
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'text' }
})
expect(authorize).toHaveBeenCalledOnce()
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({ scopeKey: 'test:tool' })
)
})
it('rejects Execute mode when the runtime cannot execute tools', async () => {
const runtime = new TestRuntime(false, false, false, false)
const controller = new AgentRuntimeController(runtime)
const stream = controller.run(
{
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b11',
conversationId: 'conversation-5',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).rejects.toThrow(
'当前 Runtime 不支持工具执行'
)
})
})
+38 -8
View File
@@ -1,11 +1,11 @@
import type {
AgentEvent,
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type {
AgentRuntime,
RuntimeAuthorizer
RuntimeAuthorizer,
RuntimeEvent
} from './runtime'
type RuntimeSlot = {
@@ -33,6 +33,14 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.requiresToolApproval
}
get supportsToolExecution(): boolean {
return this.current.runtime.supportsToolExecution
}
get capability(): AgentRuntime['capability'] {
return this.current.runtime.capability
}
replace(next: AgentRuntime): Promise<void> {
if (this.closing) {
return next.dispose().then(() => {
@@ -60,19 +68,31 @@ export class AgentRuntimeController implements AgentRuntime {
])
}
getStatus(): Promise<AgentRuntimeStatus> {
return this.current.runtime.getStatus()
async getStatus(): Promise<AgentRuntimeStatus> {
const slot = this.current
const status = await slot.runtime.getStatus()
return {
...status,
supportsToolExecution: slot.runtime.supportsToolExecution
}
}
testConnection(): Promise<AgentRuntimeStatus> {
return this.current.runtime.testConnection?.() ?? this.getStatus()
async testConnection(): Promise<AgentRuntimeStatus> {
const slot = this.current
const status = await (
slot.runtime.testConnection?.() ?? slot.runtime.getStatus()
)
return {
...status,
supportsToolExecution: slot.runtime.supportsToolExecution
}
}
async *run(
request: AgentRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<AgentEvent, void, void> {
): AsyncGenerator<RuntimeEvent, void, void> {
const slot = this.current
const toolsAllowed = request.workMode === 'execute'
const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed
@@ -80,6 +100,9 @@ export class AgentRuntimeController implements AgentRuntime {
: async () => 'deny'
slot.activeRequests += 1
try {
if (toolsAllowed && !slot.runtime.supportsToolExecution) {
throw new Error('当前 Runtime 不支持工具执行,请切换到 OpenCode 或 Continue')
}
if (
toolsAllowed &&
slot.runtime.requiresToolApproval &&
@@ -102,10 +125,13 @@ export class AgentRuntimeController implements AgentRuntime {
effectiveAuthorize
)) {
if (slot !== this.current) {
return
throw new Error('Runtime 已切换,当前请求已中断')
}
yield event
}
if (slot !== this.current) {
throw new Error('Runtime 已切换,当前请求已中断')
}
} finally {
slot.activeRequests -= 1
if (slot.retiring && slot.activeRequests === 0) {
@@ -114,6 +140,10 @@ export class AgentRuntimeController implements AgentRuntime {
}
}
async releaseConversation(conversationId: string): Promise<void> {
await this.current.runtime.releaseConversation?.(conversationId)
}
private retire(slot: RuntimeSlot): Promise<void> {
slot.retiring = true
if (!slot.disposal) {
+21
View File
@@ -1,5 +1,6 @@
import { realpath } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import {
detectAgentRuntimes,
@@ -105,6 +106,26 @@ describe('runtime discovery', () => {
expect(detection.detail).toContain('内置')
})
it('allows a bundled script to defer execution validation to its host adapter', async () => {
process.env.PATH = ''
process.env.Path = ''
const bundledScript = fileURLToPath(import.meta.url)
const detection = await detectRuntimeBinary({
binaryPath: '',
bundledPath: bundledScript,
bundledValidation: 'canonical-file',
binaryNames: ['goodbuddy-runtime-that-does-not-exist'],
label: 'Script Runtime'
})
expect(detection).toMatchObject({
available: true,
path: await realpath(bundledScript)
})
expect(detection.detail).toBe('内置 Script Runtime 已就绪')
})
it('returns both runtime detections without exposing PATH contents', async () => {
const privatePathValue = `${dirname(process.execPath)}-private-path-value`
process.env.PATH = privatePathValue
+10
View File
@@ -20,6 +20,7 @@ const VERSION_OUTPUT_LIMIT = 8 * 1024
export type RuntimeBinaryDiscoveryInput = {
binaryPath: string
bundledPath?: string
bundledValidation?: 'execute' | 'canonical-file'
binaryNames: readonly string[]
label: string
}
@@ -288,6 +289,14 @@ export async function detectRuntimeBinary(
if (bundledPath) {
const canonicalPath = await canonicalFile(bundledPath)
if (canonicalPath) {
if (input.bundledValidation === 'canonical-file') {
return availableDetection(
input.label,
canonicalPath,
undefined,
true
)
}
const validation = await validateVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
@@ -352,6 +361,7 @@ export async function detectAgentRuntimes(input: {
detectRuntimeBinary({
binaryPath: input.continueBinaryPath,
bundledPath: input.bundledPaths?.continue,
bundledValidation: 'canonical-file',
binaryNames: ['cn'],
label: 'Continue CLI'
})
+20 -7
View File
@@ -2,11 +2,11 @@ import { 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 type { AgentEvent } 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'
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
const apiKey = process.env.ANTHROPIC_API_KEY ?? ''
@@ -22,7 +22,7 @@ const portableRoot = join(
)
async function collectText(
events: AsyncGenerator<AgentEvent, void, void>
events: AsyncGenerator<RuntimeEvent, void, void>
): Promise<string> {
let output = ''
for await (const event of events) {
@@ -56,7 +56,9 @@ describe.runIf(enabled)('runtime end-to-end', () => {
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl,
model: modelName
model: modelName,
protocol: 'anthropic-messages',
authentication: 'api-key'
})
try {
@@ -86,7 +88,9 @@ describe.runIf(enabled)('runtime end-to-end', () => {
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl,
model: modelName
model: modelName,
protocol: 'anthropic-messages',
authentication: 'api-key'
})
const abortController = new AbortController()
@@ -135,7 +139,9 @@ describe.runIf(enabled)('runtime end-to-end', () => {
name: 'E2E model',
baseUrl,
modelName,
apiKey
apiKey,
protocol: 'anthropic-messages',
authentication: 'api-key'
}
})
)
@@ -158,7 +164,12 @@ describe.runIf(enabled)('runtime end-to-end', () => {
}
)
)
expect(approvals).toContain('runtime:whole-run')
expect(approvals).not.toContain('runtime:whole-run')
expect(approvals).toEqual(
expect.arrayContaining([
expect.stringMatching(/^opencode:/u)
])
)
await expect(
readFile(join(workspace, 'opencode-output.txt'), 'utf8')
).resolves.toBe('OPENCODE_E2E_OK')
@@ -192,7 +203,9 @@ describe.runIf(enabled)('runtime end-to-end', () => {
name: 'E2E model',
baseUrl,
modelName,
apiKey
apiKey,
protocol: 'anthropic-messages',
authentication: 'api-key'
}
})
)
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildBubblewrapLaunch,
resolveRuntimeSandbox
} from './runtime-sandbox'
describe('resolveRuntimeSandbox', () => {
it('reports bubblewrap enforcement only after a successful Linux probe', () => {
const probe = vi.fn(() => true)
expect(resolveRuntimeSandbox('auto', 'linux', probe)).toEqual({
binaryPath: 'bwrap',
status: {
mode: 'auto',
enforcement: 'bubblewrap',
available: true,
detail:
'Linux bubblewrap 文件系统沙箱已启用,网络仍按模型连接配置开放'
}
})
expect(probe).toHaveBeenCalledWith('bwrap')
})
it('fails closed when strict mode is unavailable', () => {
expect(
resolveRuntimeSandbox('strict', 'linux', () => false)
).toMatchObject({
status: {
mode: 'strict',
enforcement: 'unavailable',
available: false
}
})
expect(
resolveRuntimeSandbox('strict', 'win32', () => true).status.detail
).toContain('仅支持')
})
it('does not probe when sandboxing is disabled', () => {
const probe = vi.fn(() => true)
expect(resolveRuntimeSandbox('off', 'linux', probe).status).toMatchObject({
enforcement: 'disabled',
available: false
})
expect(probe).not.toHaveBeenCalled()
})
})
describe('buildBubblewrapLaunch', () => {
it('mounts only system roots, explicit runtime paths, and writable workspace paths', () => {
const launch = buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: '/opt/goodbuddy/node',
args: ['/data/runtime/index.js', 'serve'],
workspace: '/work/project',
readOnlyPaths: ['/data/runtime/index.js'],
writablePaths: ['/data/runtime/cache'],
platform: 'linux'
})
expect(launch.command).toBe('bwrap')
expect(launch.args).toContain('--unshare-all')
expect(launch.args).toContain('--share-net')
expect(launch.args).toContain('/opt/goodbuddy/node')
expect(launch.args).toContain('/data/runtime/index.js')
expect(launch.args).toContain('/data/runtime/cache')
expect(launch.args).toContain('/work/project')
expect(launch.args.slice(-3)).toEqual([
'/opt/goodbuddy/node',
'/data/runtime/index.js',
'serve'
])
})
it('rejects relative mounts and non-Linux use', () => {
expect(() =>
buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: 'node',
args: [],
workspace: 'relative',
platform: 'linux'
})
).toThrow('绝对路径')
expect(() =>
buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: 'node',
args: [],
workspace: 'C:\\work',
platform: 'win32'
})
).toThrow('仅支持 Linux')
})
it('rejects writable system mounts', () => {
expect(() =>
buildBubblewrapLaunch({
binaryPath: 'bwrap',
command: '/usr/bin/opencode',
args: [],
workspace: '/etc',
platform: 'linux'
})
).toThrow('系统路径')
})
})
+240
View File
@@ -0,0 +1,240 @@
import { spawnSync } from 'node:child_process'
import { posix } from 'node:path'
export type RuntimeSandboxMode = 'off' | 'auto' | 'strict'
export type RuntimeSandboxStatus = {
mode: RuntimeSandboxMode
enforcement: 'disabled' | 'unavailable' | 'bubblewrap'
available: boolean
detail: string
}
export type RuntimeSandboxResolution = {
status: RuntimeSandboxStatus
binaryPath?: string
}
export type BubblewrapLaunch = {
command: string
args: string[]
}
type SandboxProbe = (command: string) => boolean
type BubblewrapLaunchInput = {
binaryPath: string
command: string
args: readonly string[]
workspace: string
readOnlyPaths?: readonly string[]
writablePaths?: readonly string[]
platform?: NodeJS.Platform
}
const SYSTEM_PATHS = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc']
function defaultProbe(command: string): boolean {
const result = spawnSync(
command,
[
'--die-with-parent',
'--unshare-all',
'--share-net',
'--ro-bind',
'/',
'/',
'--proc',
'/proc',
'--dev',
'/dev',
'--',
'/bin/true'
],
{
shell: false,
stdio: 'ignore',
timeout: 1_000,
windowsHide: true
}
)
return !result.error && result.status === 0
}
export function resolveRuntimeSandbox(
mode: RuntimeSandboxMode,
platform: NodeJS.Platform = process.platform,
probe: SandboxProbe = defaultProbe
): RuntimeSandboxResolution {
if (mode === 'off') {
return {
status: {
mode,
enforcement: 'disabled',
available: false,
detail: 'Runtime OS 沙箱已关闭'
}
}
}
if (platform !== 'linux') {
return {
status: {
mode,
enforcement: 'unavailable',
available: false,
detail:
mode === 'strict'
? '严格 OS 沙箱当前仅支持安装 bubblewrap 的 Linux'
: '当前平台尚无可用的 Runtime OS 沙箱'
}
}
}
if (!probe('bwrap')) {
return {
status: {
mode,
enforcement: 'unavailable',
available: false,
detail:
mode === 'strict'
? '严格 OS 沙箱需要安装 bubblewrapbwrap'
: '未检测到 bubblewrapRuntime 将保持审批隔离但不启用 OS 沙箱'
}
}
}
return {
binaryPath: 'bwrap',
status: {
mode,
enforcement: 'bubblewrap',
available: true,
detail: 'Linux bubblewrap 文件系统沙箱已启用,网络仍按模型连接配置开放'
}
}
}
function normalizePath(value: string): string {
if (
!posix.isAbsolute(value) ||
[...value].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
})
) {
throw new Error('OS 沙箱路径必须是无控制字符的绝对路径')
}
return posix.normalize(value)
}
function isWithinPath(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}/`)
}
function uniquePaths(paths: readonly string[]): string[] {
return [
...new Set(paths.map(normalizePath))
].sort((left, right) => left.length - right.length)
}
function addDestinationDirectories(
args: string[],
paths: readonly string[]
): void {
const directories = new Set<string>()
for (const target of paths) {
let current = posix.parse(target).dir
while (current && current !== posix.parse(current).root) {
if (SYSTEM_PATHS.some((systemPath) => isWithinPath(current, systemPath))) {
break
}
directories.add(current)
current = posix.parse(current).dir
}
}
for (const directory of [...directories].sort(
(left, right) => left.length - right.length
)) {
args.push('--dir', directory)
}
}
export function buildBubblewrapLaunch(
input: BubblewrapLaunchInput
): BubblewrapLaunch {
if ((input.platform ?? process.platform) !== 'linux') {
throw new Error('bubblewrap 仅支持 Linux 路径')
}
const workspace = normalizePath(input.workspace)
const command =
posix.isAbsolute(input.command)
? normalizePath(input.command)
: input.command
const writablePaths = uniquePaths([
workspace,
...(input.writablePaths ?? [])
])
if (
writablePaths.some(
(path) =>
path === '/' ||
SYSTEM_PATHS.some((systemPath) =>
isWithinPath(path, systemPath)
)
)
) {
throw new Error('OS 沙箱不允许将系统路径挂载为可写')
}
const readOnlyPaths = uniquePaths([
...(input.readOnlyPaths ?? []),
...(posix.isAbsolute(command) &&
!SYSTEM_PATHS.some((systemPath) => isWithinPath(command, systemPath))
? [command]
: [])
]).filter(
(path) =>
!writablePaths.some((writablePath) => isWithinPath(path, writablePath))
)
const mountedPaths = [...readOnlyPaths, ...writablePaths]
const args = [
'--die-with-parent',
'--new-session',
'--unshare-all',
'--share-net',
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/tmp',
'--dir',
'/run',
'--dir',
'/home',
'--dir',
'/tmp/goodbuddy-home',
'--setenv',
'HOME',
'/tmp/goodbuddy-home',
'--setenv',
'XDG_CONFIG_HOME',
'/tmp/goodbuddy-home/.config',
'--setenv',
'XDG_CACHE_HOME',
'/tmp/goodbuddy-home/.cache'
]
for (const systemPath of SYSTEM_PATHS) {
args.push('--ro-bind-try', systemPath, systemPath)
}
addDestinationDirectories(args, mountedPaths)
for (const path of readOnlyPaths) {
args.push('--ro-bind', path, path)
}
for (const path of writablePaths) {
args.push('--bind', path, path)
}
args.push('--chdir', workspace, '--', command, ...input.args)
return {
command: input.binaryPath,
args
}
}
+31 -1
View File
@@ -18,15 +18,45 @@ export type RuntimeAuthorizer = (
request: RuntimeApprovalRequest
) => Promise<ApprovalDecision>
export type RuntimeGeneratedImageEvent = {
requestId: string
type: 'generated-image'
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
data: string
title: string
}
export type RuntimeModelUsageEvent = {
requestId: string
type: 'model-usage'
callId: string
runtime: 'model' | 'continue' | 'opencode'
provider: string
model: string
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
reportedTotalTokens?: number
}
export type RuntimeEvent =
| AgentEvent
| RuntimeGeneratedImageEvent
| RuntimeModelUsageEvent
export interface AgentRuntime {
readonly requiresToolApproval: boolean
readonly supportsToolExecution: boolean
readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus>
run(
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<AgentEvent, void, void>
): AsyncGenerator<RuntimeEvent, void, void>
releaseConversation?(conversationId: string): Promise<void>
dispose(): Promise<void>
}
+3
View File
@@ -9,12 +9,14 @@ import type {
export class UnconfiguredAgentRuntime implements AgentRuntime {
readonly requiresToolApproval = false
readonly supportsToolExecution = false
getStatus(): Promise<AgentRuntimeStatus> {
return Promise.resolve({
id: 'setup',
label: '需要配置模型',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail: '请在设置中选择并配置可用的模型或 Agent Runtime'
})
}
@@ -25,6 +27,7 @@ export class UnconfiguredAgentRuntime implements AgentRuntime {
yield {
requestId: request.requestId,
type: 'error',
status: 'failed',
message: '请先完成模型与 Agent Runtime 配置'
}
}