feat(dsbench): add SDK evaluation composition

This commit is contained in:
Yichen Jiang
2026-07-17 17:29:38 +08:00
parent a6915745e0
commit a38ff125a7
17 changed files with 365 additions and 29 deletions

View File

@@ -56,7 +56,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
}
```
@@ -120,12 +120,14 @@ export interface Config {
skills?: SkillConfig
/** Model-facing bash tool config, including this producer's background opt-in. */
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
}
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
export interface SkillConfig {
/** Mount the bundled local skill provider and model-facing skill tool (default true). */
enabled?: boolean
/** Registry-level discovery cache settings. */
registry?: SkillRegistryConfig
/** Local filesystem skill provider settings. */
@@ -137,7 +139,7 @@ export interface SkillConfig {
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts)
Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts)
Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -343,8 +345,10 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-c
Requires: `agents`
```ts config-catalog
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
/** JSON-RPC deployment config plus runtime-only test seams. */
export interface JsonRpcConfig {
/** Report max-token turn/subagent termination as a successful SDK result. */
maxTokensAsSuccess?: boolean
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/** Transport output override; production uses `process.stdout`. */
@@ -724,7 +728,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/**
* If set, the `main` agent RESUMES this persisted session id instead of

View File

@@ -21,6 +21,10 @@ Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a
Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task.
## dsbench-coding-agent
The unattended SDK composition used by DSBench: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [dsbench-coding-agent/README.md](dsbench-coding-agent/README.md).
## cordis-agent
The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on.

View File

@@ -0,0 +1,24 @@
# dsbench-coding-agent
The DSBench deployment composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval surface, or user-interaction tool because stdout belongs to the SDK protocol and benchmark turns are unattended.
The model-facing tools are:
- `bash`, foreground only
- `read`, `write`, and `edit`
- `subagent`, using one foreground in-process spawn provider
- `todo_write`
The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted benchmark result while preserving its `max-tokens` reason.
## Runtime environment
| Variable | Purpose |
|---|---|
| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint |
| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` |
| `DSH_CWD` | Benchmark workspace for bash and filesystem tools |
| `DSH_SESSION_ROOT` | JSONL trajectory directory |
| `DSH_SYSTEM_PROMPT` | DSBench-provided coding persona |
Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js.

View File

@@ -0,0 +1,77 @@
# DSBench deployment for the bundled dsh-jsonrpc-agent runtime.
# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI.
- id: jsonrpc
name: '@deepseek-ai/dsh-jsonrpc'
config:
maxTokensAsSuccess: true
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- deepseek-v4-flash
- deepseek-v4-pro
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
timeoutMs: 60000
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent driven by DSBench.'
workspaceContext: false
skills:
enabled: false
toolBash:
enableRunInBackground: false
toolTasks: false
- id: sessions
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
enableRunInBackground: false
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
config:
contextWindow: 128000
thresholdRatio: 0.8
retainTokens: 20480
summarizationModel: ''
maxTokens: 8192
compactionRetries: 1

View File

@@ -0,0 +1,7 @@
{
"name": "dsbench-coding-agent-example",
"private": true,
"version": "0.0.1",
"type": "module",
"description": "DSBench JSON-RPC coding-agent composition"
}

View File

@@ -0,0 +1,146 @@
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
function waitForLine(
lines: string[],
predicate: (value: Record<string, unknown>) => boolean,
stderr: () => string,
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + 30_000
const poll = (): void => {
while (lines.length > 0) {
const line = lines.shift()!
if (!line.trim()) continue
try {
const value = JSON.parse(line) as Record<string, unknown>
if (predicate(value)) {
resolve(value)
return
}
} catch {
reject(new Error(`non-JSON stdout from DSBench runtime: ${line}`))
return
}
}
if (Date.now() >= deadline) {
reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`))
return
}
setTimeout(poll, 10)
}
poll()
})
}
describe('dsbench-coding-agent keyless smoke', () => {
it('boots the real Cordis tree and serves initialize/shutdown over clean stdout', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dsbench-smoke-'))
const modelRequests: Record<string, unknown>[] = []
const modelServer = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
modelRequests.push(JSON.parse(body) as Record<string, unknown>)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
response.end('data: [DONE]\n\n')
})
})
await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
const address = modelServer.address()
if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
const child = spawn(process.execPath, [
'--expose-internals',
'--import',
'tsx',
binScript,
configPath,
], {
cwd: repoRoot,
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_CWD: root,
DSH_SESSION_ROOT: join(root, '.sessions'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
const lines: string[] = []
let stdoutBuffer = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdoutBuffer += chunk
const parts = stdoutBuffer.split('\n')
stdoutBuffer = parts.pop() ?? ''
lines.push(...parts)
})
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
try {
child.stdin.write(`${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { cwd: root, model: 'deepseek-v4-pro' },
})}\n`)
const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
expect(initialized).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
})
child.stdin.write(`${JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'session/prompt',
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
})}\n`)
const prompt = await waitForLine(lines, value => value.id === 2, () => stderr)
expect(prompt).toMatchObject({ jsonrpc: '2.0', id: 2, result: { accepted: true } })
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
expect(tools.map(tool => tool.function?.name).sort()).toEqual([
'bash',
'edit',
'read',
'subagent',
'todo_write',
'write',
])
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
if (child.exitCode === null) {
await new Promise<void>((resolve, reject) => {
child.once('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`runtime exited ${code}; stderr=${stderr}`))
})
})
} else {
expect(child.exitCode, stderr).toBe(0)
}
} finally {
if (child.exitCode === null) child.kill('SIGKILL')
await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
await rm(root, { recursive: true, force: true })
}
}, 40_000)
})

View File

@@ -46,7 +46,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
}
@@ -67,7 +67,7 @@ export const Config: z<Config> = z.object({
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
})
/* jscpd:ignore-end */

View File

@@ -46,7 +46,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include

View File

@@ -30,6 +30,8 @@ export const name = 'agent-spine-demo'
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
export interface SkillConfig {
/** Mount the bundled local skill provider and model-facing skill tool (default true). */
enabled?: boolean
/** Registry-level discovery cache settings. */
registry?: SkillRegistryConfig
/** Local filesystem skill provider settings. */
@@ -67,12 +69,13 @@ export interface Config {
skills?: SkillConfig
/** Model-facing bash tool config, including this producer's background opt-in. */
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
}
/** The skill config schema exported for app packages that forward `skills`. */
export const SkillConfigSchema: z<SkillConfig> = z.object({
enabled: z.boolean().default(true),
registry: SkillService.Config,
local: SkillLocal.Config,
tool: toolSkill.Config,
@@ -93,7 +96,7 @@ export const Config = z.intersect([
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
]) as unknown as z<Config>
@@ -134,8 +137,11 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(ToolRegistry, config.tools ?? {})
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, config.skills?.local ?? {})
const skillsEnabled = config.skills?.enabled ?? true
if (skillsEnabled) {
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, config.skills?.local ?? {})
}
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
@@ -145,7 +151,7 @@ export function apply(ctx: Context, config: Config): void {
}
// Both plugins prepend session-prefix messages. Registration order is the
// rendered order, so workspace instructions must precede the skill catalog.
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(toolTasks, config.toolTasks ?? {})
if (skillsEnabled) ctx.plugin(toolSkill, config.skills?.tool ?? {})
if (config.toolTasks !== false) ctx.plugin(toolTasks, config.toolTasks ?? {})
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
}

View File

@@ -301,6 +301,21 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('can omit skills and model-facing task controls for a foreground-only deployment', async () => {
const ctx = await mount({
workspaceContext: false,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: false,
}, true)
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['bash'])
expect(ctx.get('skills')).toBeUndefined()
expect(ctx.get('tasks')).toBeDefined()
await ctx.fiber.dispose()
})
it('picks shared spine config without leaking front-door fields', () => {
const appConfig = {
model: 'front-door-only',
@@ -308,9 +323,9 @@ describe('dsh-agent-spine-demo bundle', () => {
toolOrder: ['zulu'],
tools: { mode: 'native' as const },
workspaceContext: false as const,
skills: {},
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
toolTasks: false as const,
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
@@ -318,7 +333,7 @@ describe('dsh-agent-spine-demo bundle', () => {
toolOrder: appConfig.toolOrder,
tools: appConfig.tools,
workspaceContext: false,
skills: {},
skills: appConfig.skills,
toolBash: appConfig.toolBash,
toolTasks: appConfig.toolTasks,
})

View File

@@ -51,7 +51,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/**
* If set, the `main` agent RESUMES this persisted session id instead of
@@ -77,7 +77,7 @@ export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})

View File

@@ -61,6 +61,9 @@ export class DeepSeekAdapter extends LlmAdapter {
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
},
body: JSON.stringify(body),
...options.signal ? { signal: options.signal } : {},

View File

@@ -3,6 +3,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
@@ -131,6 +132,19 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
})
it('forwards the harness session id for host-side trajectory routing', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
sessionId: SessionId('child-session'),
})
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
})
it('forwards thinking config onto the wire', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })

View File

@@ -8,7 +8,7 @@ Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_ha
## Config
No `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`.
`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`.
## stdout is the protocol

View File

@@ -22,8 +22,10 @@ export const name = 'jsonrpc'
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
/** JSON-RPC deployment config plus runtime-only test seams. */
export interface JsonRpcConfig {
/** Report max-token turn/subagent termination as a successful SDK result. */
maxTokensAsSuccess?: boolean
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/** Transport output override; production uses `process.stdout`. */
@@ -32,7 +34,9 @@ export interface JsonRpcConfig {
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({})
export const Config: Schema<JsonRpcConfig> = Schema.object({
maxTokensAsSuccess: Schema.boolean().default(false),
})
/**
* Serve SDK requests over the configured streams. Effect disposal shuts down
@@ -51,7 +55,9 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport)
const server = new HarnessSdkServer(ctx, transport, {
maxTokensAsSuccess: config.maxTokensAsSuccess ?? false,
})
// Share one exit task and attempt flush and disposal independently before exiting.
let exitTask: Promise<void> | undefined

View File

@@ -59,6 +59,12 @@ interface SubagentRecord {
parentSessionId: string | undefined
}
/** 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
}
/**
* SDK server over one booted harness context and transport peer. Construction
* subscribes to session, agent, and subagent lifecycle events until shutdown;
@@ -78,6 +84,7 @@ export class HarnessSdkServer {
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
private readonly options: HarnessSdkServerOptions = {},
) {
this.disposers.push(ctx.on('session/event', (session, event) => {
if (event.type === 'turn/end') {
@@ -116,7 +123,7 @@ export class HarnessSdkServer {
agentId: String(info.id),
...(parentSessionId === undefined ? {} : { parentSessionId }),
childSessionId,
status: info.stopReason === 'completed' ? 'ok' : 'error',
status: this.successStatus(info.stopReason),
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
})
@@ -253,7 +260,12 @@ export class HarnessSdkServer {
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return reason.kind === 'completed' ? 'ok' : 'error'
return this.successStatus(reason.kind)
}
private successStatus(reason: string): 'ok' | 'error' {
if (reason === 'completed') return 'ok'
return reason === 'max-tokens' && this.options.maxTokensAsSuccess === true ? 'ok' : 'error'
}
private hasAdapterFor(model: string): boolean {

View File

@@ -329,7 +329,7 @@ describe('HarnessSdkServer', () => {
agentOptions: { model: 'deepseek' },
})
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
@@ -355,7 +355,7 @@ describe('HarnessSdkServer', () => {
agentId: 'fallback-child-agent',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'error',
status: 'ok',
stopReason: 'max-tokens',
lastAssistantMessage: [],
},
@@ -443,6 +443,24 @@ describe('HarnessSdkServer', () => {
}
})
it('can report max-token turn termination as an accepted evaluation result', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as {
finishedStatus(reason: unknown): 'ok' | 'error'
shutdown(): Promise<Record<string, never>>
}
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok')
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('reports no adapter when the LLM service is absent', async () => {
const ctx = new Context()
try {