mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
190 lines
8.3 KiB
TypeScript
190 lines
8.3 KiB
TypeScript
/**
|
|
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
|
|
* tools, so it shares no Cordis context and advertises no parent-enforced start capabilities;
|
|
* the ONE thing it reads off `request.parent` is the session's workspace cwd (see
|
|
* {@link resolveCwd}). This plugin uses named exports only; a default would hide its
|
|
* loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
|
|
* @module @deepseek-ai/dsh-subagent-acp
|
|
*/
|
|
|
|
import { accessSync, constants, statSync } from 'node:fs'
|
|
import { isAbsolute, resolve } from 'node:path'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import type {
|
|
ResolvedSubagentStartRequest,
|
|
SubagentCapabilities,
|
|
SubagentProvider,
|
|
SubagentStartRequest,
|
|
} from '@deepseek-ai/dsh-subagent'
|
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
|
|
|
|
export const name = 'subagent-acp'
|
|
export const inject = ['subagents', 'subprocess']
|
|
|
|
/** Config: how to spawn and drive the child ACP agent process. */
|
|
export interface Config {
|
|
/** Provider name on `ctx.subagents` (default `acp`). */
|
|
providerName: string
|
|
/** The executable to spawn for each run (the child ACP agent). */
|
|
command: string
|
|
/** Arguments passed to {@link command}. */
|
|
args: string[]
|
|
/**
|
|
* Working directory override for the child process and its ACP session.
|
|
* Must be non-empty; a relative path resolves against the harness launch
|
|
* directory at load, and the result must be an existing directory. When
|
|
* omitted, each child inherits its delegating parent session's cwd — and
|
|
* starting one from a parent session that has no cwd fails.
|
|
*/
|
|
cwd?: string
|
|
/**
|
|
* How to auto-answer the child's `session/request_permission` prompts:
|
|
* `reject` (default — decline every prompt) or `allow` (approve via the first
|
|
* `allow_once` or `allow_always` option). No prompt is surfaced to a human.
|
|
*/
|
|
permission: PermissionPolicy
|
|
/**
|
|
* Extra environment variables for the child process — e.g. the child
|
|
* harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
|
|
* copy of the parent env, so an explicit key here reaches the child while
|
|
* ambient secrets do not leak implicitly.
|
|
*/
|
|
env: Record<string, string>
|
|
/**
|
|
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
|
|
* window to flush persistence and tear down its own nested subprocesses
|
|
* before the parent escalates to a signal. Must not exceed
|
|
* `MAX_TIMER_DELAY_MS`.
|
|
*/
|
|
disposeEofGraceMs?: number
|
|
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
|
disposeGraceMs?: number
|
|
}
|
|
|
|
export const Config: z<Config> = z.object({
|
|
providerName: z.string().default('acp'),
|
|
command: z.string().required(),
|
|
args: z.array(z.string()).default([]),
|
|
cwd: z.string(),
|
|
permission: z.union(['allow', 'reject'] as const).default('reject'),
|
|
env: z.dict(z.string()).default({}),
|
|
disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
|
|
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
|
})
|
|
|
|
/** A dispose grace must fit the single Node timer that owns its teardown tier. */
|
|
function assertPositiveFinite(name: string, value: number): void {
|
|
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
|
|
throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
}
|
|
}
|
|
|
|
/** The shape after schemastery applied the defaults (cwd has none). */
|
|
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
|
|
|
/**
|
|
* Whether `path` names an existing directory the harness can ENTER. The
|
|
* search-permission probe matters: `statSync().isDirectory()` is true for a
|
|
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
|
|
*/
|
|
function isDirectory(path: string): boolean {
|
|
try {
|
|
if (!statSync(path).isDirectory()) return false
|
|
accessSync(path, constants.X_OK)
|
|
return true
|
|
} catch {
|
|
// statSync/accessSync throw only filesystem access errors here
|
|
// (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
|
|
// serve as the child's cwd.
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert `cwd` can actually host the child: absolute (it doubles as the ACP
|
|
* session workspace, and a relative path would be re-anchored to the server
|
|
* process's launch directory) and an existing directory (fail here, before the
|
|
* process boundary, instead of as an ambiguous spawn ENOENT).
|
|
* @param label - which source supplied the value, for the diagnostic.
|
|
* @param cwd - the candidate working directory.
|
|
* @returns `cwd`, validated.
|
|
*/
|
|
function assertUsableCwd(label: string, cwd: string): string {
|
|
if (!isAbsolute(cwd)) {
|
|
throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`)
|
|
}
|
|
if (!isDirectory(cwd)) {
|
|
throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`)
|
|
}
|
|
return cwd
|
|
}
|
|
|
|
/**
|
|
* Resolve the child's working directory: the deployment `cwd` override when
|
|
* configured (already validated at load), else the parent session's workspace
|
|
* cwd (validated here, its earliest resolvable point). Fails loud when neither
|
|
* exists — falling back to the harness process cwd would silently bind the
|
|
* child to the server's launch directory instead of the delegating session's
|
|
* workspace (one server process serves many sessions, each with its own cwd).
|
|
*/
|
|
function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string {
|
|
if (configured !== undefined) return configured
|
|
const parentCwd = request.parent.session.header.cwd
|
|
if (parentCwd === undefined) {
|
|
throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one')
|
|
}
|
|
return assertUsableCwd('parent session cwd', parentCwd)
|
|
}
|
|
|
|
/**
|
|
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
|
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
|
* a request needing any of them before `start` runs).
|
|
*/
|
|
class AcpProvider implements SubagentProvider {
|
|
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
|
// Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
|
|
readonly inheritsParentContext = false
|
|
|
|
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
|
|
|
start(request: ResolvedSubagentStartRequest) {
|
|
const spec: AcpRunSpec = {
|
|
command: this.config.command,
|
|
args: this.config.args,
|
|
cwd: resolveCwd(this.config.cwd, request),
|
|
permission: this.config.permission,
|
|
env: this.config.env,
|
|
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
|
disposeGraceMs: this.config.disposeGraceMs,
|
|
spawn: spec => this.ctx.subprocess.spawn(spec),
|
|
onError: (error, stopReason) => {
|
|
// The seam forbids `result` rejecting, so a child-level failure is
|
|
// flattened to a stop reason — preserve it here rather than losing it.
|
|
this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`)
|
|
},
|
|
}
|
|
return startAcpRun(request, spec)
|
|
}
|
|
}
|
|
|
|
export function apply(ctx: Context, config: Config): void {
|
|
// schemastery (Config) has already filled every defaulted field.
|
|
const resolved = config as ResolvedConfig
|
|
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
|
|
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
|
|
// `path.resolve('')` is the process cwd — an empty string would silently
|
|
// reintroduce the launch-directory fallback this resolution removed.
|
|
if (resolved.cwd === '') {
|
|
throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd')
|
|
}
|
|
// Interpret a relative configured cwd against the harness launch directory
|
|
// ONCE, at load, and fail a misconfigured directory here — not per start.
|
|
const validated: ResolvedConfig = resolved.cwd === undefined
|
|
? resolved
|
|
: { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) }
|
|
ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated))
|
|
}
|