Add model-controlled image invocation and read-only runtime assignments. Route image tools through Main so accepted operations retain their originating conversation when chat runtimes change, with saved references, cancellation, and manual recovery. Keep Node hashing outside browser-shared contracts to restore production builds. Validation includes the production build, typecheck, lint, focused regressions, real Host transport checks, and two successful real image requests. Full UI-driven runtime switching acceptance remains documented as pending. Release note: 开启图片模型的会话调用后,聊天助手可调用图片生成与编辑工具,Runtime 分配自动管理。图片成果保存在原会话,可继续修改,并独立显示生成状态和取消入口。
1455 lines
40 KiB
TypeScript
1455 lines
40 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { isAbsolute } from 'node:path'
|
|
import {
|
|
AgentSideConnection,
|
|
PROTOCOL_VERSION,
|
|
RequestError,
|
|
type Agent,
|
|
type AgentSideConnection as AcpAgentConnection,
|
|
type ContentBlock as AcpContentBlock,
|
|
type Stream
|
|
} from '@agentclientprotocol/sdk'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
|
import {
|
|
AttachmentError,
|
|
isImageAdmissionError,
|
|
type ImageAttachmentRef,
|
|
type SaveImageAttachment
|
|
} from '@deepseek-ai/dsh-attachment'
|
|
import {
|
|
CredentialProvider,
|
|
type CredentialInfo,
|
|
type CredentialKey,
|
|
type CredentialRecord,
|
|
type CredentialRecordEntry,
|
|
type CredentialRecordInfo,
|
|
type CredentialRef,
|
|
type ResolvedCredential
|
|
} from '@deepseek-ai/dsh-credentials'
|
|
import {
|
|
createUserMessage,
|
|
errorChain,
|
|
type ContentBlock as HarnessContentBlock,
|
|
type TokenUsage
|
|
} from '@deepseek-ai/dsh-llm'
|
|
import {
|
|
SessionId,
|
|
type SessionEvent
|
|
} from '@deepseek-ai/dsh-session'
|
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
|
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 { GoodBuddyHarnessAttachmentStore } from './goodbuddy-harness-attachment-store'
|
|
export {
|
|
GOODBUDDY_CONTROL_PROTOCOL_VERSION,
|
|
GOODBUDDY_CREDENTIAL,
|
|
GOODBUDDY_EVENT,
|
|
GOODBUDDY_HANDSHAKE,
|
|
GOODBUDDY_NATIVE_SNAPSHOT,
|
|
GOODBUDDY_RELEASE,
|
|
GOODBUDDY_SHUTDOWN,
|
|
GOODBUDDY_TOOLS_CALL,
|
|
GOODBUDDY_TOOLS_LIST,
|
|
GOODBUDDY_PREPARE
|
|
} from './deepseek-harness-protocol'
|
|
|
|
const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024
|
|
const DELTA_BATCH_CHARACTERS = 4 * 1024
|
|
const DELTA_BATCH_INTERVAL_MS = 100
|
|
const MAX_SUMMARY_CHARACTERS = 4_000
|
|
const MAX_MCP_PROXY_RESULT_BYTES = 256 * 1024
|
|
const MAX_NATIVE_SKILLS = 200
|
|
const MAX_NATIVE_TOOLS = 200
|
|
const NATIVE_SNAPSHOT_TIMEOUT_MS = 2_000
|
|
const MAIN_WEB_TOOL_NAMES = new Set(['web_search', 'web_fetch'])
|
|
const GOODBUDDY_EXECUTION_GUIDANCE = [
|
|
'GoodBuddy work mode rules:',
|
|
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
|
|
'- Inspect only what is needed, then create or update the requested workspace files promptly.',
|
|
'- Work in small verifiable steps and use tool results as the source of truth.',
|
|
'- Keep reasoning concise. Do not narrate code that can be written and checked with tools.',
|
|
'- In Ask mode, remain read-only and do not attempt mutations.'
|
|
].join('\n')
|
|
|
|
export type GoodBuddyWorkMode = 'ask' | 'execute'
|
|
|
|
export type GoodBuddyHarnessCapabilities = {
|
|
controlProtocolVersion: 1
|
|
harnessVersion: string
|
|
acpProtocolVersion: number
|
|
supports: {
|
|
cancellation: true
|
|
sessionRelease: true
|
|
reasoningEvents: boolean
|
|
toolEvents: boolean
|
|
usageEvents: boolean
|
|
credentialResolution: true
|
|
}
|
|
execution: {
|
|
mode: 'host'
|
|
}
|
|
}
|
|
|
|
export type GoodBuddyHarnessControlConfig = {
|
|
provider: string
|
|
model: string
|
|
supportsImageInput?: boolean
|
|
workspace: string
|
|
harnessVersion: string
|
|
execution: GoodBuddyHarnessCapabilities['execution']
|
|
credentialRefs: readonly string[]
|
|
skills: readonly {
|
|
name: string
|
|
description: string
|
|
content: string
|
|
directory: string
|
|
}[]
|
|
trustedAskToolDefinitions?: ReadonlyMap<string, ToolDefinition>
|
|
stream?: Stream
|
|
maxEventCharacters?: number
|
|
}
|
|
|
|
type Preparation = {
|
|
requestId: string
|
|
mode: GoodBuddyWorkMode
|
|
}
|
|
|
|
type OwnedSession = {
|
|
handle: AgentHandle
|
|
attachmentRefs: ImageAttachmentRef[]
|
|
preparation?: Preparation
|
|
proxyTools: Map<
|
|
string,
|
|
{
|
|
definition: ToolDefinition
|
|
dispose: () => void
|
|
}
|
|
>
|
|
askToolDefinitions: Map<string, ToolDefinition>
|
|
inflight?: {
|
|
requestId: string
|
|
messageId: string
|
|
mode: GoodBuddyWorkMode
|
|
turn?: number
|
|
endReason?: string
|
|
turnError?: unknown
|
|
resolve: (reason: string) => void
|
|
reject: (error: unknown) => void
|
|
eventTail: Promise<void>
|
|
eventError?: unknown
|
|
pendingDelta?: {
|
|
type: 'text' | 'reasoning'
|
|
delta: string
|
|
}
|
|
pendingDeltaTimer?: ReturnType<typeof setTimeout>
|
|
}
|
|
}
|
|
|
|
type ProxyToolDefinition = {
|
|
name: string
|
|
description: string
|
|
inputSchema: Record<string, unknown>
|
|
}
|
|
|
|
function validateProxyToolResult(
|
|
value: unknown
|
|
): Record<string, unknown> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool result is invalid')
|
|
}
|
|
const serialized = JSON.stringify(value)
|
|
if (
|
|
Buffer.byteLength(serialized, 'utf8') >
|
|
MAX_MCP_PROXY_RESULT_BYTES
|
|
) {
|
|
throw new Error(
|
|
'GoodBuddy MCP tool result exceeds safety limit'
|
|
)
|
|
}
|
|
const content = (value as Record<string, unknown>).content
|
|
if (
|
|
!Array.isArray(content) ||
|
|
content.length > 100 ||
|
|
content.some(
|
|
(part) =>
|
|
!part ||
|
|
typeof part !== 'object' ||
|
|
Array.isArray(part) ||
|
|
(part as Record<string, unknown>).type !== 'text' ||
|
|
typeof (part as Record<string, unknown>).text !== 'string'
|
|
)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool result is invalid')
|
|
}
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
function parseProxyToolCatalog(
|
|
value: unknown
|
|
): ProxyToolDefinition[] {
|
|
if (!Array.isArray(value) || value.length > 100) {
|
|
throw new Error('GoodBuddy MCP tool catalog is invalid')
|
|
}
|
|
const names = new Set<string>()
|
|
return value.map((candidate) => {
|
|
if (
|
|
!candidate ||
|
|
typeof candidate !== 'object' ||
|
|
Array.isArray(candidate)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool definition is invalid')
|
|
}
|
|
const tool = candidate as Record<string, unknown>
|
|
if (
|
|
typeof tool.name !== 'string' ||
|
|
!/^[a-zA-Z0-9_-]{1,64}$/u.test(tool.name) ||
|
|
names.has(tool.name) ||
|
|
typeof tool.description !== 'string' ||
|
|
tool.description.length > (tool.name === 'generate_image' ? imageToolDescriptionLimit : 1_000) ||
|
|
!tool.inputSchema ||
|
|
typeof tool.inputSchema !== 'object' ||
|
|
Array.isArray(tool.inputSchema)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool definition is invalid')
|
|
}
|
|
names.add(tool.name)
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
inputSchema: tool.inputSchema as Record<string, unknown>
|
|
}
|
|
})
|
|
}
|
|
|
|
type CredentialResolver = (
|
|
ref: string
|
|
) => Promise<string | undefined>
|
|
|
|
/**
|
|
* Memory-only credential provider. It deliberately has no writable operation
|
|
* and can resolve only references registered by the trusted host.
|
|
*/
|
|
export class GoodBuddyCredentialProvider extends CredentialProvider {
|
|
private resolver?: CredentialResolver
|
|
|
|
constructor(
|
|
ctx: Context,
|
|
private readonly allowedRefs: ReadonlySet<string>
|
|
) {
|
|
super(ctx)
|
|
}
|
|
|
|
bind(resolver: CredentialResolver): void {
|
|
if (this.resolver) {
|
|
throw new Error('GoodBuddy credential resolver is already bound')
|
|
}
|
|
this.resolver = resolver
|
|
}
|
|
|
|
async resolve(
|
|
ref: CredentialRef
|
|
): Promise<ResolvedCredential | undefined> {
|
|
if (!this.allowedRefs.has(ref) || !this.resolver) {
|
|
return undefined
|
|
}
|
|
const value = await this.resolver(ref)
|
|
return value ? { value, source: 'goodbuddy-main' } : undefined
|
|
}
|
|
|
|
async describe(ref: CredentialRef): Promise<CredentialInfo> {
|
|
const available =
|
|
this.allowedRefs.has(ref) && this.resolver !== undefined
|
|
return {
|
|
configured: available,
|
|
source: available ? 'goodbuddy-main' : undefined,
|
|
writable: false
|
|
}
|
|
}
|
|
|
|
async set(ref: CredentialRef, value: string): Promise<void> {
|
|
void ref
|
|
void value
|
|
throw new Error('GoodBuddy Harness credentials are read-only')
|
|
}
|
|
|
|
async unset(ref: CredentialRef): Promise<void> {
|
|
void ref
|
|
throw new Error('GoodBuddy Harness credentials are read-only')
|
|
}
|
|
|
|
async readRecord(
|
|
key: CredentialKey
|
|
): Promise<CredentialRecord | undefined> {
|
|
void key
|
|
return undefined
|
|
}
|
|
|
|
async describeRecord(
|
|
key: CredentialKey
|
|
): Promise<CredentialRecordInfo> {
|
|
void key
|
|
return { configured: false, writable: false }
|
|
}
|
|
|
|
async listRecords(): Promise<readonly CredentialRecordEntry[]> {
|
|
return []
|
|
}
|
|
|
|
async modifyRecord(
|
|
key: CredentialKey,
|
|
mutate: (
|
|
current: CredentialRecord | undefined
|
|
) => Promise<CredentialRecord | undefined>
|
|
): Promise<CredentialRecord | undefined> {
|
|
void key
|
|
void mutate
|
|
throw new Error('GoodBuddy Harness credentials are read-only')
|
|
}
|
|
|
|
async deleteRecord(key: CredentialKey): Promise<void> {
|
|
void key
|
|
throw new Error('GoodBuddy Harness credentials are read-only')
|
|
}
|
|
}
|
|
|
|
export function createBoundedAcpStream(
|
|
stream: Stream,
|
|
maxFrameBytes: number
|
|
): Stream {
|
|
if (
|
|
!Number.isSafeInteger(maxFrameBytes) ||
|
|
maxFrameBytes < 1
|
|
) {
|
|
throw new TypeError('maxFrameBytes must be a positive integer')
|
|
}
|
|
return {
|
|
readable: stream.readable.pipeThrough(
|
|
new TransformStream({
|
|
transform(message, controller) {
|
|
if (
|
|
Buffer.byteLength(JSON.stringify(message), 'utf8') >
|
|
maxFrameBytes
|
|
) {
|
|
throw new Error(
|
|
`ACP input frame exceeds ${maxFrameBytes} bytes`
|
|
)
|
|
}
|
|
controller.enqueue(message)
|
|
}
|
|
})
|
|
),
|
|
writable: new WritableStream({
|
|
async write(message) {
|
|
if (
|
|
Buffer.byteLength(JSON.stringify(message), 'utf8') >
|
|
maxFrameBytes
|
|
) {
|
|
throw new Error(
|
|
`ACP output frame exceeds ${maxFrameBytes} bytes`
|
|
)
|
|
}
|
|
const writer = stream.writable.getWriter()
|
|
try {
|
|
await writer.write(message)
|
|
} finally {
|
|
writer.releaseLock()
|
|
}
|
|
},
|
|
async close() {
|
|
const writer = stream.writable.getWriter()
|
|
try {
|
|
await writer.close()
|
|
} finally {
|
|
writer.releaseLock()
|
|
}
|
|
},
|
|
async abort(reason) {
|
|
await stream.writable.abort(reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function requiredString(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
maxLength = 512
|
|
): string {
|
|
const value = params[key]
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
value.length > maxLength
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`${key} must be a non-empty bounded string`
|
|
)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function boundedJson(value: unknown): string | undefined {
|
|
if (value === undefined) {
|
|
return undefined
|
|
}
|
|
try {
|
|
return (
|
|
typeof value === 'string'
|
|
? value
|
|
: JSON.stringify(value)
|
|
).slice(0, MAX_SUMMARY_CHARACTERS)
|
|
} catch {
|
|
return '[unserializable]'
|
|
}
|
|
}
|
|
|
|
function promptText(prompt: readonly AcpContentBlock[]): string {
|
|
const text: string[] = []
|
|
for (const block of prompt) {
|
|
switch (block.type) {
|
|
case 'text':
|
|
text.push(block.text)
|
|
break
|
|
case 'image':
|
|
case 'resource_link':
|
|
break
|
|
case 'audio':
|
|
case 'resource':
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`unsupported ACP prompt content: ${block.type}`
|
|
)
|
|
default:
|
|
block satisfies never
|
|
}
|
|
}
|
|
return text.join('')
|
|
}
|
|
|
|
function turnReason(event: SessionEvent): string | undefined {
|
|
if (event.type !== 'turn/end') {
|
|
return undefined
|
|
}
|
|
switch (event.data.reason.kind) {
|
|
case 'aborted':
|
|
case 'interrupted':
|
|
return 'cancelled'
|
|
case 'max-tokens':
|
|
return 'max_tokens'
|
|
default:
|
|
return 'end_turn'
|
|
}
|
|
}
|
|
|
|
export class GoodBuddyHarnessControlPlane {
|
|
private readonly sessions = new Map<string, OwnedSession>()
|
|
private readonly allowedCredentialRefs: ReadonlySet<string>
|
|
private readonly maxEventCharacters: number
|
|
private connection?: AcpAgentConnection
|
|
private credentialProvider?: GoodBuddyCredentialProvider
|
|
private handshaken = false
|
|
private observing = false
|
|
private closed = false
|
|
private disposing?: Promise<void>
|
|
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
private readonly config: GoodBuddyHarnessControlConfig
|
|
) {
|
|
this.allowedCredentialRefs = new Set(config.credentialRefs)
|
|
this.maxEventCharacters =
|
|
config.maxEventCharacters ?? DEFAULT_MAX_EVENT_CHARACTERS
|
|
}
|
|
|
|
bindCredentialProvider(
|
|
provider: GoodBuddyCredentialProvider
|
|
): void {
|
|
if (this.connection) {
|
|
throw new Error(
|
|
'Credential provider must be bound before bridge start'
|
|
)
|
|
}
|
|
this.credentialProvider = provider
|
|
}
|
|
|
|
start(): AcpAgentConnection {
|
|
if (this.connection) {
|
|
throw new Error(
|
|
'GoodBuddy Harness control plane is already started'
|
|
)
|
|
}
|
|
if (!this.config.stream) {
|
|
throw new Error(
|
|
'GoodBuddy Harness control plane requires its internal Host transport'
|
|
)
|
|
}
|
|
const connection = new AgentSideConnection(
|
|
() => this.createAgentApi(),
|
|
this.config.stream
|
|
)
|
|
this.connection = connection
|
|
this.credentialProvider?.bind(async (ref) => {
|
|
if (!this.handshaken || !this.allowedCredentialRefs.has(ref)) {
|
|
return undefined
|
|
}
|
|
const response = await connection.extMethod(
|
|
GOODBUDDY_CREDENTIAL,
|
|
{ ref }
|
|
)
|
|
const value = response.value
|
|
return typeof value === 'string' && value.length > 0
|
|
? value
|
|
: undefined
|
|
})
|
|
void connection.closed.finally(() => this.dispose())
|
|
return connection
|
|
}
|
|
|
|
private capabilities(): GoodBuddyHarnessCapabilities {
|
|
return {
|
|
controlProtocolVersion: GOODBUDDY_CONTROL_PROTOCOL_VERSION,
|
|
harnessVersion: this.config.harnessVersion,
|
|
acpProtocolVersion: PROTOCOL_VERSION,
|
|
supports: {
|
|
cancellation: true,
|
|
sessionRelease: true,
|
|
reasoningEvents: true,
|
|
toolEvents: true,
|
|
usageEvents: true,
|
|
credentialResolution: true
|
|
},
|
|
execution: this.config.execution
|
|
}
|
|
}
|
|
|
|
private requireSession(sessionId: string): OwnedSession {
|
|
const session = this.sessions.get(sessionId)
|
|
if (!session) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`unknown session: ${sessionId}`
|
|
)
|
|
}
|
|
return session
|
|
}
|
|
|
|
private async sendEvent(
|
|
sessionId: string,
|
|
event: Record<string, unknown>
|
|
): Promise<void> {
|
|
const record = this.sessions.get(sessionId)
|
|
const inflight = record?.inflight
|
|
if (!inflight || !this.connection) {
|
|
return
|
|
}
|
|
const params = {
|
|
sessionId,
|
|
requestId: inflight.requestId,
|
|
...event
|
|
}
|
|
const text = JSON.stringify(params)
|
|
if (text.length > this.maxEventCharacters) {
|
|
throw new Error(
|
|
'GoodBuddy Harness control event exceeds safety limit'
|
|
)
|
|
}
|
|
await this.connection.extNotification(GOODBUDDY_EVENT, params)
|
|
}
|
|
|
|
private enqueueEvent(
|
|
sessionId: string,
|
|
event: Record<string, unknown>
|
|
): void {
|
|
const record = this.sessions.get(sessionId)
|
|
const inflight = record?.inflight
|
|
if (!record || !inflight) {
|
|
return
|
|
}
|
|
const previous = inflight.eventTail
|
|
const queued = previous.then(() =>
|
|
this.sendEvent(sessionId, event)
|
|
)
|
|
inflight.eventTail = queued.catch((error: unknown) => {
|
|
inflight.eventError ??= error
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
})
|
|
}
|
|
|
|
private flushPendingDelta(sessionId: string): void {
|
|
const inflight = this.sessions.get(sessionId)?.inflight
|
|
const pending = inflight?.pendingDelta
|
|
if (!inflight || !pending) {
|
|
return
|
|
}
|
|
if (inflight.pendingDeltaTimer) {
|
|
clearTimeout(inflight.pendingDeltaTimer)
|
|
inflight.pendingDeltaTimer = undefined
|
|
}
|
|
inflight.pendingDelta = undefined
|
|
this.enqueueEvent(sessionId, pending)
|
|
}
|
|
|
|
private maxDeltaBatchCharacters(
|
|
sessionId: string,
|
|
type: 'text' | 'reasoning'
|
|
): number {
|
|
const inflight = this.sessions.get(sessionId)?.inflight
|
|
if (!inflight) {
|
|
return 0
|
|
}
|
|
const envelopeLength = JSON.stringify({
|
|
sessionId,
|
|
requestId: inflight.requestId,
|
|
type,
|
|
delta: ''
|
|
}).length
|
|
return Math.min(
|
|
DELTA_BATCH_CHARACTERS,
|
|
this.maxEventCharacters - envelopeLength
|
|
)
|
|
}
|
|
|
|
private queueDelta(
|
|
sessionId: string,
|
|
type: 'text' | 'reasoning',
|
|
delta: string
|
|
): void {
|
|
const inflight = this.sessions.get(sessionId)?.inflight
|
|
if (!inflight || !delta) {
|
|
return
|
|
}
|
|
const batchLimit = this.maxDeltaBatchCharacters(
|
|
sessionId,
|
|
type
|
|
)
|
|
if (batchLimit < 1) {
|
|
this.failEventStream(
|
|
sessionId,
|
|
new Error(
|
|
'GoodBuddy Harness control event exceeds safety limit'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
let remaining = delta
|
|
while (remaining) {
|
|
if (
|
|
inflight.pendingDelta &&
|
|
inflight.pendingDelta.type !== type
|
|
) {
|
|
this.flushPendingDelta(sessionId)
|
|
}
|
|
inflight.pendingDelta ??= { type, delta: '' }
|
|
const available =
|
|
batchLimit - inflight.pendingDelta.delta.length
|
|
if (available <= 0) {
|
|
this.flushPendingDelta(sessionId)
|
|
continue
|
|
}
|
|
inflight.pendingDelta.delta += remaining.slice(0, available)
|
|
remaining = remaining.slice(available)
|
|
if (inflight.pendingDelta.delta.length >= batchLimit) {
|
|
this.flushPendingDelta(sessionId)
|
|
}
|
|
}
|
|
if (
|
|
inflight.pendingDelta &&
|
|
!inflight.pendingDeltaTimer
|
|
) {
|
|
inflight.pendingDeltaTimer = setTimeout(() => {
|
|
this.flushPendingDelta(sessionId)
|
|
}, DELTA_BATCH_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
private queueEvent(
|
|
sessionId: string,
|
|
event: Record<string, unknown>
|
|
): void {
|
|
this.flushPendingDelta(sessionId)
|
|
this.enqueueEvent(sessionId, event)
|
|
}
|
|
|
|
private failEventStream(sessionId: string, error: unknown): void {
|
|
const record = this.sessions.get(sessionId)
|
|
const inflight = record?.inflight
|
|
if (!record || !inflight) {
|
|
return
|
|
}
|
|
inflight.eventError ??= error
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
}
|
|
|
|
private observeSessions(): void {
|
|
if (this.observing) {
|
|
return
|
|
}
|
|
this.observing = true
|
|
this.ctx.on('tools/execute', async (exec, next) => {
|
|
const sessionId = exec.agent?.session.id
|
|
const record = sessionId
|
|
? this.sessions.get(sessionId)
|
|
: undefined
|
|
if (
|
|
record &&
|
|
record.handle.agent === exec.agent &&
|
|
record.inflight?.mode === 'ask'
|
|
) {
|
|
const registeredDefinition =
|
|
record.askToolDefinitions.get(exec.name)
|
|
const executingDefinition =
|
|
record.handle.agent.ctx.tools.get(
|
|
exec.name,
|
|
exec.agent
|
|
)
|
|
if (
|
|
!registeredDefinition ||
|
|
executingDefinition !== registeredDefinition
|
|
) {
|
|
throw new Error(
|
|
`Ask 模式不允许执行非只读工具:${exec.name}`
|
|
)
|
|
}
|
|
}
|
|
return next()
|
|
})
|
|
this.ctx.on(
|
|
'session/event',
|
|
(session, event: SessionEvent) => {
|
|
const record = this.sessions.get(session.header.id)
|
|
if (!record || record.handle.agent.session !== session) {
|
|
return
|
|
}
|
|
const inflight = record.inflight
|
|
if (!inflight) {
|
|
return
|
|
}
|
|
if (event.type === 'assistant/chunk') {
|
|
const chunk = event.data.chunk
|
|
if (
|
|
chunk.type === 'text-delta' ||
|
|
chunk.type === 'reasoning-delta'
|
|
) {
|
|
this.queueDelta(
|
|
session.header.id,
|
|
chunk.type === 'text-delta'
|
|
? 'text'
|
|
: 'reasoning',
|
|
chunk.text
|
|
)
|
|
} else if (chunk.type === 'usage') {
|
|
this.queueUsage(session.header.id, chunk.usage)
|
|
}
|
|
} else if (event.type === 'tool/call') {
|
|
this.queueEvent(session.header.id, {
|
|
type: 'tool',
|
|
callId: event.data.callId,
|
|
name: event.data.name,
|
|
state: 'pending',
|
|
input: event.data.arguments.slice(
|
|
0,
|
|
MAX_SUMMARY_CHARACTERS
|
|
)
|
|
})
|
|
} else if (event.type === 'tool/result') {
|
|
const toolResult = event.data.message.content.find(
|
|
(content) => content.type === 'tool-result'
|
|
)
|
|
this.queueEvent(session.header.id, {
|
|
type: 'tool',
|
|
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
|
name: 'tool',
|
|
state:
|
|
event.data.error || toolResult?.isError === true
|
|
? 'failed'
|
|
: 'completed',
|
|
output: boundedJson(event.data.message.content)
|
|
})
|
|
}
|
|
if (
|
|
event.type === 'turn/end' &&
|
|
inflight.turn === event.data.turn
|
|
) {
|
|
inflight.endReason = turnReason(event)
|
|
}
|
|
}
|
|
)
|
|
this.ctx.on(
|
|
'agent/inbox/claimed',
|
|
({ agent, message, turn }) => {
|
|
const record = this.sessions.get(agent.session.id)
|
|
if (
|
|
record?.handle.agent === agent &&
|
|
record.inflight?.messageId === message.id
|
|
) {
|
|
record.inflight.turn = turn
|
|
}
|
|
}
|
|
)
|
|
this.ctx.on(
|
|
'agent/error',
|
|
({ agent, error }) => {
|
|
const record = this.sessions.get(agent.session.id)
|
|
if (record?.handle.agent === agent) {
|
|
if (record.inflight) {
|
|
record.inflight.turnError = error
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private queueUsage(sessionId: string, usage: TokenUsage): void {
|
|
this.flushPendingDelta(sessionId)
|
|
this.enqueueEvent(sessionId, {
|
|
type: 'model-usage',
|
|
callId: randomUUID(),
|
|
provider: this.config.provider,
|
|
model: this.config.model,
|
|
inputTokens: usage.inputTokens,
|
|
outputTokens: usage.outputTokens,
|
|
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
cacheWriteTokens: usage.cacheWriteTokens ?? 0
|
|
})
|
|
}
|
|
|
|
private proxyToolDefinition(
|
|
sessionId: string,
|
|
tool: ProxyToolDefinition
|
|
): ToolDefinition {
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: tool.inputSchema,
|
|
output: {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
content: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: true
|
|
}
|
|
}
|
|
},
|
|
required: ['content'],
|
|
additionalProperties: false
|
|
},
|
|
render(_args, result) {
|
|
const content = (
|
|
result as {
|
|
content: Array<{
|
|
type?: unknown
|
|
text?: unknown
|
|
}>
|
|
}
|
|
).content
|
|
return content
|
|
.filter(
|
|
(
|
|
part
|
|
): part is {
|
|
type: 'text'
|
|
text: string
|
|
} =>
|
|
part.type === 'text' &&
|
|
typeof part.text === 'string'
|
|
)
|
|
.map((part) => ({
|
|
type: 'text' as const,
|
|
text: part.text
|
|
}))
|
|
}
|
|
},
|
|
execute: async (argumentsValue) => {
|
|
const result = await this.connection!.extMethod(
|
|
GOODBUDDY_TOOLS_CALL,
|
|
{
|
|
sessionId,
|
|
name: tool.name,
|
|
callId: randomUUID(),
|
|
arguments: argumentsValue
|
|
}
|
|
)
|
|
return validateProxyToolResult(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async refreshProxyTools(
|
|
sessionId: string,
|
|
record: OwnedSession
|
|
): Promise<void> {
|
|
const response = await this.connection!.extMethod(
|
|
GOODBUDDY_TOOLS_LIST,
|
|
{ sessionId }
|
|
)
|
|
const tools = parseProxyToolCatalog(response.tools)
|
|
const nextNames = new Set(tools.map((tool) => tool.name))
|
|
for (const [name, registration] of record.proxyTools) {
|
|
if (!nextNames.has(name)) {
|
|
registration.dispose()
|
|
record.proxyTools.delete(name)
|
|
record.askToolDefinitions.delete(name)
|
|
}
|
|
}
|
|
for (const tool of tools) {
|
|
if (!record.proxyTools.has(tool.name)) {
|
|
const definition = this.proxyToolDefinition(sessionId, tool)
|
|
const dispose =
|
|
record.handle.agent.ctx.tools.register(definition)
|
|
record.proxyTools.set(tool.name, {
|
|
definition,
|
|
dispose
|
|
})
|
|
if (MAIN_WEB_TOOL_NAMES.has(tool.name)) {
|
|
record.askToolDefinitions.set(tool.name, definition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async nativeSnapshot(
|
|
workspace = this.config.workspace
|
|
): Promise<Record<string, unknown>> {
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(
|
|
() =>
|
|
controller.abort(
|
|
new Error('DeepSeek Harness native inventory timed out')
|
|
),
|
|
NATIVE_SNAPSHOT_TIMEOUT_MS
|
|
)
|
|
try {
|
|
const skills = await Promise.race([
|
|
this.ctx.skills.list({
|
|
cwd: workspace,
|
|
signal: controller.signal
|
|
}),
|
|
new Promise<never>((_resolve, reject) => {
|
|
controller.signal.addEventListener(
|
|
'abort',
|
|
() => reject(controller.signal.reason),
|
|
{ once: true }
|
|
)
|
|
})
|
|
])
|
|
let toolsSupported = true
|
|
let tools: Array<{
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
}> = []
|
|
try {
|
|
tools = this.ctx.tools
|
|
.schemas()
|
|
.slice(0, MAX_NATIVE_TOOLS)
|
|
.flatMap((tool) => {
|
|
const name = tool.name.trim().slice(0, 128)
|
|
if (!name) {
|
|
return []
|
|
}
|
|
const description = tool.description.trim().slice(0, 2_000)
|
|
return [
|
|
{
|
|
id: name,
|
|
name: name.slice(0, 200),
|
|
...(description ? { description } : {})
|
|
}
|
|
]
|
|
})
|
|
} catch {
|
|
toolsSupported = false
|
|
}
|
|
return {
|
|
tools,
|
|
toolsSupported,
|
|
skills: skills.slice(0, MAX_NATIVE_SKILLS).map((skill) => ({
|
|
id: skill.name.slice(0, 128),
|
|
name: skill.name.slice(0, 200),
|
|
description: skill.description.slice(0, 2_000),
|
|
source: skill.source.slice(0, 128),
|
|
provider: skill.provider.slice(0, 128)
|
|
}))
|
|
}
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
private attachmentStore(): GoodBuddyHarnessAttachmentStore {
|
|
const store = this.ctx.get('attachments')
|
|
if (!(store instanceof GoodBuddyHarnessAttachmentStore)) {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'Harness image attachment service is unavailable'
|
|
)
|
|
}
|
|
return store
|
|
}
|
|
|
|
private releaseAttachments(refs: readonly ImageAttachmentRef[]): void {
|
|
if (refs.length === 0) {
|
|
return
|
|
}
|
|
const store = this.attachmentStore()
|
|
for (const ref of refs) {
|
|
store.releaseImage(ref)
|
|
}
|
|
}
|
|
|
|
private async storePromptImages(
|
|
prompt: readonly AcpContentBlock[]
|
|
): Promise<ImageAttachmentRef[]> {
|
|
const imageBlocks = prompt.filter(
|
|
(
|
|
block
|
|
): block is Extract<AcpContentBlock, { type: 'image' }> =>
|
|
block.type === 'image'
|
|
)
|
|
if (imageBlocks.length === 0) {
|
|
return []
|
|
}
|
|
if (!this.config.supportsImageInput) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'the selected model does not accept image input'
|
|
)
|
|
}
|
|
const store = this.attachmentStore()
|
|
if (imageBlocks.length > store.imageLimits.maxImagesPerMessage) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'too many images in one prompt'
|
|
)
|
|
}
|
|
const inputs: SaveImageAttachment[] = []
|
|
let totalBytes = 0
|
|
for (const block of imageBlocks) {
|
|
if (
|
|
block.uri != null ||
|
|
!store.imageLimits.mediaTypes.includes(
|
|
block.mimeType as SaveImageAttachment['mediaType']
|
|
) ||
|
|
block.data.length === 0 ||
|
|
block.data.length % 4 !== 0 ||
|
|
block.data.length >
|
|
Math.ceil(store.imageLimits.maxImageBytes / 3) * 4 ||
|
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
|
block.data
|
|
)
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'invalid inline image prompt content'
|
|
)
|
|
}
|
|
const data = Buffer.from(block.data, 'base64')
|
|
totalBytes += data.byteLength
|
|
if (
|
|
data.byteLength === 0 ||
|
|
data.byteLength > store.imageLimits.maxImageBytes ||
|
|
totalBytes > store.imageLimits.maxMessageImageBytes ||
|
|
data.toString('base64') !== block.data
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'invalid inline image prompt content'
|
|
)
|
|
}
|
|
inputs.push({
|
|
data,
|
|
mediaType: block.mimeType as SaveImageAttachment['mediaType']
|
|
})
|
|
}
|
|
try {
|
|
return await store.saveImages(inputs)
|
|
} catch (error) {
|
|
if (error instanceof AttachmentError) {
|
|
if (isImageAdmissionError(error)) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'invalid inline image prompt content'
|
|
)
|
|
}
|
|
if (error.code === 'ATTACHMENT_WRITE_FAILED') {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'Harness image attachment service is unavailable'
|
|
)
|
|
}
|
|
}
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'Harness image attachment storage failed'
|
|
)
|
|
}
|
|
}
|
|
|
|
private createAgentApi(): Agent {
|
|
this.observeSessions()
|
|
return {
|
|
initialize: async () => ({
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
agentInfo: {
|
|
name: 'goodbuddy-deepseek-harness',
|
|
version: this.config.harnessVersion
|
|
},
|
|
agentCapabilities: {
|
|
promptCapabilities: {
|
|
image: this.config.supportsImageInput === true,
|
|
audio: false,
|
|
embeddedContext: false
|
|
},
|
|
sessionCapabilities: {
|
|
close: {}
|
|
}
|
|
},
|
|
authMethods: []
|
|
}),
|
|
authenticate: async () => undefined,
|
|
newSession: async (params) => {
|
|
if (this.closed) {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'bridge is shutting down'
|
|
)
|
|
}
|
|
if (
|
|
!isAbsolute(params.cwd) ||
|
|
params.mcpServers.length > 0
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'an absolute workspace and no MCP servers are required'
|
|
)
|
|
}
|
|
const sessionId = SessionId(randomUUID())
|
|
let genuineSkillDefinition: ToolDefinition | undefined
|
|
const handle = await this.ctx.agents.create({
|
|
sessionId,
|
|
meta: { cwd: params.cwd },
|
|
agentOptions: {
|
|
provider: this.config.provider,
|
|
model: this.config.model
|
|
},
|
|
setup: async (agentCtx) => {
|
|
agentCtx.systemPrompt.section({
|
|
name: 'goodbuddy:controlled-execution',
|
|
order: 50,
|
|
text: GOODBUDDY_EXECUTION_GUIDANCE
|
|
})
|
|
const skillRegistrations = agentCtx.inject(
|
|
['skills'],
|
|
(skillCtx) => {
|
|
for (const skill of this.config.skills) {
|
|
skillCtx.skills.register({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
content: skill.content,
|
|
source: 'bundled',
|
|
resourceBase: {
|
|
kind: 'directory',
|
|
path: skill.directory
|
|
},
|
|
invocation: {
|
|
modelInvocable: true,
|
|
userInvocable: true
|
|
}
|
|
})
|
|
}
|
|
}
|
|
)
|
|
await agentCtx.plugin(ToolSkill)
|
|
genuineSkillDefinition =
|
|
agentCtx.tools.get('skill')
|
|
await skillRegistrations
|
|
}
|
|
})
|
|
const askToolDefinitions = new Map(
|
|
this.config.trustedAskToolDefinitions ?? []
|
|
)
|
|
if (genuineSkillDefinition) {
|
|
askToolDefinitions.set(
|
|
'skill',
|
|
genuineSkillDefinition
|
|
)
|
|
}
|
|
this.sessions.set(sessionId, {
|
|
handle,
|
|
attachmentRefs: [],
|
|
proxyTools: new Map(),
|
|
askToolDefinitions
|
|
})
|
|
return {
|
|
sessionId,
|
|
modes: {
|
|
currentModeId: 'ask',
|
|
availableModes: [
|
|
{
|
|
id: 'ask',
|
|
name: 'Ask',
|
|
description: 'Read-only'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
prompt: async (params) => {
|
|
const record = this.requireSession(params.sessionId)
|
|
if (record.inflight) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'a prompt is already in flight'
|
|
)
|
|
}
|
|
const preparation = record.preparation
|
|
record.preparation = undefined
|
|
if (!preparation) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'a single-use goodbuddy/session/prepare is required'
|
|
)
|
|
}
|
|
await this.refreshProxyTools(params.sessionId, record)
|
|
const text = promptText(params.prompt)
|
|
if (!text.trim()) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'empty prompt'
|
|
)
|
|
}
|
|
const attachmentRefs = await this.storePromptImages(
|
|
params.prompt
|
|
)
|
|
let message: ReturnType<typeof createUserMessage>
|
|
try {
|
|
const content: HarnessContentBlock[] = [
|
|
{ type: 'text', text },
|
|
...attachmentRefs.map((attachment) => ({
|
|
type: 'image' as const,
|
|
attachment
|
|
}))
|
|
]
|
|
message = createUserMessage({
|
|
content,
|
|
source: { kind: 'user' }
|
|
})
|
|
} catch (error) {
|
|
this.releaseAttachments(attachmentRefs)
|
|
throw error
|
|
}
|
|
const stopReason = await new Promise<string>(
|
|
(resolve, reject) => {
|
|
record.inflight = {
|
|
requestId: preparation.requestId,
|
|
messageId: message.id,
|
|
mode: preparation.mode,
|
|
resolve,
|
|
reject,
|
|
eventTail: Promise.resolve()
|
|
}
|
|
try {
|
|
record.handle.agent.followup(message)
|
|
} catch (error) {
|
|
record.inflight = undefined
|
|
this.releaseAttachments(attachmentRefs)
|
|
reject(error)
|
|
return
|
|
}
|
|
record.attachmentRefs.push(...attachmentRefs)
|
|
void record.handle.agent.whenIdle().then(() => {
|
|
const current = record.inflight
|
|
if (current?.messageId !== message.id) {
|
|
return
|
|
}
|
|
this.flushPendingDelta(params.sessionId)
|
|
void current.eventTail.then(() => {
|
|
if (record.inflight === current) {
|
|
record.inflight = undefined
|
|
}
|
|
if (current.eventError) {
|
|
current.reject(current.eventError)
|
|
return
|
|
}
|
|
if (current.turnError) {
|
|
const details = errorChain(
|
|
current.turnError
|
|
).slice(0, MAX_SUMMARY_CHARACTERS)
|
|
current.reject(
|
|
RequestError.internalError(
|
|
{ details },
|
|
`DeepSeek Harness turn failed: ${details}`
|
|
)
|
|
)
|
|
return
|
|
}
|
|
current.resolve(current.endReason ?? 'cancelled')
|
|
})
|
|
}, (error) => {
|
|
if (record.inflight?.messageId === message.id) {
|
|
record.inflight = undefined
|
|
}
|
|
reject(error)
|
|
})
|
|
}
|
|
)
|
|
return {
|
|
stopReason:
|
|
stopReason === 'cancelled'
|
|
? 'cancelled'
|
|
: stopReason === 'max_tokens'
|
|
? 'max_tokens'
|
|
: 'end_turn'
|
|
}
|
|
},
|
|
cancel: async ({ sessionId }) => {
|
|
const record = this.sessions.get(sessionId)
|
|
record?.handle.agent.cancel({ kind: 'user' })
|
|
if (record?.inflight) {
|
|
const inflight = record.inflight
|
|
if (inflight.pendingDeltaTimer) {
|
|
clearTimeout(inflight.pendingDeltaTimer)
|
|
}
|
|
record.inflight = undefined
|
|
inflight.resolve('cancelled')
|
|
}
|
|
},
|
|
closeSession: async ({ sessionId }) => {
|
|
await this.releaseSession(sessionId)
|
|
},
|
|
extMethod: (method, params) =>
|
|
this.extensionMethod(method, params),
|
|
extNotification: async () => {
|
|
throw RequestError.methodNotFound('extension notification')
|
|
}
|
|
}
|
|
}
|
|
|
|
async extensionMethod(
|
|
method: string,
|
|
params: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
if (method === GOODBUDDY_HANDSHAKE) {
|
|
const version = params.controlProtocolVersion
|
|
if (version !== GOODBUDDY_CONTROL_PROTOCOL_VERSION) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'incompatible GoodBuddy Harness control protocol'
|
|
)
|
|
}
|
|
this.handshaken = true
|
|
return this.capabilities() as unknown as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
}
|
|
if (!this.handshaken) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'GoodBuddy handshake is required'
|
|
)
|
|
}
|
|
if (method === GOODBUDDY_PREPARE) {
|
|
const sessionId = requiredString(params, 'sessionId')
|
|
const requestId = requiredString(params, 'requestId')
|
|
const mode = params.mode
|
|
if (mode !== 'ask' && mode !== 'execute') {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'mode must be ask or execute'
|
|
)
|
|
}
|
|
const record = this.requireSession(sessionId)
|
|
if (record.inflight || record.preparation) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'session is already prepared or running'
|
|
)
|
|
}
|
|
record.preparation = { requestId, mode }
|
|
return { prepared: true }
|
|
}
|
|
if (method === GOODBUDDY_RELEASE) {
|
|
await this.releaseSession(
|
|
requiredString(params, 'sessionId')
|
|
)
|
|
return { released: true }
|
|
}
|
|
if (method === GOODBUDDY_NATIVE_SNAPSHOT) {
|
|
const workspace = params.workspace === undefined
|
|
? this.config.workspace
|
|
: requiredString(params, 'workspace')
|
|
if (!isAbsolute(workspace)) throw RequestError.invalidParams(undefined, 'workspace must be absolute')
|
|
return this.nativeSnapshot(workspace)
|
|
}
|
|
if (method === GOODBUDDY_SHUTDOWN) {
|
|
await this.dispose()
|
|
return { shutdown: true }
|
|
}
|
|
throw RequestError.methodNotFound(method)
|
|
}
|
|
|
|
async releaseSession(sessionId: string): Promise<void> {
|
|
const record = this.sessions.get(sessionId)
|
|
if (!record) {
|
|
return
|
|
}
|
|
this.sessions.delete(sessionId)
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
if (record.inflight) {
|
|
if (record.inflight.pendingDeltaTimer) {
|
|
clearTimeout(record.inflight.pendingDeltaTimer)
|
|
}
|
|
record.inflight.resolve('cancelled')
|
|
record.inflight = undefined
|
|
}
|
|
try {
|
|
await record.handle.dispose()
|
|
} finally {
|
|
this.releaseAttachments(record.attachmentRefs)
|
|
}
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.disposing ??= (async () => {
|
|
this.closed = true
|
|
const sessions = [...this.sessions.entries()]
|
|
this.sessions.clear()
|
|
for (const [, record] of sessions) {
|
|
record.handle.agent.cancel({ kind: 'disposed' })
|
|
if (record.inflight?.pendingDeltaTimer) {
|
|
clearTimeout(record.inflight.pendingDeltaTimer)
|
|
}
|
|
record.inflight?.resolve('cancelled')
|
|
}
|
|
await Promise.allSettled(
|
|
sessions.map(([, record]) => record.handle.dispose())
|
|
)
|
|
for (const [, record] of sessions) {
|
|
this.releaseAttachments(record.attachmentRefs)
|
|
}
|
|
})()
|
|
await this.disposing
|
|
}
|
|
}
|
|
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
|