docs: rebalance prose cleanup and add trimming skill

This commit is contained in:
Tianyi Cui
2026-07-13 23:27:00 +08:00
parent fcdc318dda
commit 148046b9c8
392 changed files with 2801 additions and 1754 deletions

View File

@@ -1,7 +1,8 @@
/**
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider} on
* `ctx.subagents` that runs each child agent in a spawned SUBPROCESS, driven over the Agent
* Client Protocol (ACP) as the client.
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
* tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent-
* enforced start capabilities. 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
*/

View File

@@ -1,6 +1,10 @@
/**
* Fresh-process ACP subagent client. Drives one child session and owns process
* cancellation and quiescent disposal.
* Fresh-process ACP subagent client. Drives one child session and owns cancellation and
* quiescent disposal.
*
* TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
* sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
* with-key coverage drives the real ACP example.
* @module @deepseek-ai/dsh-subagent-acp/run
*/
@@ -24,7 +28,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
/** Fixed response to child permission requests: reject, or first allow option. */
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
export type PermissionPolicy = 'allow' | 'reject'
/** Resolved spawn spec for an ACP child process (no defaults — see Config). */
@@ -68,7 +72,7 @@ export interface AcpRunSpec {
onError?: (error: Error, stopReason: SubagentStopReason) => void
}
/** Default EOF grace for child flush and nested-process teardown before signaling. */
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */

View File

