Quellcode durchsuchen

Merge pull request #389 from deepseek-harness/worktree/dsbench-patch

feat(jsonrpc): add evaluation-ready coding-agent composition
Tianyi Cui vor 1 Monat
Ursprung
Commit
b736889ac7

+ 13 - 7
docs/config-catalog.md

@@ -64,7 +64,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']>
 }
 ```
@@ -140,12 +140,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. */
@@ -157,7 +159,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:57`](../packages/examples/agent-spine-demo/src/index.ts)
+Source: [`packages/examples/agent-spine-demo/src/index.ts:59`](../packages/examples/agent-spine-demo/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-local`
 
@@ -172,7 +174,9 @@ export interface Config {
   maxTimeoutMs?: number
   /** Per-stream in-memory output cap; overflow spills to a temp file. */
   maxOutputBytes?: number
-  /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
+  /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
+  maxSpillBytes?: number
+  /** Grace period for kill escalation and for inherited pipes after shell exit. */
   graceMs?: number
 }
 ```
@@ -384,8 +388,10 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../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`. */
@@ -856,7 +862,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 pre-created agent RESUMES this persisted session id instead of

+ 4 - 0
examples/README.md

@@ -33,6 +33,10 @@ The full-screen terminal sibling of `repl-agent`: it reuses the same coding back
 
 Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition.
 
+## jsonrpc-agent
+
+An unattended coding agent driven through the Python SDK: 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 [jsonrpc-agent/README.md](jsonrpc-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.

+ 24 - 0
examples/jsonrpc-agent/README.md

@@ -0,0 +1,24 @@
+# jsonrpc-agent
+
+The unattended coding-agent 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 turns are driven by the SDK.
+
+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 evaluation 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` | Agent workspace for bash and filesystem tools |
+| `DSH_SESSION_ROOT` | JSONL trajectory directory |
+| `DSH_SYSTEM_PROMPT` | Deployment-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.

+ 74 - 0
examples/jsonrpc-agent/cordis.yml

@@ -0,0 +1,74 @@
+# Unattended coding-agent 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
+
+- 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.'
+    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

+ 7 - 0
examples/jsonrpc-agent/package.json

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

+ 146 - 0
examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts

@@ -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 JSON-RPC agent 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('jsonrpc-agent keyless smoke', () => {
+  it('boots the real Cordis tree and serves initialize/shutdown over clean stdout', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-agent-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, provider: 'deepseek', 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)
+})

+ 3 - 0
examples/package.json

@@ -8,6 +8,7 @@
     "@cordisjs/plugin-hmr": "workspace:*",
     "@cordisjs/plugin-include": "workspace:*",
     "@deepseek-ai/dsh-acp-demo": "workspace:*",
+    "@deepseek-ai/dsh-agent-spine-demo": "workspace:*",
     "@deepseek-ai/dsh-bash-local": "workspace:*",
     "@deepseek-ai/dsh-bash-sandbox": "workspace:*",
     "@deepseek-ai/dsh-cli-demo": "workspace:*",
@@ -17,12 +18,14 @@
     "@deepseek-ai/dsh-fs-policy": "workspace:*",
     "@deepseek-ai/dsh-hooks-claude": "workspace:*",
     "@deepseek-ai/dsh-hooks-codex": "workspace:*",
+    "@deepseek-ai/dsh-jsonrpc": "workspace:*",
     "@deepseek-ai/dsh-llm": "workspace:*",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:*",
     "@deepseek-ai/dsh-llm-replay": "workspace:*",
     "@deepseek-ai/dsh-permission": "workspace:*",
     "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
     "@deepseek-ai/dsh-sandbox-local": "workspace:*",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
     "@deepseek-ai/dsh-spill-local": "workspace:*",
     "@deepseek-ai/dsh-spill-policy": "workspace:*",
     "@deepseek-ai/dsh-stdio-demo": "workspace:*",

+ 6 - 5
packages/bash/bash-local/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-bash-local
 
-Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
+Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
 
 The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
 
@@ -14,7 +14,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
     timeoutMs: 120000          # default foreground timeout
     maxTimeoutMs: 600000       # cap for per-call overrides
     maxOutputBytes: 64000      # per-stream in-memory cap; overflow spills to disk
-    graceMs: 3000              # SIGTERM→SIGKILL escalation grace on kills
+    maxSpillBytes: 67108864    # per-stream full-output spill cap
+    graceMs: 3000              # kill escalation and post-exit pipe-drain grace
 ```
 
 ## Behavior (and where it came from)
@@ -22,8 +23,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
 Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
 
 - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
-- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
-- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
+- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
+- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
 - **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
 - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
 
@@ -41,6 +42,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
 - **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
 - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
-- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them.
+- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
 
 The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.

+ 8 - 2
packages/bash/bash-local/src/index.ts

@@ -10,7 +10,7 @@ import z from 'schemastery'
 import { BashExecutor } from '@deepseek-ai/dsh-bash'
 import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
 import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
-import { DEFAULT_GRACE_MS, runBash } from './run.ts'
+import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
 import type { RunInternals, RunningBash } from './run.ts'
 
 /** Plugin config (all optional — `static Config` supplies the defaults). */
@@ -23,7 +23,9 @@ export interface Config {
   maxTimeoutMs?: number
   /** Per-stream in-memory output cap; overflow spills to a temp file. */
   maxOutputBytes?: number
-  /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
+  /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
+  maxSpillBytes?: number
+  /** Grace period for kill escalation and for inherited pipes after shell exit. */
   graceMs?: number
 }
 
@@ -46,6 +48,7 @@ export class LocalBashExecutor extends BashExecutor {
     timeoutMs: z.number().default(120_000),
     maxTimeoutMs: z.number().default(600_000),
     maxOutputBytes: z.number().default(64_000),
+    maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
     graceMs: z.number().default(DEFAULT_GRACE_MS),
   })
 
@@ -64,6 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
     assertPositiveFinite('timeoutMs', this.config.timeoutMs)
     assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
     assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
+    assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
     assertPositiveFinite('graceMs', this.config.graceMs)
     ctx.effect(() => async () => {
       // Await closure so even a TERM-trapping child cannot outlive the fiber.
@@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor {
       cwd: spec.workdir,
       stdoutMaxBytes: spec.stdoutMaxBytes,
       stderrMaxBytes: this.config.maxOutputBytes,
+      maxSpillBytes: this.config.maxSpillBytes,
       graceMs: this.config.graceMs,
       signal: d.signal,
       stdin: spec.stdin,
@@ -139,6 +144,7 @@ export class LocalBashExecutor extends BashExecutor {
       cwd: spec.workdir,
       stdoutMaxBytes: this.config.maxOutputBytes,
       stderrMaxBytes: this.config.maxOutputBytes,
+      maxSpillBytes: this.config.maxSpillBytes,
       graceMs: this.config.graceMs,
       signal: spec.signal,
       stdin: spec.stdin,

+ 61 - 14
packages/bash/bash-local/src/run.ts

@@ -8,7 +8,7 @@
 import { type ChildProcessByStdio, spawn } from 'node:child_process'
 import type { Readable, Writable } from 'node:stream'
 import { randomBytes } from 'node:crypto'
-import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
+import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
@@ -72,7 +72,9 @@ export interface SpawnSpec {
   stdoutMaxBytes: number
   /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
   stderrMaxBytes: number
-  /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
+  /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
+  maxSpillBytes: number
+  /** Grace period for kill escalation and for inherited pipes after shell exit. */
   graceMs: number
   /**
    * Abort signal — kills the process group when it fires. The executor owns
@@ -119,6 +121,9 @@ export interface RunInternals {
 /** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
 export const DEFAULT_GRACE_MS = 3_000
 
+/** Default per-stream spill cap (the `maxSpillBytes` config). */
+export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
+
 let spillCounter = 0
 let defaultSpillDir: string | undefined
 
@@ -133,9 +138,9 @@ function privateSpillDir(): string {
 }
 
 /**
- * Collects one stream with a bounded in-memory tail. The FULL stream is
- * always recoverable: on first overflow a spill file is created and every
- * chunk (including those already collected) is appended there.
+ * Collects one stream with a bounded in-memory tail. On first overflow a
+ * spill file is created and every chunk (including those already collected)
+ * is appended there while the full stream remains within `maxSpillBytes`.
  *
  * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
  * end of command output; the spill file covers the head.
@@ -146,11 +151,13 @@ export class OutputCollector {
   private dropped = false
   private spillFd: number | undefined
   private spillFile: string | undefined
+  private spillDisabled = false
   /** Total bytes ever pushed (not just retained). */
   private total = 0
 
   constructor(
     private readonly maxBytes: number,
+    private readonly maxSpillBytes: number,
     private readonly label: string,
     private readonly spillDir: string,
   ) {}
@@ -166,7 +173,7 @@ export class OutputCollector {
   push(chunk: Buffer): void {
     this.total += chunk.length
     const overflows = this.bytes + chunk.length > this.maxBytes
-    if (overflows || this.spillFd !== undefined) this.spillAll(chunk)
+    if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
     this.chunks.push(chunk)
     this.bytes += chunk.length
     while (this.bytes > this.maxBytes && this.chunks.length > 1) {
@@ -188,6 +195,10 @@ export class OutputCollector {
 
   /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
   private spillAll(chunk: Buffer): void {
+    if (this.total > this.maxSpillBytes) {
+      this.discardSpill()
+      return
+    }
     if (this.spillFd === undefined) {
       // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
       // existing path, symlink or not) + owner-only mode: defeats spill-path
@@ -202,6 +213,30 @@ export class OutputCollector {
     writeSync(this.spillFd, chunk)
   }
 
+  /** Stop spilling and remove the file once it can no longer hold the complete stream. */
+  private discardSpill(): void {
+    const fd = this.spillFd
+    const file = this.spillFile
+    this.spillFd = undefined
+    this.spillFile = undefined
+    this.spillDisabled = true
+    if (fd !== undefined) {
+      try {
+        closeSync(fd)
+      } catch {
+        // Retain the descriptor so finalize can retry the failed close.
+        this.spillFd = fd
+      }
+    }
+    if (file !== undefined) {
+      try {
+        unlinkSync(file)
+      } catch {
+        // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
+      }
+    }
+  }
+
   /**
    * Incremental read in whole-stream byte coordinates: returns everything
    * pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -301,8 +336,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
     ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
     : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
 
-  const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
-  const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
+  const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
+  const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
   child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
   child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
 
@@ -328,12 +363,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
   }
 
   const done = new Promise<SpawnOutcome>((resolve, reject) => {
-    child.on('error', (error) => {
-      // No meaningful close outcome follows a spawn failure.
-      cleanup()
-      reject(error)
-    })
-    child.on('close', (exitCode, signal) => {
+    let settled = false
+    let pipeDrainTimer: NodeJS.Timeout | undefined
+    const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
+      if (settled) return
+      settled = true
+      child.stdout.destroy()
+      child.stderr.destroy()
       cleanup()
       resolve({
         exitCode,
@@ -341,9 +377,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
         stdout: stdout.finalize(),
         stderr: stderr.finalize(),
       })
+    }
+    child.on('error', (error) => {
+      // No meaningful close outcome follows a spawn failure.
+      settled = true
+      cleanup()
+      reject(error)
+    })
+    child.on('exit', (exitCode, signal) => {
+      pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
     })
+    child.on('close', settle)
     function cleanup(): void {
       if (graceTimer !== undefined) clearTimeout(graceTimer)
+      if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
       spec.signal?.removeEventListener('abort', onAbort)
     }
   })

+ 1 - 0
packages/bash/bash-local/tests/executor.spec.ts

@@ -66,6 +66,7 @@ describe('LocalBashExecutor.run', () => {
     await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
     await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
     await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
+    await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
     await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
 
     const { bash } = await setup()

+ 72 - 5
packages/bash/bash-local/tests/run.spec.ts

@@ -1,4 +1,4 @@
-import { mkdtempSync, readFileSync, statSync } from 'node:fs'
+import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
@@ -6,7 +6,10 @@ import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
 import { killGroup, OutputCollector, runBash } from '../src/run.ts'
 import type { RunningBash } from '../src/run.ts'
 
-const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
+const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
+  failNextClose: { value: false },
+  failNextUnlink: { value: false },
+}))
 vi.mock('node:fs', async (importOriginal) => {
   const actual = await importOriginal<typeof import('node:fs')>()
   return {
@@ -18,6 +21,13 @@ vi.mock('node:fs', async (importOriginal) => {
       }
       actual.closeSync(fd)
     },
+    unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
+      if (failNextUnlink.value) {
+        failNextUnlink.value = false
+        throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
+      }
+      actual.unlinkSync(path)
+    },
   }
 })
 
@@ -29,6 +39,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
     cwd: process.cwd(),
     stdoutMaxBytes: 64_000,
     stderrMaxBytes: 64_000,
+    maxSpillBytes: 64 * 1024 * 1024,
     graceMs: 3_000,
     ...overrides,
   }
@@ -173,6 +184,22 @@ describe('runBash', () => {
     const result = await running.done
     expect(result.signal).toBe('SIGTERM')
   })
+
+  it('bounds inherited-pipe draining after the shell exits', async () => {
+    const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
+    const started = Date.now()
+    const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
+    const descendant = await waitForPidFile(pidFile)
+    try {
+      const result = await running.done
+      expect(Date.now() - started).toBeLessThan(1_000)
+      expect(result.exitCode).toBe(0)
+      expect(result.stdout.text).toBe('shell-done\n')
+    } finally {
+      process.kill(descendant, 'SIGKILL')
+      await waitGone(descendant)
+    }
+  })
 })
 
 describe('stdin and extra env (set by in-process plugins)', () => {
@@ -282,7 +309,7 @@ describe('output truncation and spill', () => {
 
 describe('OutputCollector', () => {
   it('keeps the tail of a single oversized chunk', () => {
-    const collector = new OutputCollector(10, 'test', spillDir)
+    const collector = new OutputCollector(10, 100, 'test', spillDir)
     collector.push(Buffer.from('0123456789abcdef'))
     const out = collector.finalize()
     expect(out.text).toBe('6789abcdef')
@@ -291,7 +318,7 @@ describe('OutputCollector', () => {
   })
 
   it('readFrom returns increments and flags lossy reads', () => {
-    const collector = new OutputCollector(10, 'test', spillDir)
+    const collector = new OutputCollector(10, 100, 'test', spillDir)
     collector.push(Buffer.from('aaaaa'))
     const first = collector.readFrom(0)
     expect(first.text).toBe('aaaaa')
@@ -312,7 +339,7 @@ describe('OutputCollector', () => {
   })
 
   it('contains close failures and drops the spill path', () => {
-    const collector = new OutputCollector(4, 'closefail', spillDir)
+    const collector = new OutputCollector(4, 100, 'closefail', spillDir)
     collector.push(Buffer.from('aaaa'))
     collector.push(Buffer.from('bbbb'))
     expect(collector.readFrom(0).spillPath).toBeDefined()
@@ -326,6 +353,46 @@ describe('OutputCollector', () => {
     expect(out!.truncated).toBe(true)
     expect(out!.spillPath).toBeUndefined()
   })
+
+  it('discards a spill that exceeds its configured cap', () => {
+    const collector = new OutputCollector(4, 8, 'bounded', spillDir)
+    collector.push(Buffer.from('aaaa'))
+    collector.push(Buffer.from('bbbb'))
+    const spillPath = collector.readFrom(0).spillPath!
+    expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
+
+    collector.push(Buffer.from('c'))
+    collector.push(Buffer.from('dddd'))
+    const out = collector.finalize()
+    expect(out.text).toBe('dddd')
+    expect(out.truncated).toBe(true)
+    expect(out.spillPath).toBeUndefined()
+    expect(() => readFileSync(spillPath)).toThrow()
+  })
+
+  it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
+    const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
+    collector.push(Buffer.from('abcdefgh'))
+    const out = collector.finalize()
+    expect(out.text).toBe('efgh')
+    expect(out.truncated).toBe(true)
+    expect(out.spillPath).toBeUndefined()
+  })
+
+  it('contains cleanup failures while disabling an oversize spill', () => {
+    const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
+    collector.push(Buffer.from('aaaa'))
+    collector.push(Buffer.from('bbbb'))
+    const spillPath = collector.readFrom(0).spillPath!
+
+    failNextClose.value = true
+    failNextUnlink.value = true
+    expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
+    expect(failNextClose.value).toBe(false)
+    expect(failNextUnlink.value).toBe(false)
+    expect(collector.finalize().spillPath).toBeUndefined()
+    unlinkSync(spillPath)
+  })
 })
 
 describe('killGroup', () => {

+ 2 - 2
packages/examples/acp-demo/src/index.ts

@@ -53,7 +53,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']>
 }
 
@@ -75,7 +75,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 */
 

+ 2 - 2
packages/examples/agent-spine-demo/README.md

@@ -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` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app 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. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `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` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app 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`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `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
 
@@ -62,5 +62,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 
 ## Known Limitations and Deferred Work
 
-- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle.
+- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
 - **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.

+ 13 - 7
packages/examples/agent-spine-demo/src/index.ts

@@ -31,6 +31,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. */
@@ -73,12 +75,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,
@@ -100,7 +103,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' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
 ]) as unknown as z<Config>
 
