mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The native adapter's route was named deepseek, colliding with pi-ai's catalog provider of the same name, so the two DeepSeek paths could never be mounted side by side. The web settings page needs both configurable at once. Compositions, fixtures, goldens, scaffolding defaults, and docs all move together (pre-release, no shim); TUI/session-query-spill/ missing-credential goldens re-recorded through their keyless refresh modes because provider-name length shifts box padding and spill truncation points.
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
/**
|
|
* JSON-RPC method and notification surface for out-of-process harness SDKs.
|
|
* The surrounding context owns plugins, persistence, and configured adapters.
|
|
*
|
|
* @module @deepseek-ai/dsh-jsonrpc/server
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import { resolve } from 'node:path'
|
|
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
|
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
|
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
|
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
|
import type {
|
|
InitializeParams,
|
|
InitializeResult,
|
|
JsonRpcTransportPeer,
|
|
SessionEventNotification,
|
|
SessionFinishedNotification,
|
|
SessionPromptParams,
|
|
SessionPromptResult,
|
|
SubagentFinishedNotification,
|
|
SubagentStartedNotification,
|
|
} from '@deepseek-ai/dsh-sdk-protocol'
|
|
|
|
interface SessionRecord {
|
|
handle: AgentHandle
|
|
lastTurnEnd: TurnEndReason | undefined
|
|
activePrompt: boolean
|
|
}
|
|
|
|
/** Recover the delegating parent from the service-owned scoped carrier. */
|
|
function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
|
|
return carrierKeyOf(carrier) as Agent
|
|
}
|
|
|
|
/** Deployment-specific status mapping for SDK turn and subagent outcomes. */
|
|
export interface HarnessSdkServerOptions {
|
|
/** Report max-token termination as an accepted result instead of an infrastructure error. */
|
|
maxTokensAsSuccess?: boolean
|
|
}
|
|
|
|
function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' {
|
|
if (reason === 'completed') return 'ok'
|
|
return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error'
|
|
}
|
|
|
|
/**
|
|
* SDK server over one booted harness context and transport peer. Construction
|
|
* subscribes to session, agent, and subagent lifecycle events until shutdown;
|
|
* reinitialization is unsupported.
|
|
*/
|
|
export class HarnessSdkServer {
|
|
private cwd = process.cwd()
|
|
private provider = 'deepseek-official'
|
|
private model = 'deepseek-official'
|
|
private maxTokens: number | undefined
|
|
private llmFiber: { dispose(): Promise<void> } | undefined
|
|
private readonly sessions = new Map<string, SessionRecord>()
|
|
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
|
|
private readonly disposers: (() => void)[] = []
|
|
private shutdownTask: Promise<Record<string, never>> | undefined
|
|
private shuttingDown = false
|
|
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
private readonly transport: JsonRpcTransportPeer,
|
|
private readonly options: HarnessSdkServerOptions = {},
|
|
) {
|
|
const serverOptions = this.options
|
|
this.disposers.push(ctx.on('session/event', (session, event) => {
|
|
if (event.type === 'turn/end') {
|
|
const rec = this.sessions.get(String(session.id))
|
|
if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) {
|
|
rec.lastTurnEnd = event.data.reason
|
|
}
|
|
}
|
|
const payload: SessionEventNotification = { sessionId: String(session.id), event }
|
|
this.transport.notify('session.event', payload)
|
|
}))
|
|
this.disposers.push(ctx.on('session/created', (session) => {
|
|
const parentSession = session.header.parentSession
|
|
if (parentSession === undefined) return
|
|
const payload: SubagentStartedNotification = {
|
|
parentSessionId: String(parentSession),
|
|
childSessionId: String(session.id),
|
|
}
|
|
this.transport.notify('subagent.started', payload)
|
|
}))
|
|
this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
|
|
const parent = subagentParentOf(this)
|
|
// This protocol reports only in-process child sessions. The service
|
|
// snapshots the provider's exact run provenance through child disposal;
|
|
// matching ids or parent lineage alone never establishes locality.
|
|
if (!info.local) return
|
|
const payload: SubagentFinishedNotification = {
|
|
provider: info.provider,
|
|
agentId: String(info.id),
|
|
parentSessionId: String(parent.session.id),
|
|
childSessionId: String(info.id),
|
|
status: successStatus(info.stopReason, serverOptions),
|
|
stopReason: info.stopReason,
|
|
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
|
|
}
|
|
transport.notify('subagent.finished', payload)
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Configure the SDK route, mounting the DeepSeek fallback only when unowned.
|
|
* @param params - SDK handshake parameters.
|
|
* @returns server identity for the handshake.
|
|
*/
|
|
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
|
if (params.maxTokens !== undefined
|
|
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
|
|
throw new TypeError('initialize maxTokens must be a positive safe integer')
|
|
}
|
|
this.cwd = resolve(params.cwd)
|
|
this.provider = params.provider
|
|
this.model = params.model
|
|
this.maxTokens = params.maxTokens
|
|
if (!this.hasAdapterFor(this.provider)) {
|
|
if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`)
|
|
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
|
|
}
|
|
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
|
|
}
|
|
|
|
/**
|
|
* Run one prompt to settlement; overlap on the same session fails.
|
|
* @param params - target session and user content.
|
|
* @returns acceptance after the turn settled.
|
|
*/
|
|
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
|
|
const rec = await this.getOrCreateSession(params.sessionId)
|
|
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
|
|
// An agent-loop-only reload disposes the loop's agents while this record
|
|
// survives; a retained agent accepts followup() silently, so validate the
|
|
// record against the live registry before delivery (as the ACP bridge does).
|
|
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
|
|
throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
|
|
}
|
|
rec.activePrompt = true
|
|
try {
|
|
rec.lastTurnEnd = undefined
|
|
rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }))
|
|
await rec.handle.agent.whenIdle()
|
|
const payload: SessionFinishedNotification = {
|
|
sessionId: params.sessionId,
|
|
status: this.finishedStatus(rec.lastTurnEnd),
|
|
reason: rec.lastTurnEnd,
|
|
}
|
|
this.transport.notify('session.finished', payload)
|
|
return { accepted: true }
|
|
} finally {
|
|
rec.activePrompt = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dispose server-owned agents, adapter, and subscriptions to quiescence.
|
|
* The surrounding context remains running.
|
|
* @returns empty JSON-RPC result.
|
|
*/
|
|
shutdown(): Promise<Record<string, never>> {
|
|
this.shutdownTask ??= this.performShutdown()
|
|
return this.shutdownTask
|
|
}
|
|
|
|
private async performShutdown(): Promise<Record<string, never>> {
|
|
this.shuttingDown = true
|
|
const pendingCreations = [...this.sessionCreations.values()]
|
|
await Promise.allSettled(pendingCreations)
|
|
this.sessionCreations.clear()
|
|
const records = [...this.sessions.values()]
|
|
this.sessions.clear()
|
|
const failures: unknown[] = []
|
|
while (this.disposers.length > 0) {
|
|
try {
|
|
this.disposers.pop()?.()
|
|
} catch (error) {
|
|
failures.push(error)
|
|
}
|
|
}
|
|
const teardownResults = await Promise.allSettled([
|
|
...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())),
|
|
...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]),
|
|
])
|
|
this.llmFiber = undefined
|
|
failures.push(...teardownResults
|
|
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
|
.map(result => result.reason as unknown))
|
|
if (failures.length === 1) throw failures[0]
|
|
if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed')
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
|
|
* JSON-RPC error response) on an unknown method.
|
|
* @param method - the JSON-RPC method name.
|
|
* @param params - the raw params object from the wire.
|
|
* @returns the handler's result, to be serialized as the response.
|
|
*/
|
|
async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
|
|
switch (method) {
|
|
case 'initialize':
|
|
return this.initialize(params as unknown as InitializeParams)
|
|
case 'session/prompt':
|
|
return this.prompt(params as unknown as SessionPromptParams)
|
|
case 'shutdown':
|
|
return this.shutdown()
|
|
default:
|
|
throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
|
|
}
|
|
}
|
|
|
|
private async getOrCreateSession(sessionId: string): Promise<SessionRecord> {
|
|
if (this.shuttingDown) throw new Error('SDK server is shutting down')
|
|
const existing = this.sessions.get(sessionId)
|
|
if (existing) return existing
|
|
const pending = this.sessionCreations.get(sessionId)
|
|
if (pending) return pending
|
|
const creation = this.createSession(sessionId)
|
|
this.sessionCreations.set(sessionId, creation)
|
|
void creation.then(
|
|
() => { this.sessionCreations.delete(sessionId) },
|
|
() => { this.sessionCreations.delete(sessionId) },
|
|
)
|
|
return creation
|
|
}
|
|
|
|
private async createSession(sessionId: string): Promise<SessionRecord> {
|
|
const handle = await this.ctx.agents.create({
|
|
sessionId: SessionId(sessionId),
|
|
meta: { cwd: this.cwd },
|
|
agentOptions: {
|
|
provider: this.provider,
|
|
model: this.model,
|
|
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
|
},
|
|
})
|
|
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
|
|
this.sessions.set(sessionId, rec)
|
|
return rec
|
|
}
|
|
|
|
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
|
|
if (!reason) return 'error'
|
|
return successStatus(reason.kind, this.options)
|
|
}
|
|
|
|
private hasAdapterFor(provider: string): boolean {
|
|
return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
|
|
}
|
|
}
|