Files
goodbuddy/src/main/agent/deepseek-harness-runtime.ts
T
mesalogo dd29f309f1 feat: add save_image tool and list image MCP tools
Expose save_image across direct model, MCP gateway, DeepSeek Harness and remote Agents (written on the remote host via chunked reads), and show the image MCP tool list in settings.
2026-10-02 10:08:42 +08:00

1780 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { toolOperationSummary } from './tool-operation-summary'
import { realpath } from 'node:fs/promises'
import type {
AgentRuntimeStatus,
ModelRequestHeaders,
RuntimeNativeSnapshot,
RuntimeNativeTool
} from '../../shared/contracts'
import {
runtimeNativeInventoryLimits,
runtimeNativeSkillSchema,
runtimeNativeToolSchema
} from '../../shared/runtime-customization-contracts'
import { RequestError } from '@agentclientprotocol/sdk'
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent
} from './runtime'
import type { ModelToolDefinition, ModelToolProviderLike } from './model-tool-provider'
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
import {
assertObjectJsonSchema,
validateJsonSchemaValue
} from '@deepseek-ai/dsh-tools'
import {
GOODBUDDY_CONTROL_PROTOCOL_VERSION,
GOODBUDDY_CREDENTIAL,
GOODBUDDY_EVENT,
GOODBUDDY_HANDSHAKE,
GOODBUDDY_NATIVE_SNAPSHOT,
GOODBUDDY_PREPARE,
GOODBUDDY_RELEASE,
GOODBUDDY_SHUTDOWN,
GOODBUDDY_TOOLS_CALL,
GOODBUDDY_TOOLS_LIST
} from './deepseek-harness-protocol'
import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol'
import { promptWithUntrustedConversationHistory } from './runtime-conversation-history'
const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk'
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000
const DEFAULT_MAX_STDERR_BYTES = 64 * 1024
const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024
const MAX_QUEUED_UPDATES = 1_000
const MAX_APPROVAL_DETAIL_CHARACTERS = 4_000
const MAX_MCP_PROXY_TOOLS = 100
const MAX_MCP_TOOL_DESCRIPTION_CHARACTERS = 1_000
const MAX_NATIVE_TOOLS = runtimeNativeInventoryLimits.tools
const MAX_MCP_TOOL_SCHEMA_BYTES = 32 * 1024
const MAIN_WEB_TOOL_NAMES = new Set(['web_search', 'web_fetch'])
const DSH_BUILTIN_TOOL_KINDS: Readonly<
Partial<Record<string, RuntimeNativeTool['kind']>>
> = {
bash: 'shell',
edit: 'write',
pwsh: 'shell',
read: 'read',
read_image: 'read',
write: 'write'
}
const DSH_SCHEMA_SCALAR_KEYS = new Set([
'type',
'required',
'additionalProperties',
'enum',
'const',
'description',
'title',
'default',
'examples'
])
type AcpPermissionRequest = {
sessionId: string
toolCall: {
toolCallId: string
title?: string | null
name?: string | null
kind?: string | null
rawInput?: unknown
}
options: Array<{
optionId: string
name: string
kind:
| 'allow_once'
| 'allow_always'
| 'reject_once'
| 'reject_always'
}>
}
type AcpSessionNotification = {
sessionId: string
update: {
sessionUpdate: string
content?: { type: string; text?: string }
toolCallId?: string
title?: string | null
name?: string | null
status?: string | null
rawInput?: unknown
rawOutput?: unknown
goodBuddyEvent?: Record<string, unknown>
}
}
type AcpAgent = {
initialize(params: unknown): Promise<unknown>
newSession(params: unknown): Promise<{ sessionId: string }>
prompt(params: unknown): Promise<{ stopReason?: string }>
cancel(params: { sessionId: string }): Promise<void>
extMethod(
method: string,
params: Record<string, unknown>
): Promise<Record<string, unknown>>
extNotification(
method: string,
params: Record<string, unknown>
): Promise<void>
}
type AcpConnection = {
readonly signal: AbortSignal
readonly closed: Promise<void>
}
export type DeepSeekHarnessAcpSdk = {
PROTOCOL_VERSION: number
ClientSideConnection: new (
toClient: (agent: AcpAgent) => {
requestPermission(
params: AcpPermissionRequest
): Promise<unknown>
sessionUpdate(params: AcpSessionNotification): Promise<void>
extMethod(
method: string,
params: Record<string, unknown>
): Promise<Record<string, unknown>>
extNotification(
method: string,
params: Record<string, unknown>
): Promise<void>
},
stream: unknown
) => AcpConnection & AcpAgent
ndJsonStream(
output: WritableStream<Uint8Array>,
input: ReadableStream<Uint8Array>
): unknown
}
/**
* Process-neutral child contract. The main process may adapt either a Node
* ChildProcess or an Electron UtilityProcess to these WHATWG byte streams.
*/
export type DeepSeekHarnessChild = {
readonly stdin: WritableStream<Uint8Array>
readonly stdout: ReadableStream<Uint8Array>
readonly stderr?: ReadableStream<Uint8Array>
readonly exited: Promise<{
exitCode: number | null
signal?: string | null
}>
terminate(): void | Promise<void>
}
export type DeepSeekHarnessLaunchOptions = {
cwd: string
signal: AbortSignal
baseUrl: string
model: string
supportsImageInput: boolean
requestHeaders?: ModelRequestHeaders
credentialRefs: readonly string[]
skillPackages: readonly RuntimeSkillPackage[]
extensionPackages: readonly ControlledHarnessExtensionPackage[]
}
export type DeepSeekHarnessRuntimeOptions = {
defaultWorkspace: string
baseUrl: string
model: string
supportsImageInput?: boolean
requestHeaders?: ModelRequestHeaders
launch: (
options: DeepSeekHarnessLaunchOptions
) => Promise<DeepSeekHarnessChild>
/**
* Explicit hard timeout for each initialization operation, including the
* complete launcher call. When omitted, launcher startup is expanded from
* the enabled extension count while later ACP operations retain 10 seconds.
*/
initializationTimeoutMs?: number
promptTimeoutMs?: number
shutdownTimeoutMs?: number
maxStderrBytes?: number
maxEventCharacters?: number
credentialRefs?: Readonly<Record<string, string>>
skillPackages?: RuntimeSkillPackage[]
extensionPackages?: ControlledHarnessExtensionPackage[]
toolProvider?: ModelToolProviderLike
createToolProvider?: (workspace: string) => ModelToolProviderLike
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
}
type ActiveRun = {
request: AgentExecutionRequest
toolProvider?: ModelToolProviderLike
toolController: AbortController
authorize?: RuntimeAuthorizer
updates: AcpSessionNotification['update'][]
toolNames: Map<
string,
{ name: string; input?: string; summary?: string; title?: string }
>
wake?: () => void
closed: boolean
}
type HarnessState = {
workspace: string
child: DeepSeekHarnessChild
connection: AcpConnection
agent: AcpAgent
capabilities: GoodBuddyHarnessCapabilities
}
type GoodBuddyHarnessCapabilities = {
controlProtocolVersion: number
harnessVersion: string
acpProtocolVersion: number
supports: {
cancellation: boolean
sessionRelease: boolean
reasoningEvents: boolean
toolEvents: boolean
usageEvents: boolean
credentialResolution: boolean
}
execution: {
mode: 'host'
}
}
function safeStringify(value: unknown): string | undefined {
if (value === undefined) {
return undefined
}
try {
const text =
typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return text.slice(0, MAX_APPROVAL_DETAIL_CHARACTERS)
} catch {
return '[无法序列化]'
}
}
export function harnessPromptError(error: unknown): unknown {
if (!(error instanceof RequestError) || error.code !== -32603) {
return error
}
const rawDetails =
error.data &&
typeof error.data === 'object' &&
!Array.isArray(error.data) &&
typeof (error.data as Record<string, unknown>).details ===
'string'
? (error.data as Record<string, unknown>).details
: undefined
const details =
typeof rawDetails === 'string' ? rawDetails : undefined
return details
? new Error(details.slice(0, MAX_APPROVAL_DETAIL_CHARACTERS))
: error
}
function isMainWebTool(
tool: Awaited<
ReturnType<ModelToolProviderLike['listTools']>
>[number]
): boolean {
return (
tool.source === 'builtin' &&
MAIN_WEB_TOOL_NAMES.has(tool.name)
)
}
function dshCompatibleMainInputSchema(
schema: Record<string, unknown>
): Record<string, unknown> {
const compatible: Record<string, unknown> = {}
for (const [key, value] of Object.entries(schema)) {
if (DSH_SCHEMA_SCALAR_KEYS.has(key)) {
compatible[key] = value
continue
}
if (
key === 'properties' &&
value &&
typeof value === 'object' &&
!Array.isArray(value)
) {
compatible.properties = Object.fromEntries(
Object.entries(value).map(([name, propertySchema]) => [
name,
propertySchema &&
typeof propertySchema === 'object' &&
!Array.isArray(propertySchema)
? dshCompatibleMainInputSchema(
propertySchema as Record<string, unknown>
)
: propertySchema
])
)
continue
}
if (
key === 'items' &&
value &&
typeof value === 'object' &&
!Array.isArray(value)
) {
compatible.items = dshCompatibleMainInputSchema(
value as Record<string, unknown>
)
continue
}
if (key === 'oneOf' && Array.isArray(value)) {
compatible.oneOf = value.map((candidate) =>
candidate &&
typeof candidate === 'object' &&
!Array.isArray(candidate)
? dshCompatibleMainInputSchema(
candidate as Record<string, unknown>
)
: candidate
)
}
}
return compatible
}
function proxyToolInputSchema(
tool: Awaited<
ReturnType<ModelToolProviderLike['listTools']>
>[number]
): Record<string, unknown> {
if (isStoryGraphTool(tool.name)) {
const schema = structuredClone(tool.inputSchema)
const scope = (schema.properties as Record<string, Record<string, unknown>>).scope!
// Scope is a discriminated union: Global and projects are mutually exclusive.
scope.oneOf = scope.anyOf
delete scope.anyOf
return dshCompatibleMainInputSchema(schema)
}
return isMainWebTool(tool) || isMainImageTool(tool.name)
? dshCompatibleMainInputSchema(tool.inputSchema)
: tool.inputSchema
}
function isMainImageTool(name: string): boolean {
return name === 'generate_image' || name === 'save_image'
}
function boundedProxyToolCatalog(
tools: Awaited<
ReturnType<ModelToolProviderLike['listTools']>
>,
workMode: 'ask' | 'execute'
): Array<{
name: string
description: string
inputSchema: Record<string, unknown>
}> {
const catalog = tools.filter(
(tool) =>
isMainWebTool(tool) || isStoryGraphTool(tool.name) ||
(workMode === 'execute' && (tool.source === 'mcp' || isMainImageTool(tool.name)))
)
if (catalog.length > MAX_MCP_PROXY_TOOLS) {
throw new Error(
'DeepSeek Harness MCP 工具数量超过安全限制'
)
}
const names = new Set<string>()
return catalog.map((tool) => {
if (
!/^[a-zA-Z0-9_-]{1,64}$/u.test(tool.name) ||
names.has(tool.name)
) {
throw new Error('DeepSeek Harness MCP 工具名称无效或冲突')
}
names.add(tool.name)
const description = tool.description.slice(
0,
tool.name === 'generate_image' ? imageToolDescriptionLimit : MAX_MCP_TOOL_DESCRIPTION_CHARACTERS
)
const inputSchema = proxyToolInputSchema(tool)
let serialized: string
try {
serialized = JSON.stringify(inputSchema)
} catch (error) {
throw new Error('DeepSeek Harness MCP 工具结构无效', {
cause: error
})
}
if (
!serialized ||
Buffer.byteLength(serialized, 'utf8') >
MAX_MCP_TOOL_SCHEMA_BYTES
) {
throw new Error(
'DeepSeek Harness MCP 工具结构超过安全限制'
)
}
return {
name: tool.name,
description,
inputSchema
}
})
}
function timeoutError(label: string): Error {
return new Error(`DeepSeek Harness ${label}超时`)
}
async function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
label: string
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(timeoutError(label)), timeoutMs)
})
try {
return await Promise.race([operation, timeout])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
async function defaultLoadAcpSdk(): Promise<DeepSeekHarnessAcpSdk> {
// Keep the process launcher injectable while still using the official ACP
// implementation by default. The non-literal import also lets this isolated
// runtime land before the dependency wiring change.
return (await import(
ACP_PACKAGE_NAME
)) as unknown as DeepSeekHarnessAcpSdk
}
export class DeepSeekHarnessRuntime implements AgentRuntime {
readonly runtimeId = 'deepseek-harness'
readonly requiresToolApproval = false
readonly supportsToolExecution = true
readonly supportsScopedDataTools = false
private state?: HarnessState
private initialization?: Promise<HarnessState>
private launchController?: AbortController
private disposed = false
private fatalError?: Error
private stderrBytes = 0
private readonly sessions = new Map<string, { id: string; workspace: string }>()
private readonly workspaceTools = new Map<string, ModelToolProviderLike>()
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
private readonly sessionInitializations = new Map<
string,
Promise<string>
>()
private readonly activeRuns = new Map<string, ActiveRun>()
private readonly conversationTails = new Map<string, Promise<void>>()
constructor(private readonly options: DeepSeekHarnessRuntimeOptions) {}
private get initializationTimeoutMs(): number {
return (
this.options.initializationTimeoutMs ??
DEFAULT_INITIALIZATION_TIMEOUT_MS
)
}
private get launchTimeoutMs(): number {
return (
this.options.initializationTimeoutMs ??
deepSeekHarnessStartupBudget(
this.options.extensionPackages?.length ?? 0
).mainTimeoutMs
)
}
private get promptTimeoutMs(): number | undefined {
return this.options.promptTimeoutMs
}
private get shutdownTimeoutMs(): number {
return (
this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS
)
}
private get maxStderrBytes(): number {
return this.options.maxStderrBytes ?? DEFAULT_MAX_STDERR_BYTES
}
private get maxEventCharacters(): number {
return (
this.options.maxEventCharacters ??
DEFAULT_MAX_EVENT_CHARACTERS
)
}
private async terminate(
child: DeepSeekHarnessChild
): Promise<void> {
try {
await child.terminate()
} catch {
// Termination is best-effort; the bounded exit wait still prevents hangs.
}
}
private fail(error: Error): void {
if (this.fatalError) {
return
}
this.fatalError = error
for (const run of this.activeRuns.values()) {
run.closed = true
run.wake?.()
run.wake = undefined
}
}
private async consumeStderr(
child: DeepSeekHarnessChild
): Promise<void> {
if (!child.stderr) {
return
}
const reader = child.stderr.getReader()
try {
while (true) {
const result = await reader.read()
if (result.done) {
return
}
this.stderrBytes += result.value.byteLength
if (this.stderrBytes > this.maxStderrBytes) {
this.fail(
new Error(
`DeepSeek Harness stderr 超过 ${this.maxStderrBytes} 字节安全限制`
)
)
await this.terminate(child)
return
}
}
} catch {
this.fail(new Error('DeepSeek Harness stderr 管道读取失败'))
} finally {
reader.releaseLock()
}
}
private handleExit(result: {
exitCode: number | null
signal?: string | null
}): void {
if (this.disposed) {
return
}
const suffix = result.signal
? `signal ${result.signal}`
: `code ${result.exitCode ?? 'unknown'}`
this.fail(new Error(`DeepSeek Harness 进程意外退出(${suffix})`))
}
private onSessionUpdate(
notification: AcpSessionNotification
): void {
const run = this.activeRuns.get(notification.sessionId)
if (!run || run.closed) {
return
}
if (run.updates.length >= MAX_QUEUED_UPDATES) {
this.fail(
new Error('DeepSeek Harness ACP 更新积压超过安全限制')
)
return
}
run.updates.push(notification.update)
run.wake?.()
run.wake = undefined
}
private onBridgeEvent(params: Record<string, unknown>): void {
const sessionId = params.sessionId
const requestId = params.requestId
if (
typeof sessionId !== 'string' ||
typeof requestId !== 'string'
) {
this.fail(new Error('DeepSeek Harness 扩展事件关联无效'))
return
}
const run = this.activeRuns.get(sessionId)
if (
!run ||
run.closed ||
run.request.requestId !== requestId
) {
return
}
const eventLength = JSON.stringify(params).length
if (eventLength > this.maxEventCharacters) {
this.fail(
new Error('DeepSeek Harness 扩展事件超过安全限制')
)
return
}
const event = this.bridgeEventToUpdate(params) ?? {
sessionUpdate: 'goodbuddy_event',
goodBuddyEvent: params
}
this.onSessionUpdate({ sessionId, update: event })
}
private bridgeEventToUpdate(
params: Record<string, unknown>
): AcpSessionNotification['update'] | undefined {
if (params.type === 'text' || params.type === 'reasoning') {
if (typeof params.delta !== 'string') {
return undefined
}
return {
sessionUpdate:
params.type === 'text'
? 'agent_message_chunk'
: 'agent_thought_chunk',
content: { type: 'text', text: params.delta }
}
}
if (params.type === 'tool') {
if (
typeof params.callId !== 'string' ||
typeof params.name !== 'string'
) {
return undefined
}
const status =
params.state === 'running'
? 'in_progress'
: params.state === 'completed'
? 'completed'
: params.state === 'failed'
? 'failed'
: 'pending'
return {
sessionUpdate:
status === 'pending' ? 'tool_call' : 'tool_call_update',
toolCallId: params.callId,
name: params.name,
status,
rawInput: params.input,
rawOutput: params.output
}
}
return undefined
}
private toUsageEvent(
requestId: string,
params: Record<string, unknown>
): RuntimeEvent | undefined {
if (
params.type !== 'model-usage' ||
typeof params.callId !== 'string' ||
typeof params.provider !== 'string' ||
typeof params.model !== 'string'
) {
return undefined
}
const counts = [
params.inputTokens,
params.outputTokens,
params.cacheReadTokens,
params.cacheWriteTokens
]
if (
counts.some(
(value) =>
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < 0
)
) {
return undefined
}
return {
requestId,
type: 'model-usage',
callId: params.callId,
runtime: 'deepseek-harness',
provider: params.provider,
model: params.model,
inputTokens: params.inputTokens as number,
outputTokens: params.outputTokens as number,
cacheReadTokens: params.cacheReadTokens as number,
cacheWriteTokens: params.cacheWriteTokens as number
}
}
private parseCapabilities(
value: Record<string, unknown>,
protocolVersion: number
): GoodBuddyHarnessCapabilities {
const capabilities =
value as unknown as GoodBuddyHarnessCapabilities
const supports = capabilities.supports
if (
capabilities.controlProtocolVersion !==
GOODBUDDY_CONTROL_PROTOCOL_VERSION ||
capabilities.acpProtocolVersion !== protocolVersion ||
typeof capabilities.harnessVersion !== 'string' ||
!supports?.cancellation ||
!supports.sessionRelease ||
!supports.credentialResolution ||
capabilities.execution?.mode !== 'host'
) {
throw new Error(
'DeepSeek Harness 内部控制面必需能力握手失败'
)
}
return capabilities
}
private async handlePermission(
permission: AcpPermissionRequest
): Promise<{
outcome:
| { outcome: 'selected'; optionId: string }
| { outcome: 'cancelled' }
}> {
const run = this.activeRuns.get(permission.sessionId)
const reject = permission.options.find(
(option) =>
option.kind === 'reject_once' ||
option.kind === 'reject_always'
)
const allowOnce = permission.options.find(
(option) => option.kind === 'allow_once'
) ?? permission.options.find((option) => option.kind === 'allow_always')
if (
!run ||
run.closed ||
run.request.workMode !== 'execute'
) {
return reject
? {
outcome: {
outcome: 'selected',
optionId: reject.optionId
}
}
: { outcome: { outcome: 'cancelled' } }
}
if (allowOnce) {
return {
outcome: {
outcome: 'selected',
optionId: allowOnce.optionId
}
}
}
return reject
? {
outcome: {
outcome: 'selected',
optionId: reject.optionId
}
}
: { outcome: { outcome: 'cancelled' } }
}
private async initialize(): Promise<HarnessState> {
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
const launchController = new AbortController()
this.launchController = launchController
let child: DeepSeekHarnessChild | undefined
try {
const workspace = await realpath(this.options.defaultWorkspace)
child = await withTimeout(
this.options.launch({
cwd: workspace,
signal: launchController.signal,
baseUrl: this.options.baseUrl,
model: this.options.model,
supportsImageInput:
this.options.supportsImageInput === true,
requestHeaders: this.options.requestHeaders ?? {},
credentialRefs: Object.keys(
this.options.credentialRefs ?? {}
),
skillPackages: this.options.skillPackages ?? [],
extensionPackages: this.options.extensionPackages ?? []
}),
this.launchTimeoutMs,
'启动'
)
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
const sdk = await (this.options.loadAcpSdk ?? defaultLoadAcpSdk)()
let agent: AcpAgent | undefined
const connection = new sdk.ClientSideConnection(
(connectedAgent) => {
agent = connectedAgent
return {
requestPermission: (params) =>
this.handlePermission(params),
sessionUpdate: async (params) => {
this.onSessionUpdate(params)
},
extMethod: async (method, params) => {
if (method === GOODBUDDY_CREDENTIAL) {
const ref = params.ref
if (typeof ref !== 'string') {
return {}
}
const value = this.options.credentialRefs?.[ref]
return value ? { value } : {}
}
if (method === GOODBUDDY_TOOLS_LIST) {
if (typeof params.sessionId !== 'string') {
throw new Error(
'DeepSeek Harness MCP 工具上下文不可用'
)
}
const run = this.activeRuns.get(params.sessionId)
if (!run?.toolProvider || run.closed) return { tools: [] }
const context = {
imageToolBinding: run.request.imageToolBinding,
conversationId:
run?.request.conversationId ??
'deepseek-harness-tool-catalog',
browserTabId: run?.request.browserTabId,
workMode:
run?.request.workMode === 'ask'
? ('ask' as const)
: ('execute' as const),
knowledgeCapabilityToken:
run?.request.knowledgeCapabilityToken
}
const tools = await run.toolProvider.listTools(
context,
connection.signal
)
const catalog = boundedProxyToolCatalog(tools, context.workMode)
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || isStoryGraphTool(tool.name) || isMainImageTool(tool.name)
))
return { tools: catalog }
}
if (method === GOODBUDDY_TOOLS_CALL) {
const sessionId = params.sessionId
const name = params.name
const argumentsValue = params.arguments
const run =
typeof sessionId === 'string'
? this.activeRuns.get(sessionId)
: undefined
if (
!run ||
run.closed ||
!run.toolProvider ||
typeof name !== 'string' ||
!argumentsValue ||
typeof argumentsValue !== 'object' ||
Array.isArray(argumentsValue)
) {
throw new Error(
'DeepSeek Harness MCP 工具调用无效'
)
}
const context = {
imageToolBinding: run.request.imageToolBinding,
...(name === 'generate_image' ? { toolCallId: typeof params.callId === 'string' ? params.callId : crypto.randomUUID() } : {}),
conversationId: run.request.conversationId,
browserTabId: run.request.browserTabId,
workMode:
run.request.workMode === 'execute'
? ('execute' as const)
: ('ask' as const),
knowledgeCapabilityToken:
run.request.knowledgeCapabilityToken
}
const tools = this.proxyToolCatalogs.get(sessionId as string) ?? []
const tool = tools.find(
(candidate) =>
candidate.name === name &&
(candidate.source === 'mcp' || isMainImageTool(candidate.name) || isStoryGraphTool(candidate.name) ||
isMainWebTool(candidate))
)
if (!tool) {
throw new Error(
'DeepSeek Harness 请求了未知 Main 代理工具'
)
}
const isWebTool = isMainWebTool(tool)
if (
!isWebTool && !isStoryGraphTool(name) &&
(context.workMode !== 'execute' || !run.authorize)
) {
throw new Error(
'DeepSeek Harness MCP 工具需要 Execute 模式授权'
)
}
const argumentSummary =
safeStringify(argumentsValue) ?? '{}'
const inputSchema = proxyToolInputSchema(tool)
try {
assertObjectJsonSchema(inputSchema)
} catch (error) {
throw new Error(
'DeepSeek Harness MCP 工具参数结构不受支持',
{ cause: error }
)
}
const violations = validateJsonSchemaValue(
inputSchema,
argumentsValue
)
if (violations.length > 0) {
throw new Error(
`DeepSeek Harness MCP 工具参数无效:${violations
.slice(0, 5)
.join('; ')
.slice(0, 1_000)}`
)
}
if (!isWebTool && !isStoryGraphTool(name)) {
const approval =
run.toolProvider.getApproval(
tool,
argumentsValue as Record<string, unknown>,
argumentSummary,
context
)
const decision = await run
.authorize!(approval)
.catch(() => 'deny')
if (decision === 'deny') {
throw new Error(
'DeepSeek Harness MCP 工具调用未获执行授权'
)
}
}
const result = await run.toolProvider.callTool(
name,
argumentsValue as Record<string, unknown>,
run.toolController.signal,
context
)
return {
content: result.parts.map((part) =>
part.type === 'text'
? { type: 'text', text: part.text }
: {
type: 'text',
text: `[${part.mimeType} image result omitted]`
}
)
}
}
throw new Error(
`不支持的 DeepSeek Harness 扩展请求:${method}`
)
},
extNotification: async (method, params) => {
if (method === GOODBUDDY_EVENT) {
this.onBridgeEvent(params)
}
}
}
},
sdk.ndJsonStream(child.stdin, child.stdout)
)
if (!agent) {
throw new Error('DeepSeek Harness ACP 客户端初始化失败')
}
const stateWithoutCapabilities = {
workspace,
child,
connection,
agent
}
void this.consumeStderr(child)
void child.exited.then(
(result) => this.handleExit(result),
() =>
this.fail(
new Error('DeepSeek Harness 无法获取进程退出状态')
)
)
void connection.closed.then(
() => {
if (!this.disposed) {
this.fail(new Error('DeepSeek Harness ACP 连接已关闭'))
}
},
() =>
this.fail(new Error('DeepSeek Harness ACP 连接异常关闭'))
)
const initialization = await withTimeout(
stateWithoutCapabilities.agent.initialize({
protocolVersion: sdk.PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: {
name: 'GoodBuddy',
version: '1'
}
}),
this.initializationTimeoutMs,
'ACP 初始化'
)
const advertisedImageInput =
Boolean(
initialization &&
typeof initialization === 'object' &&
(
initialization as {
agentCapabilities?: {
promptCapabilities?: { image?: unknown }
}
}
).agentCapabilities?.promptCapabilities?.image === true
)
if (
advertisedImageInput !==
(this.options.supportsImageInput === true)
) {
throw new Error(
'DeepSeek Harness Host 图片能力与所选模型连接不一致'
)
}
const capabilities = this.parseCapabilities(
await withTimeout(
stateWithoutCapabilities.agent.extMethod(
GOODBUDDY_HANDSHAKE,
{
controlProtocolVersion:
GOODBUDDY_CONTROL_PROTOCOL_VERSION
}
),
this.initializationTimeoutMs,
'内部控制面握手'
),
sdk.PROTOCOL_VERSION
)
const state = {
...stateWithoutCapabilities,
capabilities
}
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
this.state = state
return state
} catch (error) {
launchController.abort(error)
if (child) {
await this.terminate(child)
}
throw error
} finally {
if (this.launchController === launchController) {
this.launchController = undefined
}
}
}
private async getState(): Promise<HarnessState> {
if (this.fatalError) {
throw this.fatalError
}
if (this.state) {
return this.state
}
this.initialization ??= this.initialize()
try {
return await this.initialization
} catch (error) {
this.initialization = undefined
throw error
}
}
async getStatus(): Promise<AgentRuntimeStatus> {
return {
id: 'deepseek-harness',
label: 'DeepSeek Harness',
available: !this.disposed && !this.fatalError,
supportsToolExecution: true,
detail: this.disposed
? 'DeepSeek Harness Runtime 已关闭'
: this.fatalError?.message ??
(this.state
? `DeepSeek Harness ${this.state.capabilities.harnessVersion} · 当前用户权限`
: 'DeepSeek Harness 已配置,将在首次使用时启动')
}
}
get hasRetainedSessions(): boolean {
return this.sessions.size > 0 || this.sessionInitializations.size > 0
}
private toolsFor(workspace: string): ModelToolProviderLike | undefined {
if (!this.options.createToolProvider) return this.options.toolProvider
let provider = this.workspaceTools.get(workspace)
if (!provider) {
provider = this.options.createToolProvider(workspace)
this.workspaceTools.set(workspace, provider)
}
return provider
}
async testConnection(): Promise<AgentRuntimeStatus> {
try {
await this.getState()
return {
id: 'deepseek-harness',
label: 'DeepSeek Harness',
available: true,
supportsToolExecution: true,
detail: `DeepSeek Harness ${this.state?.capabilities.harnessVersion ?? ''} · 当前用户权限`
}
} catch (error) {
return {
id: 'deepseek-harness',
label: 'DeepSeek Harness',
available: false,
supportsToolExecution: true,
detail:
error instanceof Error
? error.message
: 'DeepSeek Harness 不可用'
}
}
}
async getNativeSnapshot(workspace?: string): Promise<RuntimeNativeSnapshot> {
const state = await this.getState()
const response = await withTimeout(
state.agent.extMethod(
GOODBUDDY_NATIVE_SNAPSHOT,
workspace ? { workspace } : {}
),
this.initializationTimeoutMs,
'原生能力清单'
)
const assignedSkillIds = new Set(
(this.options.skillPackages ?? []).map((skill) => skill.id)
)
const rawSkills = Array.isArray(response.skills)
? response.skills
: []
const skills = rawSkills
.filter(
(
candidate
): candidate is Record<string, unknown> => {
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate)
) {
return false
}
const skill = candidate as Record<string, unknown>
return (
typeof skill.id === 'string' &&
typeof skill.name === 'string' &&
!assignedSkillIds.has(skill.id.trim())
)
}
)
.flatMap((skill) => {
const source =
typeof skill.source === 'string'
? skill.source
: ''
const mappedSource =
source === 'project-dsh' ||
source === 'project-agents'
? ('workspace' as const)
: source === 'user-dsh' ||
source === 'user-agents'
? ('global' as const)
: source === 'runtime'
? ('runtime' as const)
: source === 'custom'
? ('plugin' as const)
: ('unknown' as const)
const description =
typeof skill.description === 'string'
? skill.description.trim()
: ''
const parsed = runtimeNativeSkillSchema.safeParse({
id: skill.id,
name: skill.name,
...(description
? {
description
}
: {}),
source: mappedSource
})
return parsed.success ? [parsed.data] : []
})
.slice(0, runtimeNativeInventoryLimits.skills)
const rawTools = Array.isArray(response.tools)
? response.tools
: []
const toolsSupported =
response.toolsSupported === true &&
Array.isArray(response.tools)
const tools = rawTools
.flatMap((candidate) => {
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate)
) {
return []
}
const tool = candidate as Record<string, unknown>
if (
typeof tool.id !== 'string' ||
typeof tool.name !== 'string'
) {
return []
}
const id = tool.id.trim()
const builtinKind = DSH_BUILTIN_TOOL_KINDS[id]
const description =
typeof tool.description === 'string'
? tool.description.trim()
: ''
const parsed = runtimeNativeToolSchema.safeParse({
id,
name: tool.name,
...(description ? { description } : {}),
kind: builtinKind ?? 'other',
source:
id === 'skill'
? 'skill'
: builtinKind
? 'runtime'
: 'plugin',
ask:
id === 'read'
? 'allowed'
: id === 'skill'
? 'conditional'
: 'blocked',
execute: 'allowed'
})
return parsed.success ? [parsed.data] : []
})
.slice(0, MAX_NATIVE_TOOLS)
return {
provider: 'deepseek-harness',
available: true,
inventoryStatus: toolsSupported ? 'available' : 'partial',
detail:
toolsSupported
? '显示 DeepSeek Harness Host 与插件原生能力;GoodBuddy 分配的 Skill 和 MCP 不在此清单中。'
: 'DeepSeek Harness 已连接,但工具清单暂不可用;GoodBuddy 分配的 Skill 和 MCP 不在原生清单中。',
agents: [],
tools,
toolsSupported,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills,
rules: [],
prompts: [],
resources: [],
resourcesSupported: false,
context: {
strategy: 'unsupported',
manualCompact: false,
detail: 'DeepSeek Harness 暂不支持原生上下文压缩。'
}
}
}
private async acquireConversation(
conversationId: string,
signal: AbortSignal
): Promise<() => void> {
signal.throwIfAborted()
const previous =
this.conversationTails.get(conversationId) ?? Promise.resolve()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.then(
() => gate,
() => gate
)
this.conversationTails.set(conversationId, tail)
const aborted = new Promise<never>((_resolve, reject) => {
const onAbort = (): void => reject(signal.reason)
signal.addEventListener('abort', onAbort, { once: true })
previous.finally(() =>
signal.removeEventListener('abort', onAbort)
)
})
try {
await Promise.race([previous, aborted])
signal.throwIfAborted()
return () => {
release()
if (this.conversationTails.get(conversationId) === tail) {
this.conversationTails.delete(conversationId)
}
}
} catch (error) {
release()
if (this.conversationTails.get(conversationId) === tail) {
this.conversationTails.delete(conversationId)
}
throw error
}
}
private async getSession(
state: HarnessState,
conversationId: string,
workspace: string
): Promise<{ id: string; created: boolean }> {
const current = this.sessions.get(conversationId)
if (current?.workspace === workspace) {
return { id: current.id, created: false }
}
if (current) {
await this.releaseConversation(conversationId)
}
const pending = this.sessionInitializations.get(conversationId)
if (pending) {
return { id: await pending, created: false }
}
const creation = state.agent
.newSession({
cwd: workspace,
mcpServers: []
})
.then((response) => {
if (!response.sessionId) {
throw new Error('DeepSeek Harness 未返回 ACP 会话 ID')
}
this.sessions.set(conversationId, { id: response.sessionId, workspace })
return response.sessionId
})
this.sessionInitializations.set(conversationId, creation)
try {
return { id: await creation, created: true }
} finally {
this.sessionInitializations.delete(conversationId)
}
}
private async waitForUpdate(run: ActiveRun): Promise<void> {
if (run.updates.length > 0 || run.closed || this.fatalError) {
return
}
await new Promise<void>((resolve) => {
run.wake = resolve
})
}
private toRuntimeEvent(
requestId: string,
update: AcpSessionNotification['update'],
toolNames: ActiveRun['toolNames']
): RuntimeEvent | undefined {
if (update.goodBuddyEvent) {
return this.toUsageEvent(
requestId,
update.goodBuddyEvent
)
}
if (
(update.sessionUpdate === 'agent_message_chunk' ||
update.sessionUpdate === 'agent_thought_chunk') &&
update.content?.type === 'text' &&
update.content.text
) {
return {
requestId,
type:
update.sessionUpdate === 'agent_thought_chunk'
? 'reasoning'
: 'text',
delta: update.content.text
}
}
if (
(update.sessionUpdate === 'tool_call' ||
update.sessionUpdate === 'tool_call_update') &&
update.toolCallId
) {
const state =
update.status === 'in_progress'
? 'running'
: update.status === 'completed'
? 'completed'
: update.status === 'failed'
? 'failed'
: 'pending'
const reportedName = (
update.name ??
update.title ??
'DeepSeek Harness 工具'
).slice(0, 200)
const callId = update.toolCallId.slice(0, 256)
const previous = toolNames.get(callId)
const name =
reportedName === 'tool' || (!update.name && !update.title)
? previous?.name ?? reportedName
: reportedName
const title = toolOperationSummary(
undefined,
update.title === 'tool' || update.title === update.name || update.title === previous?.name
? undefined : update.title
) ?? previous?.title
const input = safeStringify(update.rawInput) ?? previous?.input
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
if (state === 'pending' || state === 'running') {
toolNames.set(callId, { name, input, summary, title })
} else {
toolNames.delete(callId)
}
const output = safeStringify(update.rawOutput)
return {
requestId,
type: 'tool',
callId,
name,
state,
summary: summary ?? `DeepSeek Harness 工具:${name}`,
...(input ? { input } : {}),
...(output ? { output } : {})
}
}
return undefined
}
async *run(
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted()
if (
request.images?.length &&
this.options.supportsImageInput !== true
) {
throw new Error('当前 DeepSeek Harness 模型连接未启用图像输入')
}
const release = await this.acquireConversation(
request.conversationId,
signal
)
let state: HarnessState | undefined
let sessionId: string | undefined
let run: ActiveRun | undefined
const toolController = new AbortController()
const abortTools = (): void => {
toolController.abort(signal.reason)
}
signal.addEventListener('abort', abortTools, { once: true })
try {
state = await this.getState()
signal.throwIfAborted()
const workspace = request.executionWorkspace
? await realpath(request.executionWorkspace)
: state.workspace
const session = await this.getSession(
state,
request.conversationId,
workspace
)
sessionId = session.id
// A reused Harness session already retains earlier turns natively.
const includeHistory = session.created
run = {
request,
toolProvider: this.toolsFor(workspace),
toolController,
authorize,
updates: [],
toolNames: new Map(),
closed: false
}
this.activeRuns.set(sessionId, run)
yield {
requestId: request.requestId,
type: 'status',
message: 'DeepSeek Harness 正在处理请求'
}
let completed = false
let response:
| { stopReason?: string }
| undefined
let promptError: unknown
const cancel = (): void => {
run!.closed = true
run!.wake?.()
run!.wake = undefined
void state!.agent
.cancel({
sessionId: sessionId!
})
.catch(() => undefined)
}
signal.addEventListener('abort', cancel, { once: true })
if (signal.aborted) {
cancel()
}
try {
await withTimeout(
state.agent.extMethod(GOODBUDDY_PREPARE, {
sessionId,
requestId: request.requestId,
mode:
request.workMode === 'execute' ? 'execute' : 'ask'
}),
this.initializationTimeoutMs,
'请求准备'
)
signal.throwIfAborted()
} catch (error) {
signal.removeEventListener('abort', cancel)
throw error
}
const promptOperation = state.agent.prompt({
sessionId,
prompt: [
{
type: 'text',
text: promptWithUntrustedConversationHistory(
request,
includeHistory
)
},
...(request.images ?? []).map((image) => ({
type: 'image' as const,
data: image.data,
mimeType: image.mediaType
}))
]
})
const prompt = (
this.promptTimeoutMs === undefined
? promptOperation
: withTimeout(
promptOperation,
this.promptTimeoutMs,
'请求'
)
)
.then((value) => {
response = value
})
.catch((error: unknown) => {
promptError = harnessPromptError(error)
})
.finally(() => {
completed = true
run!.wake?.()
run!.wake = undefined
})
try {
while (!completed || run.updates.length > 0) {
signal.throwIfAborted()
if (this.fatalError) {
throw this.fatalError
}
if (run.closed && !completed) {
throw new Error(
'DeepSeek Harness 请求在完成前已关闭'
)
}
if (run.updates.length === 0) {
await this.waitForUpdate(run)
continue
}
const update = run.updates.shift()!
const event = this.toRuntimeEvent(
request.requestId,
update,
run.toolNames
)
if (event) {
yield event
}
}
await prompt
if (promptError) {
cancel()
throw promptError
}
if (response?.stopReason === 'cancelled') {
throw signal.aborted
? signal.reason
: new Error('DeepSeek Harness 请求已取消')
}
yield {
requestId: request.requestId,
type: 'done',
sessionId
}
} finally {
if (!completed) {
cancel()
}
signal.removeEventListener('abort', cancel)
if (completed) {
await prompt
} else {
await withTimeout(
prompt,
this.shutdownTimeoutMs,
'取消请求'
).catch(async () => {
await withTimeout(
state!.agent.extMethod(GOODBUDDY_RELEASE, { sessionId }),
this.shutdownTimeoutMs,
'释放已取消会话'
).catch(async () => {
await this.terminate(state!.child)
})
await this.forgetSession(request.conversationId)
})
}
}
} finally {
signal.removeEventListener('abort', abortTools)
toolController.abort(
new Error('DeepSeek Harness 请求工具上下文已关闭')
)
if (run) {
run.closed = true
run.toolController.abort(
new Error('DeepSeek Harness 请求工具上下文已关闭')
)
}
if (sessionId && this.activeRuns.get(sessionId) === run) {
this.activeRuns.delete(sessionId)
}
release()
}
}
async releaseConversation(conversationId: string): Promise<void> {
await this.sessionInitializations.get(conversationId)?.catch(() => undefined)
const session = this.sessions.get(conversationId)
const sessionId = session?.id
if (sessionId && this.state) {
await this.state.agent
.extMethod(GOODBUDDY_RELEASE, { sessionId })
.catch(async () => {
await this.state?.agent
.cancel({ sessionId })
.catch(() => undefined)
})
}
await this.forgetSession(conversationId)
}
private async forgetSession(conversationId: string): Promise<void> {
const session = this.sessions.get(conversationId)
const provider = session
? this.workspaceTools.get(session.workspace) ?? this.options.toolProvider
: this.options.toolProvider
this.sessions.delete(conversationId)
if (session) this.proxyToolCatalogs.delete(session.id)
await provider
?.releaseConversation(conversationId)
.catch(() => undefined)
if (
session && provider &&
this.workspaceTools.get(session.workspace) === provider &&
![...this.sessions.values()].some((other) => other.workspace === session.workspace)
) {
this.workspaceTools.delete(session.workspace)
await provider.dispose()
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.launchController?.abort(
new Error('DeepSeek Harness Runtime 已关闭')
)
this.launchController = undefined
const state = this.state
this.state = undefined
this.initialization = undefined
for (const [sessionId, run] of this.activeRuns) {
run.closed = true
run.toolController.abort(
new Error('DeepSeek Harness Runtime 已关闭')
)
run.wake?.()
run.wake = undefined
if (state) {
void state.agent
.cancel({
sessionId
})
.catch(() => undefined)
}
}
this.activeRuns.clear()
this.sessions.clear()
this.proxyToolCatalogs.clear()
this.sessionInitializations.clear()
this.conversationTails.clear()
const providers = new Set([
...this.workspaceTools.values(),
...(this.options.toolProvider ? [this.options.toolProvider] : [])
])
this.workspaceTools.clear()
const disposeTools = () => Promise.allSettled(
[...providers].map((provider) => provider.dispose())
)
if (!state) {
await disposeTools()
return
}
await withTimeout(
state.agent.extMethod(GOODBUDDY_SHUTDOWN, {}),
this.shutdownTimeoutMs,
'内部控制面关闭'
).catch(() => undefined)
await disposeTools()
await this.terminate(state.child)
await withTimeout(
Promise.allSettled([
state.child.exited,
state.connection.closed
]).then(() => undefined),
this.shutdownTimeoutMs,
'关闭'
).catch(() => undefined)
}
}
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
import { isStoryGraphTool } from '../../shared/story-graph-tools'