@@ -150,8 +153,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, Object.assign({}, config.skills?.local, { dshHome }))
+  const skillsEnabled = config.skills?.enabled ?? true
+  if (skillsEnabled) {
+    ctx.plugin(SkillService, config.skills?.registry ?? {})
+    ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
+  }
   ctx.plugin(AgentRegistry)
   ctx.plugin(TaskService)
   ctx.plugin(invariants)
@@ -161,8 +167,8 @@ 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 ?? [],
     ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},

+ 18 - 3
packages/examples/agent-spine-demo/tests/agent-core.spec.ts

@@ -344,6 +344,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',
@@ -352,9 +367,9 @@ describe('dsh-agent-spine-demo bundle', () => {
       tools: { mode: 'native' as const },
       dshHome: '/tmp/dsh-home',
       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({
@@ -363,7 +378,7 @@ describe('dsh-agent-spine-demo bundle', () => {
       tools: appConfig.tools,
       dshHome: appConfig.dshHome,
       workspaceContext: false,
-      skills: {},
+      skills: appConfig.skills,
       toolBash: appConfig.toolBash,
       toolTasks: appConfig.toolTasks,
     })

+ 1 - 1
packages/examples/cli-demo/src/index.ts

@@ -61,7 +61,7 @@ export const Config: z<Config> = z.object({
   toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   tools: ToolRegistry.Config,
   toolBash: agentCore.ToolBashConfigSchema,
-  toolTasks: agentCore.ToolTasksConfigSchema,
+  toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
   workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
 })
 /* jscpd:ignore-end */

+ 15 - 0
packages/examples/cli-demo/tests/cli-demo.spec.ts

@@ -146,6 +146,21 @@ describe('dsh-cli-demo app composition', () => {
     expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
   })
 
+  it('accepts false to keep task services without model-facing task controls', async () => {
+    const ctx = await mount({
+      provider: 'mock',
+      model: 'mock',
+      skills: { enabled: false },
+      toolTasks: false,
+      workspaceContext: false,
+    })
+
+    expect(ctx.get('tasks')).toBeDefined()
+    expect(ctx.get('tools')?.get('task_output')).toBeUndefined()
+    expect(ctx.get('tools')?.get('task_list')).toBeUndefined()
+    expect(ctx.get('tools')?.get('task_kill')).toBeUndefined()
+  })
+
   it('exposes the Loader-safe namespace plugin shape and schema', () => {
     expect(cliDemo.name).toBe('cli-demo')
     expect(cliDemo.Config).toBeDefined()

+ 2 - 2
packages/examples/stdio-demo/src/index.ts

@@ -97,7 +97,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 pre-created agent RESUMES this persisted session id instead of
@@ -125,7 +125,7 @@ export const Config: z<Config> = z.object({
   ui: UiConfigSchema,
   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(),
 })

+ 3 - 0
packages/llm/llm-deepseek/src/adapter.ts

@@ -91,6 +91,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 } : {},

+ 14 - 0
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -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, { CONTEXT_WINDOW_EXCEEDED_CODE, 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 } from '@deepseek-ai/dsh-llm-deepseek'
 import { httpErrorCode } from '../src/adapter.ts'
@@ -133,6 +134,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' })

+ 1 - 1
packages/ui/jsonrpc/README.md

@@ -8,7 +8,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
 
 ## Config
 
-There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport 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 runtime-only transport seams; production uses process stdio and `process.exit`.
 
 ## stdout is the protocol
 

+ 11 - 3
packages/ui/jsonrpc/src/index.ts

@@ -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
@@ -41,6 +45,8 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({})
  * owns root-context disposal for EOF and signals.
  */
 export function apply(ctx: Context, config: JsonRpcConfig): void {
+  // Cordis applies the schema default before invoking the plugin.
+  const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
   // The later transport callback must dispose this plugin's fiber, not its ambient context.
   const fiber = ctx.fiber
   /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
@@ -51,7 +57,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: resolvedConfig.maxTokensAsSuccess,
+  })
 
   // Share one exit task and attempt flush and disposal independently before exiting.
   let exitTask: Promise<void> | undefined

+ 20 - 3
packages/ui/jsonrpc/src/server.ts

@@ -57,7 +57,22 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
   return carrierKeyOf(carrier) as Agent
 }
 
-/** SDK server whose subscriptions and created agents live until {@link shutdown}. */
+/** 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'
@@ -72,7 +87,9 @@ export class HarnessSdkServer {
   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))
@@ -99,7 +116,7 @@ export class HarnessSdkServer {
         agentId: String(info.id),
         parentSessionId: String(parent.session.id),
         childSessionId: String(info.id),
-        status: info.stopReason === 'completed' ? 'ok' : 'error',
+        status: successStatus(info.stopReason, serverOptions),
         stopReason: info.stopReason,
         ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
       })
@@ -233,7 +250,7 @@ export class HarnessSdkServer {
 
   private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
     if (!reason) return 'error'
-    return reason.kind === 'completed' ? 'ok' : 'error'
+    return successStatus(reason.kind, this.options)
   }
 
   private hasAdapterFor(provider: string): boolean {

+ 20 - 2
packages/ui/jsonrpc/tests/server.spec.ts

@@ -656,7 +656,7 @@ describe('HarnessSdkServer', () => {
         signal: new AbortController().signal,
       })
       const transport = new FakeTransport()
-      const server = new HarnessSdkServer(ctx, transport)
+      const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
 
       missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
       await missedStartRun.result
@@ -692,7 +692,7 @@ describe('HarnessSdkServer', () => {
           agentId: 'fallback-child-session',
           parentSessionId: 'fallback-parent',
           childSessionId: 'fallback-child-session',
-          status: 'error',
+          status: 'ok',
           stopReason: 'max-tokens',
           lastAssistantMessage: [],
         },
@@ -782,6 +782,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 {

+ 9 - 0
pnpm-lock.yaml

@@ -101,6 +101,9 @@ importers:
       '@deepseek-ai/dsh-acp-demo':
         specifier: workspace:*
         version: link:../packages/examples/acp-demo
+      '@deepseek-ai/dsh-agent-spine-demo':
+        specifier: workspace:*
+        version: link:../packages/examples/agent-spine-demo
       '@deepseek-ai/dsh-bash-local':
         specifier: workspace:*
         version: link:../packages/bash/bash-local
@@ -128,6 +131,9 @@ importers:
       '@deepseek-ai/dsh-hooks-codex':
         specifier: workspace:*
         version: link:../packages/hooks/hooks-codex
+      '@deepseek-ai/dsh-jsonrpc':
+        specifier: workspace:*
+        version: link:../packages/ui/jsonrpc
       '@deepseek-ai/dsh-llm':
         specifier: workspace:*
         version: link:../packages/llm/llm
@@ -146,6 +152,9 @@ importers:
       '@deepseek-ai/dsh-sandbox-local':
         specifier: workspace:*
         version: link:../packages/sandbox/sandbox-local
+      '@deepseek-ai/dsh-session-persistence-jsonl':
+        specifier: workspace:*
+        version: link:../packages/session-persistence/session-persistence-jsonl
       '@deepseek-ai/dsh-spill-local':
         specifier: workspace:*
         version: link:../packages/spill/spill-local

+ 2 - 2
python/sdk/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-README.md: 80c9d1f50d26fc4f4670800fd7d7f5ea442ad891
-README.zh.md: ffedb5eb30f17388fe589863dbc654b22716b40c
+README.md: 5fd1bc7cd89152a28d3da17100fd62eed4f8cb14
+README.zh.md: 247a2ca5ea5c1c3afc19335a6bbcba356c823211

+ 1 - 1
python/sdk/README.md

@@ -27,7 +27,7 @@ from deepseek_harness import DeepSeekHarness
 with DeepSeekHarness(
     provider="deepseek",
     model="deepseek-v4-flash",
-    cordis="examples/dsbench-coding-agent/cordis.yml",
+    cordis="examples/jsonrpc-agent/cordis.yml",
 ) as harness:
     result = harness.run("Make the requested code change.")
 ```

+ 1 - 1
python/sdk/README.zh.md

@@ -23,7 +23,7 @@ from deepseek_harness import DeepSeekHarness
 with DeepSeekHarness(
     provider="deepseek",
     model="deepseek-v4-flash",
-    cordis="examples/dsbench-coding-agent/cordis.yml",
+    cordis="examples/jsonrpc-agent/cordis.yml",
 ) as harness:
     result = harness.run("Make the requested code change.")
 ```