feat: add secure multi-runtime controls
Make agent backends configurable and governable with encrypted credentials, explicit context sharing, and tool approval boundaries. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
1b6178b841
commit
698a15ad14
@@ -57,6 +57,7 @@ function getTextDelta(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
export class BigtokenAgentRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = false
|
||||
private readonly conversations = new Map<string, ConversationMessage[]>()
|
||||
private readonly fetcher: typeof fetch
|
||||
|
||||
@@ -84,10 +85,38 @@ export class BigtokenAgentRuntime implements AgentRuntime {
|
||||
]
|
||||
}
|
||||
|
||||
private saveConversation(
|
||||
conversationId: string,
|
||||
messages: ConversationMessage[]
|
||||
): void {
|
||||
const retained: ConversationMessage[] = []
|
||||
let bytes = 0
|
||||
for (const message of messages.slice(-20).reverse()) {
|
||||
const messageBytes = Buffer.byteLength(message.content)
|
||||
if (bytes + messageBytes > 512 * 1024) {
|
||||
break
|
||||
}
|
||||
retained.unshift(message)
|
||||
bytes += messageBytes
|
||||
}
|
||||
this.conversations.delete(conversationId)
|
||||
this.conversations.set(conversationId, retained)
|
||||
while (this.conversations.size > 50) {
|
||||
const oldest = this.conversations.keys().next().value
|
||||
if (oldest) {
|
||||
this.conversations.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentRequest,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<AgentEvent, void, void> {
|
||||
if (!this.options.apiKey) {
|
||||
throw new Error('请先在设置中配置 Bigtoken API Key')
|
||||
}
|
||||
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
@@ -137,71 +166,84 @@ export class BigtokenAgentRuntime implements AgentRuntime {
|
||||
let buffer = ''
|
||||
let answer = ''
|
||||
let completed = false
|
||||
let streamEnded = false
|
||||
|
||||
while (!completed) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done }).replaceAll(
|
||||
'\r\n',
|
||||
'\n'
|
||||
)
|
||||
try {
|
||||
while (!completed) {
|
||||
const { done, value } = await reader.read()
|
||||
streamEnded = done
|
||||
buffer += decoder.decode(value, { stream: !done }).replaceAll(
|
||||
'\r\n',
|
||||
'\n'
|
||||
)
|
||||
|
||||
const blocks = buffer.split('\n\n')
|
||||
buffer = blocks.pop() ?? ''
|
||||
|
||||
for (const block of blocks) {
|
||||
const data = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
|
||||
if (!data || data === '[DONE]') {
|
||||
continue
|
||||
if (Buffer.byteLength(buffer) > 1024 * 1024) {
|
||||
throw new Error('Bigtoken 流式响应块超过安全限制')
|
||||
}
|
||||
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(data)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const blocks = buffer.split('\n\n')
|
||||
buffer = blocks.pop() ?? ''
|
||||
|
||||
const error = getErrorMessage(event)
|
||||
if (error) {
|
||||
throw new Error(error)
|
||||
}
|
||||
for (const block of blocks) {
|
||||
const data = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
|
||||
const delta = getTextDelta(event)
|
||||
if (delta) {
|
||||
answer += delta
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta
|
||||
if (!data || data === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(data)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const error = getErrorMessage(event)
|
||||
if (error) {
|
||||
throw new Error(error.slice(0, 1_000))
|
||||
}
|
||||
|
||||
const delta = getTextDelta(event)
|
||||
if (delta) {
|
||||
answer += delta
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event &&
|
||||
typeof event === 'object' &&
|
||||
'type' in event &&
|
||||
event.type === 'message_stop'
|
||||
) {
|
||||
completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event &&
|
||||
typeof event === 'object' &&
|
||||
'type' in event &&
|
||||
event.type === 'message_stop'
|
||||
) {
|
||||
if (done) {
|
||||
completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
completed = true
|
||||
} finally {
|
||||
if (!streamEnded) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
throw new Error('Bigtoken 返回了空内容')
|
||||
}
|
||||
|
||||
this.conversations.set(request.conversationId, [
|
||||
this.saveConversation(request.conversationId, [
|
||||
...messages,
|
||||
{ role: 'assistant', content: answer }
|
||||
])
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
|
||||
describe('ContinueAgentRuntime', () => {
|
||||
it('does not launch the CLI for an already-cancelled request', async () => {
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
command: 'command-that-must-not-run',
|
||||
defaultWorkspace: process.cwd()
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('cancelled'))
|
||||
const stream = runtime.run(
|
||||
{
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'test'
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
|
||||
await expect(stream.next()).rejects.toThrow('cancelled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntime } from './runtime'
|
||||
|
||||
type ContinueRuntimeOptions = {
|
||||
command: string
|
||||
defaultWorkspace: string
|
||||
}
|
||||
|
||||
function extractContinueText(output: string): string {
|
||||
const trimmed = output.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const record = parsed as Record<string, unknown>
|
||||
for (const key of ['content', 'message', 'response', 'text']) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export class ContinueAgentRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = true
|
||||
private readonly children = new Set<ReturnType<typeof spawn>>()
|
||||
|
||||
constructor(private readonly options: ContinueRuntimeOptions) {}
|
||||
|
||||
private terminate(child: ReturnType<typeof spawn>): void {
|
||||
if (child.exitCode !== null || child.killed) {
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
const killer = spawn('taskkill.exe', [
|
||||
'/PID',
|
||||
String(child.pid),
|
||||
'/T',
|
||||
'/F'
|
||||
])
|
||||
killer.unref()
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
|
||||
private checkAvailability(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(this.options.command, ['--version'], {
|
||||
cwd: this.options.defaultWorkspace,
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_NO_TTY: '1'
|
||||
},
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
resolve(false)
|
||||
}, 2_000)
|
||||
child.once('error', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(code === 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
const available = await this.checkAvailability()
|
||||
return {
|
||||
id: 'continue',
|
||||
label: 'Continue CLI',
|
||||
available,
|
||||
detail: available
|
||||
? '通过 Continue CLI headless 模式执行'
|
||||
: 'Continue CLI 不可用'
|
||||
}
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentRequest,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<AgentEvent, void, void> {
|
||||
signal.throwIfAborted()
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
message: 'Continue 正在执行任务'
|
||||
}
|
||||
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
signal.throwIfAborted()
|
||||
const child = spawn(
|
||||
this.options.command,
|
||||
['-p', '--format', 'json', '--silent'],
|
||||
{
|
||||
cwd: this.options.defaultWorkspace,
|
||||
env: {
|
||||
...process.env,
|
||||
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
|
||||
FORCE_NO_TTY: '1'
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
this.children.add(child)
|
||||
const { stdin, stdout: childStdout, stderr: childStderr } = child
|
||||
if (!stdin || !childStdout || !childStderr) {
|
||||
this.terminate(child)
|
||||
reject(new Error('Continue CLI 管道初始化失败'))
|
||||
return
|
||||
}
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputExceeded = false
|
||||
const abort = (): void => {
|
||||
this.terminate(child)
|
||||
reject(signal.reason)
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
childStdout.setEncoding('utf8')
|
||||
childStderr.setEncoding('utf8')
|
||||
childStdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (Buffer.byteLength(stdout) > 4 * 1024 * 1024) {
|
||||
outputExceeded = true
|
||||
this.terminate(child)
|
||||
}
|
||||
})
|
||||
childStderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
if (Buffer.byteLength(stderr) > 64 * 1024) {
|
||||
outputExceeded = true
|
||||
this.terminate(child)
|
||||
}
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
this.children.delete(child)
|
||||
signal.removeEventListener('abort', abort)
|
||||
reject(error)
|
||||
})
|
||||
child.once('close', (code) => {
|
||||
this.children.delete(child)
|
||||
signal.removeEventListener('abort', abort)
|
||||
if (outputExceeded) {
|
||||
reject(new Error('Continue CLI 输出超过安全限制'))
|
||||
} else if (code === 0) {
|
||||
resolve(stdout)
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
stderr.trim().slice(0, 1_000) ||
|
||||
`Continue CLI 已退出(code ${code ?? 'unknown'})`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
stdin.end(request.prompt)
|
||||
})
|
||||
|
||||
const text = extractContinueText(result)
|
||||
if (!text) {
|
||||
throw new Error('Continue CLI 未返回内容')
|
||||
}
|
||||
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: text
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await Promise.all(
|
||||
[...this.children].map(
|
||||
(child) =>
|
||||
new Promise<void>((resolve) => {
|
||||
child.once('close', () => resolve())
|
||||
this.terminate(child)
|
||||
setTimeout(resolve, 2_000)
|
||||
})
|
||||
)
|
||||
)
|
||||
this.children.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
import { BigtokenAgentRuntime } from './bigtoken-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { DemoAgentRuntime } from './demo-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import type { AgentRuntime } from './runtime'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
|
||||
export function createAgentRuntime(defaultWorkspace: string): AgentRuntime {
|
||||
export function createAgentRuntime(
|
||||
defaultWorkspace: string,
|
||||
settings?: ResolvedRuntimeSettings
|
||||
): AgentRuntime {
|
||||
const baseUrl = process.env.GOODBUDDY_OPENCODE_URL
|
||||
const embedded = process.env.GOODBUDDY_OPENCODE_EMBEDDED === 'true'
|
||||
const provider = settings?.provider ?? 'auto'
|
||||
|
||||
if (baseUrl || embedded) {
|
||||
if (provider === 'continue') {
|
||||
return new ContinueAgentRuntime({
|
||||
command: process.env.GOODBUDDY_CONTINUE_COMMAND ?? 'cn',
|
||||
defaultWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
if (provider === 'opencode' || (provider === 'auto' && (baseUrl || embedded))) {
|
||||
return new OpenCodeRuntime({
|
||||
baseUrl,
|
||||
embedded,
|
||||
@@ -15,13 +29,14 @@ export function createAgentRuntime(defaultWorkspace: string): AgentRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
const bigtokenApiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
|
||||
if (bigtokenApiKey) {
|
||||
const bigtokenApiKey =
|
||||
settings?.apiKey ?? process.env.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
|
||||
if (provider === 'bigtoken' || (provider === 'auto' && bigtokenApiKey)) {
|
||||
return new BigtokenAgentRuntime({
|
||||
apiKey: bigtokenApiKey,
|
||||
apiKey: bigtokenApiKey ?? '',
|
||||
baseUrl:
|
||||
process.env.GOODBUDDY_BIGTOKEN_BASE_URL ?? 'https://bigtoken.ai',
|
||||
model: process.env.GOODBUDDY_BIGTOKEN_MODEL ?? 'sonnet-5'
|
||||
settings?.bigtokenBaseUrl ?? defaultRuntimeSettings.bigtokenBaseUrl,
|
||||
model: settings?.bigtokenModel ?? defaultRuntimeSettings.bigtokenModel
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
}
|
||||
|
||||
export class DemoAgentRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = false
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return {
|
||||
id: 'demo',
|
||||
|
||||
@@ -19,6 +19,7 @@ export type OpenCodeRuntimeOptions = {
|
||||
}
|
||||
|
||||
export class OpenCodeRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = true
|
||||
private client?: OpencodeClient
|
||||
private server?: OpenCodeServer
|
||||
private readonly sessions = new Map<string, string>()
|
||||
@@ -108,7 +109,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<AgentEvent, void, void> {
|
||||
const client = await this.getClient()
|
||||
const directory = request.workspace ?? this.options.defaultWorkspace
|
||||
const directory = this.options.defaultWorkspace
|
||||
const sessionId = await this.getSessionId(client, request, directory)
|
||||
|
||||
yield {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntime } from './runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
|
||||
class TestRuntime implements AgentRuntime {
|
||||
readonly dispose = vi.fn(async () => {})
|
||||
readonly started: Promise<void>
|
||||
private release?: () => void
|
||||
private markStarted!: () => void
|
||||
|
||||
constructor(
|
||||
private readonly delayed = false,
|
||||
readonly requiresToolApproval = false
|
||||
) {
|
||||
this.started = new Promise((resolve) => {
|
||||
this.markStarted = resolve
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return Promise.resolve({
|
||||
id: 'demo',
|
||||
label: 'Test',
|
||||
available: true,
|
||||
detail: 'Test runtime'
|
||||
})
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentRequest
|
||||
): AsyncGenerator<AgentEvent, void, void> {
|
||||
this.markStarted()
|
||||
if (this.delayed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.release = resolve
|
||||
})
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: 'old runtime event'
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
this.release?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentRuntimeController', () => {
|
||||
it('suppresses retired runtime events and disposes it after requests exit', async () => {
|
||||
const previous = new TestRuntime(true, true)
|
||||
const next = new TestRuntime()
|
||||
const controller = new AgentRuntimeController(previous)
|
||||
const authorize = vi.fn(async () => {})
|
||||
const approvedStream = controller.run(
|
||||
{
|
||||
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b08',
|
||||
conversationId: 'conversation-2',
|
||||
prompt: 'test'
|
||||
},
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
const pendingEvent = approvedStream.next()
|
||||
await previous.started
|
||||
expect(authorize).toHaveBeenCalledWith(true)
|
||||
|
||||
const replacement = controller.replace(next)
|
||||
previous.finish()
|
||||
|
||||
await expect(pendingEvent).resolves.toMatchObject({ done: true })
|
||||
await replacement
|
||||
expect(previous.dispose).toHaveBeenCalledOnce()
|
||||
await expect(controller.getStatus()).resolves.toMatchObject({
|
||||
label: 'Test'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntime } 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
|
||||
}
|
||||
|
||||
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))
|
||||
])
|
||||
}
|
||||
|
||||
getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return this.current.runtime.getStatus()
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentRequest,
|
||||
signal: AbortSignal,
|
||||
authorize?: (requiresToolApproval: boolean) => Promise<void>
|
||||
): AsyncGenerator<AgentEvent, void, void> {
|
||||
const slot = this.current
|
||||
slot.activeRequests += 1
|
||||
try {
|
||||
await authorize?.(slot.runtime.requiresToolApproval)
|
||||
for await (const event of slot.runtime.run(request, signal)) {
|
||||
if (slot !== this.current) {
|
||||
return
|
||||
}
|
||||
yield event
|
||||
}
|
||||
} finally {
|
||||
slot.activeRequests -= 1
|
||||
if (slot.retiring && slot.activeRequests === 0) {
|
||||
await this.disposeSlot(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
|
||||
export interface AgentRuntime {
|
||||
readonly requiresToolApproval: boolean
|
||||
getStatus(): Promise<AgentRuntimeStatus>
|
||||
run(
|
||||
request: AgentRequest,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
authorize?: (requiresToolApproval: boolean) => Promise<void>
|
||||
): AsyncGenerator<AgentEvent, void, void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user