diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ad8ffceede..f595e6574b 100644 --- a/docs/config-catalog.md +++ b/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 - /** 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 } ``` @@ -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 - /** 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 /** * If set, the pre-created agent RESUMES this persisted session id instead of diff --git a/examples/README.md b/examples/README.md index abeee1d97e..c06bb791df 100644 --- a/examples/README.md +++ b/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. diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md new file mode 100644 index 0000000000..cfbf6787b1 --- /dev/null +++ b/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. diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml new file mode 100644 index 0000000000..de19b3da94 --- /dev/null +++ b/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 diff --git a/examples/jsonrpc-agent/package.json b/examples/jsonrpc-agent/package.json new file mode 100644 index 0000000000..080b0649a6 --- /dev/null +++ b/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" +} diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts new file mode 100644 index 0000000000..41afb03574 --- /dev/null +++ b/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) => boolean, + stderr: () => string, +): Promise> { + 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 + 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[] = [] + 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) + 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(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((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(resolve => modelServer.close(() => { resolve() })) + await rm(root, { recursive: true, force: true }) + } + }, 40_000) +}) diff --git a/examples/package.json b/examples/package.json index 76349b2ea4..3a5f90d30e 100644 --- a/examples/package.json +++ b/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:*", diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index fcd1317b75..c63f86f797 100644 --- a/packages/bash/bash-local/README.md +++ b/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 ` 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 ` 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. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 587c33b9a2..6428cd7ac8 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/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, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index fa4dae73d6..600e920c96 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/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((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) } }) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 9db0c2eebd..2e24addb3b 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/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() diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index e65500a4b5..91afd1aede 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/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() return { @@ -18,6 +21,13 @@ vi.mock('node:fs', async (importOriginal) => { } actual.closeSync(fd) }, + unlinkSync(path: Parameters[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[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', () => { diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 6785e5f957..d9baa96394 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/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 - /** 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 } @@ -75,7 +75,7 @@ export const Config: z = 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 */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index f7a0f28cea..76efd77845 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/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. diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a4965ca457..74ba5e0cc9 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/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 = 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>, ]) as unknown as z @@ -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 } : {}, diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5d5b336ff9..bc12706e93 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/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, }) diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index 1308209681..e5c77af9ed 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -61,7 +61,7 @@ export const Config: z = 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 */ diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 343d6184d7..2111ff6aa8 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/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() diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 531c4a3d5e..0bf66ab007 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/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 - /** 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 /** * If set, the pre-created agent RESUMES this persisted session id instead of @@ -125,7 +125,7 @@ export const Config: z = 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(), }) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 4f7cc66f87..918c8eee82 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/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 } : {}, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5d15a1e437..954a0ecebd 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/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' }) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 92a8d290bf..856c933cbc 100644 --- a/packages/ui/jsonrpc/README.md +++ b/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 diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 29fb1aeff8..099ef6008f 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/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 = Schema.object({}) +export const Config: Schema = 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 = 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 | undefined diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 8949ccb06a..f3a164340c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -57,7 +57,22 @@ function subagentParentOf(carrier: Scoped): 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 { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 5b9259dd85..034577f105 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/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> + } + + 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42f5763fdf..dba3435f60 100644 --- a/pnpm-lock.yaml +++ b/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 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index bb012a90db..181df4986e 100644 --- a/python/sdk/README.i18n.yaml +++ b/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 diff --git a/python/sdk/README.md b/python/sdk/README.md index 80c9d1f50d..5fd1bc7cd8 100644 --- a/python/sdk/README.md +++ b/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.") ``` diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index ffedb5eb30..247a2ca5ea 100644 --- a/python/sdk/README.zh.md +++ b/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.") ```