Files
goodbuddy/src/main/agent/runtime-controller.ts
T
lofyer b8fc7bc86e
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
chore: prepare GoodBuddy 0.8.2
2026-08-06 22:47:13 +08:00

207 lines
5.4 KiB
TypeScript

import type {
AgentRequest,
AgentRuntimeStatus
} from '../../shared/contracts'
import type {
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent
} from './runtime'
type RuntimeSlot = {
runtime: AgentRuntime
activeRequests: number
retiring: boolean
disposal?: Promise<void>
resolveDisposal?: () => void
}
export class AgentRuntimeController implements AgentRuntime {
private current: RuntimeSlot
private replacementQueue: Promise<void> = Promise.resolve()
private closing = false
constructor(runtime: AgentRuntime) {
this.current = {
runtime,
activeRequests: 0,
retiring: false
}
}
get requiresToolApproval(): boolean {
return this.current.runtime.requiresToolApproval
}
get runtimeId(): AgentRuntimeStatus['id'] | undefined {
return this.current.runtime.runtimeId
}
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(() => {
throw new Error('Agent Runtime 正在关闭')
})
}
const operation = this.replacementQueue.then(() =>
this.performReplace(next)
)
this.replacementQueue = operation.catch(() => undefined)
return operation
}
private async performReplace(next: AgentRuntime): Promise<void> {
const previous = this.current
this.current = {
runtime: next,
activeRequests: 0,
retiring: false
}
const disposal = this.retire(previous)
await Promise.race([
disposal,
new Promise<void>((resolve) => setTimeout(resolve, 2_000))
])
}
async getStatus(): Promise<AgentRuntimeStatus> {
return this.probe((runtime) => runtime.getStatus())
}
async testConnection(): Promise<AgentRuntimeStatus> {
return this.probe(
(runtime) =>
runtime.testConnection?.() ?? runtime.getStatus()
)
}
private async probe(
operation: (runtime: AgentRuntime) => Promise<AgentRuntimeStatus>
): Promise<AgentRuntimeStatus> {
if (this.closing) {
throw new Error('Agent Runtime 正在关闭')
}
const slot = this.current
slot.activeRequests += 1
try {
const status = await operation(slot.runtime)
if (slot !== this.current) {
throw new Error('Runtime 已切换,请重试')
}
return {
...status,
supportsToolExecution: slot.runtime.supportsToolExecution
}
} finally {
slot.activeRequests -= 1
if (slot.retiring && slot.activeRequests === 0) {
await this.disposeSlot(slot)
}
}
}
async *run(
request: AgentRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
if (this.closing) {
throw new Error('Agent Runtime 正在关闭')
}
const slot = this.current
const toolsAllowed = request.workMode === 'execute'
const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed
? authorize
: async () => 'deny'
slot.activeRequests += 1
try {
if (toolsAllowed && !slot.runtime.supportsToolExecution) {
throw new Error('当前 Runtime 不支持工具执行,请切换到 OpenCode 或 Continue')
}
if (
toolsAllowed &&
slot.runtime.requiresToolApproval &&
effectiveAuthorize
) {
const decision = await effectiveAuthorize({
scopeKey: 'runtime:whole-run',
title: '允许 Agent 使用工作区工具?',
description:
'该 Runtime 尚不能报告单个工具调用,可能读取或修改工作区文件并执行命令。',
allowPermanent: false
})
if (decision === 'deny') {
throw new Error('用户拒绝了 Agent 工具执行')
}
}
for await (const event of slot.runtime.run(
request,
signal,
effectiveAuthorize
)) {
if (slot !== this.current) {
throw new Error('Runtime 已切换,当前请求已中断')
}
yield event
}
if (slot !== this.current) {
throw new Error('Runtime 已切换,当前请求已中断')
}
} finally {
slot.activeRequests -= 1
if (slot.retiring && slot.activeRequests === 0) {
await this.disposeSlot(slot)
}
}
}
async releaseConversation(conversationId: string): Promise<void> {
await this.current.runtime.releaseConversation?.(conversationId)
}
private retire(slot: RuntimeSlot): Promise<void> {
slot.retiring = true
if (!slot.disposal) {
slot.disposal = new Promise((resolve) => {
slot.resolveDisposal = resolve
})
}
if (slot.activeRequests === 0) {
void this.disposeSlot(slot)
}
return slot.disposal
}
private async disposeSlot(slot: RuntimeSlot): Promise<void> {
if (!slot.resolveDisposal) {
return
}
const resolve = slot.resolveDisposal
slot.resolveDisposal = undefined
try {
await slot.runtime.dispose()
} catch {
resolve()
return
}
resolve()
}
async dispose(): Promise<void> {
this.closing = true
const operation = this.replacementQueue.then(() =>
this.retire(this.current)
)
this.replacementQueue = operation.catch(() => undefined)
await operation
}
}