jsonrpc: harden Python SDK lifecycle and protocol

This commit is contained in:
Yichen Jiang
2026-07-13 16:34:03 +08:00
parent 7696f88d9f
commit 5d913a796a
7 changed files with 198 additions and 66 deletions

View File

@@ -20,4 +20,4 @@ The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). The `initialize` params `sessionRoot`, `systemPrompt`, and `clientInfo`, and the `session/prompt` param `profile`, are accepted for wire compatibility but currently unused — persistence roots and the deployment persona come from the `cordis.yml` (see the TODO in [`src/server.ts`](src/server.ts)).
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies.

View File

@@ -13,6 +13,7 @@
*/
import type { Context } from 'cordis'
import { resolve } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
@@ -27,17 +28,6 @@ export interface InitializeParams {
cwd: string
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
model: string
/** Accepted for SDK wire compatibility; unused — persistence roots come from the `cordis.yml`. */
sessionRoot?: string
/**
* Accepted for SDK wire compatibility; currently NOT applied — the deployment
* persona comes from the `cordis.yml` system-prompt config. TODO(jsonrpc):
* map this onto a per-runtime system-prompt section once a per-agent override
* seam exists.
*/
systemPrompt?: string
/** Accepted for SDK wire compatibility; unused diagnostic client identity. */
clientInfo?: { name?: string; version?: string }
}
/** Result of the `initialize` request: the server's identity for the SDK handshake. */
@@ -52,8 +42,6 @@ export interface SessionPromptParams {
sessionId: string
/** The prompt content blocks, sent verbatim as the user message. */
contentBlocks: ContentBlock[]
/** Accepted for SDK wire compatibility; unused — profiles are not a harness concept. */
profile?: string
}
/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */
@@ -132,7 +120,7 @@ export class HarnessSdkServer {
agentId: String(info.id),
...(parentSessionId === undefined ? {} : { parentSessionId }),
childSessionId,
status: info.stopReason === 'completed' || info.stopReason === 'max-tokens' ? 'ok' : 'error',
status: info.stopReason === 'completed' ? 'ok' : 'error',
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
})
@@ -148,7 +136,7 @@ export class HarnessSdkServer {
* @returns the server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
this.cwd = params.cwd
this.cwd = resolve(params.cwd)
this.model = params.model
if (!this.llmFiber && !this.hasAdapterFor(this.model)) {
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] })
@@ -193,12 +181,28 @@ export class HarnessSdkServer {
this.shuttingDown = true
const pendingCreations = [...this.sessionCreations.values()]
await Promise.allSettled(pendingCreations)
this.sessionCreations.clear()
const records = [...this.sessions.values()]
this.sessions.clear()
await Promise.all(records.map(rec => rec.handle.dispose()))
await this.llmFiber?.dispose()
this.subagentSessions.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
while (this.disposers.length > 0) this.disposers.pop()?.()
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 {}
}
@@ -251,7 +255,7 @@ export class HarnessSdkServer {
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return reason.kind === 'completed' || reason.kind === 'max-tokens' ? 'ok' : 'error'
return reason.kind === 'completed' ? 'ok' : 'error'
}
private hasAdapterFor(model: string): boolean {

View File

@@ -108,15 +108,12 @@ describe('HarnessSdkServer', () => {
const init = await server.handleRequest('initialize', {
cwd: storageDir,
model: 'dsagent-model',
sessionRoot: storageDir,
systemPrompt: 'Custom SDK instructions.',
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'fix it' }],
profile: 'build',
})
expect(llmServer.requests).toHaveLength(1)
@@ -307,7 +304,7 @@ describe('HarnessSdkServer', () => {
agentId: 'fallback-child-agent',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'ok',
status: 'error',
stopReason: 'max-tokens',
lastAssistantMessage: [],
},
@@ -386,7 +383,7 @@ describe('HarnessSdkServer', () => {
}
expect(server.finishedStatus(undefined)).toBe('error')
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok')
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error')
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
await server.shutdown()
} finally {
@@ -462,4 +459,63 @@ describe('HarnessSdkServer', () => {
expect(retryHandle.dispose).toHaveBeenCalledOnce()
await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down')
})
it('resolves a relative cwd before creating the session', async () => {
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
.mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => ({ models: () => ['model'] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; model: string }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>>
}
await server.initialize({ cwd: '.', model: 'model' })
await server.getOrCreateSession('relative')
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
await server.shutdown()
})
it('settles every teardown and aggregates multiple failures', async () => {
const firstDispose = vi.fn(() => { throw new Error('first teardown failed') })
const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed')))
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create: vi.fn(), get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined }>
shutdown(): Promise<Record<string, never>>
}
server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined })
server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined })
await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed')
expect(firstDispose).toHaveBeenCalledOnce()
expect(secondDispose).toHaveBeenCalledOnce()
})
it('continues teardown after a subscription disposer fails', async () => {
let subscription = 0
const listenerFailure = new Error('listener teardown failed')
const on = vi.fn(() => {
subscription += 1
return subscription === 1 ? () => { throw listenerFailure } : () => undefined
})
const ctx = {
on,
agents: { create: vi.fn(), get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.shutdown()).rejects.toBe(listenerFailure)
expect(on).toHaveBeenCalledTimes(4)
})
})