@@ -1,7 +1,9 @@
/**
* A minimal mock ACP AGENT, run as a subprocess, for the keyless `dsh-subagent-acp` tests. It
* speaks the agent side of ACP over stdio and is fully scripted by environment variables — no
* model, no network.
* Minimal no-network ACP child process for keyless backend tests. Environment variables script its
* text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a
* readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark
* SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with
* an explicit tsconfig, mirroring real example boot.
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
*/
@@ -122,8 +124,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
process.exit(1)
}
if (IGNORE_CANCEL) {
// A NON-COOPERATIVE child: receive session/cancel but never resolve the pending prompt
// and never exit.
// A non-cooperative child receives cancellation but neither resolves nor exits. The
// backend must still settle `aborted`, and disposal must kill the process.
return Promise.resolve()
}
resolveCancel?.('cancelled')
@@ -142,7 +144,7 @@ new AgentSideConnection(
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces
// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL
// escalation.
// escalation. READY_FILE proves the trap was armed before the test disposes the run.
if (process.env.MOCK_TRAP_SIGTERM === '1') {
process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
// Keep the event loop alive (a bare timer) so nothing else lets it exit.
@@ -152,7 +154,8 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') {
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the
// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and
// exit ON OUR own — no signal involved.
// exit on its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves
// the EOF grace window was long enough for durable flush.
if (FLUSH_ON_EOF !== undefined) {
const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
process.stdin.on('end', () => {
@@ -163,7 +166,9 @@ if (FLUSH_ON_EOF !== undefined) {
})
}
// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier.
// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier before SIGKILL. The signal
// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE
// proves the handler was armed before disposal.
if (process.env.MOCK_IGNORE_EOF === '1') {
const sigtermFile = process.env.MOCK_SIGTERM_FILE
process.on('SIGTERM', () => {

View File

@@ -9,7 +9,9 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import * as acp from '../src/index.ts'
/**
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP server.
* With-key cross-process seam proof: the backend spawns the real acp-agent example, speaks ACP over
* stdio, and returns its real model answer. This is the out-of-process counterpart to in-process
* spawn coverage and self-skips without `DEEPSEEK_API_KEY`.
*/
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).

View File

@@ -2,7 +2,8 @@
* The in-process FORK subagent backend: registers a {@link SubagentProvider} on
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a prefix of the
* parent's session log — so the child inherits the parent's conversation context instead of
* starting fresh.
* starting fresh. The seed ends at the last `turn/end`: the current tool-call turn is
* unbalanced and cannot be replayed as a valid child session.
* @module @deepseek-ai/dsh-subagent-fork
*/
@@ -32,8 +33,9 @@ export const Config: z<Config> = z.object({
/**
* The balanced completed-turn prefix of `parent`'s log: every event up to and including the
* last `turn/end`.
*
* last `turn/end`. The in-flight turn is excluded; before any completed turn the child starts
* fresh. Because live sequence numbers equal array indexes, the result remains a valid seed
* beginning at sequence zero.
* @param parent - the agent whose session log to slice.
* @returns the seed events, contiguous from seq 0; empty when no turn has completed.
*/

View File

@@ -136,7 +136,8 @@ describe('dsh-subagent-fork', () => {
it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => {
// Drive the parent so it has one completed turn, then start a SECOND turn that is still
// open (a hanging model call), and fork while it's in flight.
// open (a hanging model call), and fork while it's in flight. The seed must stop after the
// balanced first turn; including the open turn would fail invariant replay during start.
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
@@ -181,7 +182,8 @@ describe('dsh-subagent-fork', () => {
})
it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
// Regression: readResult must scope to the child's own events (after the seed).
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()

View File

@@ -1,6 +1,12 @@
/**
* Child-scoped structured-output tool, prompt instruction, terminal guard, and
* authoritative result capture for in-process subagents.
* Child-scoped structured-output tool, prompt instruction, terminal guard, and authoritative
* result capture for in-process subagents. Each child registers its real schema on its own
* scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt
* contribution is ordinary reconstructed request state.
*
* Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also
* waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard
* then prevent later listeners or calls from reopening a completed structured run.
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/

View File

@@ -35,7 +35,11 @@ const SCHEMA: StructuredOutputSchema = {
required: ['answer'],
}
/** Real loop and inline provider without a backend package dependency cycle. */
/**
* Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading
* spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while
* this fixture isolates driver behavior and scripts the child's `structured_output` calls.
*/
async function setup(script: Script, options: SetupOptions = {}) {
const ctx = new Context()
const adapter = new MockAdapter(script)
@@ -209,7 +213,9 @@ describe('in-process structured output', () => {
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
let wrapperInstalled = false
// Install a wrapper before the child loop can run.
// Register before ready-only start: structured output is attached before session-start and the
// loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose
// to the later terminal checkpoint.
ctx.on('agent/session-start', (child) => {
if (child === parent) return
wrapperInstalled = true
@@ -233,7 +239,8 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// The terminal checkpoint must discard steering queued by a wrapper.
// A downstream policy stops, then a later wrapper delegates and queues steering that ordinary
// folding would turn into continue. The terminal checkpoint must discard that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {

View File

@@ -12,8 +12,8 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-spawn'
// `tools` is deliberately not injected: the shared driver registers structured output through
// the child's creation context, whose factory already requires the tool service.
// `tools` is deliberately not injected: the child factory already provides it during setup,
// and adding it here would unnecessarily change this provider's apply timing.
export const inject = ['subagents']
/** Config: the registry name to register the provider under. */

View File

@@ -152,7 +152,8 @@ describe('dsh-subagent-spawn', () => {
})
it('rejects without publishing when the request signal is already aborted', async () => {
// An already-aborted signal will not emit another abort event.
// An already-aborted signal emits no future event, so start must check it before listening and
// settle aborted without running the child. The empty model script proves no turn occurs.
const controller = new AbortController()
controller.abort()
const { ctx, parent } = await setup([])
@@ -161,7 +162,8 @@ describe('dsh-subagent-spawn', () => {
})
it('same-tick cancellation rejects start and prevents child publication', async () => {
// Same-tick cancellation must win before publication.
// Same-tick cancellation must win before async factory publication: no child may become
// visible, `started` must not fulfill, and the empty script proves no model turn occurs.
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length

View File

@@ -1,7 +1,8 @@
/**
* Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external
* agent as a child process and must keep the parent deployment's credentials out of it, tear
* it down to quiescence, and isolate it from the host user's on-disk CLI state.
* it down to quiescence, and isolate it from the host user's on-disk CLI state. This package
* registers no provider; consuming plugins own and validate every timing or path default.
* @module @deepseek-ai/dsh-subagent-subprocess
*/
@@ -39,8 +40,8 @@ export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv
}
/**
* Capture the child's spawn-level failure as a promise the run's result path can race.
*
* Capture the child's spawn-level `error` event as a promise. Call in the same tick as
* `spawn()`; otherwise an early event can be unhandled and crash the parent.
* @param child - the just-spawned child process.
* @returns a promise that RESOLVES (never rejects) with the child's first
* `error` event; for a child that spawns cleanly it never settles.
@@ -109,8 +110,8 @@ export interface DisposeLadderGraces {
}
/**
* Tear a child process down to QUIESCENCE: resolves only once the child has actually exited
* (or was already gone), never merely after requesting it. Three-tier escalation —
* Tear a child process down to quiescence, resolving only after exit: close stdin and allow
* cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit.
*
* @param child - the child process to tear down.
* @param graces - the two grace periods, from the consuming plugin's Config.
@@ -118,7 +119,7 @@ export interface DisposeLadderGraces {
export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise<void> {
// Already gone: nothing to reap.
if (child.exitCode !== null || child.signalCode !== null) return
// 1.
// 1. Close stdin and allow cooperative teardown and durable-state flush.
child.stdin?.end()
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
// 2. SIGTERM, escalating if the child still does not exit within the grace.
@@ -147,9 +148,9 @@ export interface IsolatedConfigDir {
}
/**
* An isolated config dir for one child run, so the child's behavior is a function of
* deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state happens to
* exist on the host machine. Two modes.
* An isolated config dir for one child run, independent of host CLI state. Without
* `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory
* is returned unchanged and remains deployment-owned.
*
* @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
* `dsh-subagent-codex-`); ignored when `pinnedPath` is set.

View File

@@ -15,8 +15,8 @@ import {
waitForExit,
} from '../src/index.ts'
// `rm` is wrapped (real-passthrough by default) so one test can inject a rejection
// deterministically.
// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
// failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, rm: vi.fn(actual.rm) }

View File

@@ -14,7 +14,9 @@ import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-t
* Which START-TIME features a provider supports. Checked by the service before delegating to
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule).
* degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability.
*/
export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
@@ -54,8 +56,9 @@ export interface SubagentStartRequest {
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* Supported object-rooted JSON Schema for {@link SubagentResult.structured}.
* Requires the provider capability and plain host-realm JSON data.
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
* a successful child returns the matching value as {@link SubagentResult.structured}.
*/
readonly outputSchema?: StructuredOutputSchema
/**
@@ -124,8 +127,9 @@ export interface SubagentResult {
}
/**
* Ready child handle. Consumers await {@link result} and always {@link dispose}
* for quiescence. Optional methods indicate their runtime capabilities.
* Child handle returned only after readiness. Consumers await {@link result} and must always
* {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime
* capability discovery; narrow their presence before calling.
*/
export interface SubagentRun {
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
@@ -170,9 +174,9 @@ export interface SubagentProvider {
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* Whether a child receives the parent's completed conversation history. This
* descriptive fact drives tool wording; it says nothing about services, tools,
* or authority.
* Whether the child sees the parent's completed-turn prefix. This is descriptive, not a
* service-validated start capability: the model-facing tool derives truthful wording from it.
* It says nothing about tool registration, injected services, or authority inheritance.
*/
readonly inheritsParentContext: boolean
/**

View File

@@ -1,6 +1,11 @@
/**
* Provider-bound model tool that delegates to one child agent, awaits its
* result, and always disposes the run. Provider lifecycle controls registration.
* Model-facing delegation tool bound by configuration to one provider; transport selection is not
* exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and
* re-derives conversation-history wording after reload, so load order is irrelevant.
*
* Execution synchronously awaits the child result and always disposes the run. Non-completed stop
* reasons become error results, while transport details remain behind `ctx.subagents`. Load this
* plugin more than once to expose multiple configured providers.
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -77,7 +82,8 @@ export const Config: z<Config> = z.object({
model: z.string(),
}).default(undefined as unknown as { model: string }),
persona: z.string(),
// Preserve omitted filters and nested lists; an empty allow-list means deny all.
// Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which
// silently means deny all. Preserve omission while retaining an explicit empty allow-list.
toolFilter: z.object({
allow: z.array(z.string()).default(undefined as unknown as string[]),
deny: z.array(z.string()).default(undefined as unknown as string[]),
@@ -253,7 +259,7 @@ export function apply(ctx: Context, config: Config): void {
if (present !== undefined) {
mount(present)
} else {
// Not an error: the backend's fiber may simply activate after this one.
// Not an error: the backend's fiber may activate after this one.
// The tool appears the moment the provider registers; a typo'd provider
// name shows up as this note plus a tool that never materializes.
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)