docs: rebalance prose cleanup and add trimming skill
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user