feat(subagent): activation-based continuable subagents (source)

Replace the Task-backed continuation manager with one durable Session plus at
most one process-local Activation — a residency epoch for a reconstructed child
Agent, not a request, result, cancellation, or Task boundary. The manager owns
activation admission, authority, the live ownership graph, cold resume, and
child-first disposal; the Agent inbox is the only turn FIFO.

- startContinuable() is async and returns { childId, messageId } at inbox
  acceptance; followup() takes a SubagentAuthority and returns AgentMessageId.
- SubagentProvider.resume?(), SubagentProviderResumeRequest, SubagentRun.steer?(),
  SubagentProviderStartRequest and SubagentContinuation are deleted;
  prepareContinuable?() is the continuable-creation capability.
- Cold resume calls ctx.agents.resume() from the manager through a private
  activation-owner scope, never dispatching through a provider.
- Extract shared child composition, descriptor seeding, depth accounting, and
  one-shot run settlement so the manager and one-shot driver keep one home
  per fact.

Tests and docs follow in subsequent commits.
This commit is contained in:
Dudu-0223
2026-07-30 13:26:13 +08:00
committed by Tianyi Cui
parent dabd50e067
commit 26a117f842
15 changed files with 1721 additions and 868 deletions

View File

@@ -12,12 +12,13 @@ import z from 'schemastery'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
SubagentCapabilities,
SubagentProvider,
SubagentProviderResumeRequest,
SubagentProviderStartRequest,
SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-fork'
// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the
@@ -64,7 +65,7 @@ class ForkProvider implements SubagentProvider {
constructor(readonly name: string) {}
start(request: SubagentProviderStartRequest) {
start(request: SubagentStartRequest) {
const seed = completedTurnPrefix(request.parent)
return startInProcessRun(request, {
// Only pass a seed when there's a completed turn to inherit; an empty seed
@@ -73,11 +74,12 @@ class ForkProvider implements SubagentProvider {
})
}
resume(request: SubagentProviderResumeRequest) {
// Cold resume loads the child's OWN persisted transcript, which already
// contains the completed-turn prefix captured at initial creation; it
// never forks the parent's newer history again.
return resumeInProcessRun(request)
prepareContinuable(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec> {
// The fork prefix is captured ONCE, at creation: it becomes part of the
// child's own durable transcript, so a later cold resume replays that
// prefix instead of re-forking the parent's newer history.
const seed = completedTurnPrefix(request.parent)
return Promise.resolve(seed.length > 0 ? { seed } : {})
}
}

View File

@@ -1,24 +1,32 @@
/**
* Shared driver for in-process subagent providers. The agent factory's
* Shared driver for in-process ONE-SHOT subagent providers. The agent factory's
* creation transaction owns unpublished setup and rollback; after publication
* the returned AgentHandle is the one quiescent lifecycle owner held by the
* provider's caller.
*
* Continuable children never come through here: the continuation manager
* composes and drives them directly, so this driver owns exactly one turn with
* one result.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import {
applyChildComposition,
assertSubagentMaxDepth,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
import type {
SubagentDescriptorData,
SubagentProviderResumeRequest,
SubagentProviderStartRequest,
SubagentResult,
SubagentRun,
SubagentStartRequest,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
@@ -36,14 +44,6 @@ export {
STRUCTURED_OUTPUT_INSTRUCTION,
} from './structured.ts'
/** Thrown when starting a child would exceed the requested depth cap. */
class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
this.name = 'SubagentDepthError'
}
}
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
@@ -67,76 +67,31 @@ export interface InProcessRunOptions {
readonly seed?: SessionEvent[]
}
/** Whether one activation must prove its final state durable before success. */
type Durability = 'best-effort' | 'required'
/** Activation-specific inputs to the shared in-process driver. */
interface DriveTurnOptions {
readonly durability: Durability
/** Attribution for a resumed activation's follow-up prompt. */
readonly source?: MessageSource
readonly structured?: StructuredAttachment
}
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/**
* Register the one-shot child-scoped contribution that appends the durable
* `subagent/descriptor` event. The prepended `agent/prompt-submit` wrapper
* appends before downstream admission can block or throw. Allowed admission
* opens the initial turn afterward; the final required checkpoint also
* persists the descriptor when no turn opens.
*/
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => {
agent.session.append('subagent/descriptor', descriptor)
return next()
}, { prepend: true })
}
/**
* Establish and drive one in-process child. Fulfillment means the agent is
* already published in the registry; rejection means the agent factory's
* Establish and drive one in-process one-shot child. Fulfillment means the agent
* is already published in the registry; rejection means the agent factory's
* creation transaction and any partially-created child have reached quiescence.
* A `request.continuation` publishes exactly its stable child id and appends
* its descriptor before the child's initial prompt admission.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a ready holder-owned run.
*/
export async function startInProcessRun(
request: SubagentProviderStartRequest,
request: SubagentStartRequest,
options: InProcessRunOptions,
): Promise<SubagentRun> {
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const childDepth = delegationDepthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
const childDepth = resolveChildDepth(parent, request.maxDepth)
// A continuable delegation names the durable conversation up front; the
// provider publishes exactly that id instead of allocating one internally.
const childId = request.continuation?.sessionId ?? SessionId(randomUUID())
const seedLength = options.seed?.length ?? 0
const parentHeader = parent.session.header
const parentProvider = parent.options.provider
const parentModel = parent.options.model
const parentMaxTokens = parent.options.maxTokens
const agentOptions: AgentOptions = {
...parentProvider !== undefined ? { provider: parentProvider } : {},
...parentModel !== undefined ? { model: parentModel } : {},
...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {},
...request.agentOptions,
subagentDepth: childDepth,
}
const childId = SessionId(randomUUID())
const seed = options.seed
const activationBoundary = seed?.length ?? 0
// Capture before the first await: a later parent switch belongs to the
// parent's future.
@@ -145,6 +100,8 @@ export async function startInProcessRun(
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
// Inherited overrides land on the child's own log, so its effective policy
// is reconstructable from that log alone.
const childSession = (childCtx.agent as Agent).session
if (inheritedMode !== undefined) {
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
@@ -152,29 +109,20 @@ export async function startInProcessRun(
if (inheritedPolicy !== undefined) {
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
}
if (request.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
}
if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
applyChildComposition(childCtx, {
persona: request.persona,
toolFilter: request.toolFilter,
})
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
if (request.continuation !== undefined) {
attachDescriptorAppend(childCtx, request.continuation.descriptor)
}
}
const handle = await parent.ctx.agents.create({
sessionId: childId,
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...seedLength > 0 ? { seedLength } : {},
},
...options.seed === undefined ? {} : { seed: options.seed },
agentOptions,
meta: childSessionMeta(parent, childDepth, activationBoundary),
...seed !== undefined ? { seed } : {},
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
signal: request.signal,
setup,
})
@@ -183,62 +131,15 @@ export async function startInProcessRun(
request.signal,
request.prompt,
childId,
seedLength,
{
durability: request.continuation === undefined ? 'best-effort' : 'required',
...structured === undefined ? {} : { structured },
},
activationBoundary,
structured,
)
}
/**
* Reconstruct a persisted continuable child under the live parent's scope and
* drive one follow-up turn. The resumed session's own transcript is the seed
* (loaded through the parent's persistence-backed registry `resume`), so a
* fork child never re-forks current parent history; the persisted header
* remains authoritative for lineage and the delegation-depth floor.
* @param request - the fully resolved resume request from the continuation manager.
* @returns a fresh ready holder-owned run for this activation.
*/
export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise<SubagentRun> {
if (request.signal.aborted) throw prePublicationAbort()
const descriptor = request.descriptor
const agentOptions: AgentOptions = {
...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
}
const setup = (childCtx: Context): void => {
if (descriptor.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona })
}
if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter)
}
const handle = await request.parent.ctx.agents.resume({
resumeSessionId: request.sessionId,
agentOptions,
signal: request.signal,
setup,
})
// The result boundary is this activation's own work: everything already in
// the resumed transcript belongs to earlier turns.
const resumePoint = handle.agent.session.events.length
return driveTurn(
handle,
request.signal,
request.prompt,
request.sessionId,
resumePoint,
{ durability: 'required', source: request.source },
)
}
/**
* Drive one activation turn on a published child and wrap it as a run. The
* caller has already created or resumed the agent; this owns the
* signal-handoff race, the live abort listener, result collection past
* `boundary`, the continuable-run durability confirmation, confirmed
* steering, and disposal.
* Drive one turn on a published child and wrap it as a run. The caller has
* already created the agent; this owns the signal-handoff race, the live abort
* listener, result collection past `boundary`, and disposal.
*/
function driveTurn(
handle: AgentHandle,
@@ -246,10 +147,9 @@ function driveTurn(
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
options: DriveTurnOptions,
structured: StructuredAttachment | undefined,
): SubagentRun | Promise<never> {
const child = handle.agent
const { durability, source, structured } = options
// Agent creation detaches its creation-only abort listener before returning.
// Close the narrow handoff race before installing the live-run listener.
if (signal.aborted) {
@@ -265,30 +165,13 @@ function driveTurn(
const result: Promise<SubagentResult> = (async () => {
try {
child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } }))
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
await child.whenIdle()
if (durability === 'required') {
try {
const participated = await child.ctx.sessions.flush(child.session)
if (!participated) {
throw new Error(`session "${child.id}" required durability checkpoint has no registered listener`)
}
} catch (error: unknown) {
if (!signal.aborted) {
throw new SubagentError(
`subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`,
'DURABILITY_FAILED',
{ cause: error },
)
}
}
}
return readResult(
child,
boundary,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
durability === 'required' && signal.aborted,
)
} finally {
signal.removeEventListener('abort', onAbort)
@@ -304,23 +187,6 @@ function driveTurn(
flags.cancelled = true
return handle.dispose()
},
async steer(content: ContentBlock[], steeringSource: MessageSource): Promise<void> {
// The status check and submission share one synchronous frame. An idle
// Agent.steer() would queue an untracked turn after this run's result.
if (child.status !== 'running') {
throw new Error(`subagent child "${childId}" is not running; the message was not delivered`)
}
// Avoid waiting for the structured terminal checkpoint when its outcome
// is already authoritative and synchronously visible.
if (structured?.captured() !== undefined) {
throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`)
}
const receipt = child.steer(createUserMessage({ content, source: steeringSource }))
const outcome = await receipt.outcome
if (outcome.status === 'rejected') {
throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`)
}
},
}
}
@@ -330,7 +196,6 @@ function readResult(
boundary: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
cancellationOwnsCompleted = false,
): SubagentResult {
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
@@ -338,13 +203,8 @@ function readResult(
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead. Activation cancellation during
// its final durability checkpoint also owns a recorded completed turn because
// the provider has not published that result yet.
const stopReason: SubagentStopReason = cancelled
&& (recorded !== 'completed' || cancellationOwnsCompleted)
? 'aborted'
: recorded
// `aborted` end, yielding `disposed` instead.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded
if (structured !== undefined) {
if (structured.captured !== undefined) {
return { output, structured: structured.captured.value, stopReason }

View File

@@ -9,12 +9,12 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {
ContinuableCreateSpec,
SubagentCapabilities,
SubagentProvider,
SubagentProviderResumeRequest,
SubagentProviderStartRequest,
SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-spawn'
// `tools` is deliberately not injected: the child factory already provides it during setup,
@@ -45,17 +45,17 @@ class SpawnProvider implements SubagentProvider {
constructor(readonly name: string) {}
start(request: SubagentProviderStartRequest) {
start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot (including the structured capture when the
// request carries an outputSchema), and maps the result.
return startInProcessRun(request, {})
}
resume(request: SubagentProviderResumeRequest) {
// Cold resume reconstructs the persisted child from its own transcript
// under the live parent scope; the shared driver drives the follow-up turn.
return resumeInProcessRun(request)
prepareContinuable(): Promise<ContinuableCreateSpec> {
// A spawned child starts fresh, so it contributes no seed; the continuation
// manager owns every later operation on it.
return Promise.resolve({})
}
}

View File

@@ -0,0 +1,128 @@
/**
* Shared in-process child composition: the delegation-depth budget, the
* durable session metadata, the resolved child `AgentOptions`, and the scoped
* setup a child agent needs. Both the one-shot provider driver and the
* continuation manager compose children this way, so depth accounting and
* lineage stamping have one home.
*
* @module @deepseek-ai/dsh-subagent/child-agent
*/
import type { Context } from 'cordis'
import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
import { delegationDepthOf } from './depth.ts'
/** Thrown when starting a child would exceed the requested depth cap. */
export class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
this.name = 'SubagentDepthError'
}
}
/**
* Resolve the child's delegation depth from its parent and enforce an optional
* cap. The persisted parent header is the monotone floor, so a resumed parent
* cannot delegate as if it were top-level.
* @param parent - the delegating parent agent.
* @param maxDepth - optional absolute cap the resolved depth must not exceed.
* @returns the child's non-negative safe-integer depth.
* @throws {SubagentDepthError} when the resolved depth exceeds `maxDepth`.
* @throws {RangeError} when the resolved depth leaves the safe-integer range.
*/
export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number {
const childDepth = delegationDepthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (maxDepth !== undefined && childDepth > maxDepth) {
throw new SubagentDepthError(childDepth, maxDepth)
}
return childDepth
}
/**
* Resolve the child's `AgentOptions`: the parent's provider/model/maxTokens
* route unless the request overrides it, stamped with the child's own
* delegation depth.
* @param parent - the delegating parent whose route the child inherits.
* @param requested - per-child overrides, if any.
* @param childDepth - the resolved delegation depth to stamp.
* @returns the resolved options for `ctx.agents.create()`.
*/
export function resolveChildAgentOptions(
parent: Agent,
requested: AgentOptions | undefined,
childDepth: number,
): AgentOptions {
const parentProvider = parent.options.provider
const parentModel = parent.options.model
const parentMaxTokens = parent.options.maxTokens
return {
...parentProvider !== undefined ? { provider: parentProvider } : {},
...parentModel !== undefined ? { model: parentModel } : {},
...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {},
...requested,
subagentDepth: childDepth,
}
}
/**
* Build the child session's durable creation metadata: the parent's workspace,
* its direct lineage, the recursion budget that must survive persistence, and
* the seed boundary that separates inherited parent history from child work.
* @param parent - the delegating parent agent.
* @param childDepth - the resolved delegation depth to persist.
* @param lineageSeedLength - how many leading events came from the parent's log.
* @returns the `meta` for `ctx.agents.create()`.
*/
export function childSessionMeta(
parent: Agent,
childDepth: number,
lineageSeedLength: number,
): NonNullable<CreateAgentOptions['meta']> {
const parentHeader = parent.session.header
return {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {},
}
}
/** The scoped composition a child agent's creation window applies. */
export interface ChildComposition {
/** Per-child persona shadowing the deployment persona. */
readonly persona?: string | undefined
/** Per-child tool scoping. */
readonly toolFilter?: ToolRestriction | undefined
}
/**
* Apply one child's scoped composition inside its creation window: a shadowing
* persona section and a tool restriction, both owned by the child's scope and
* therefore invisible to its parent and siblings.
* @param childCtx - the child agent's scoped creation context.
* @param composition - the persona and tool filter to install.
*/
export function applyChildComposition(childCtx: Context, composition: ChildComposition): void {
if (composition.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona })
}
if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
}
/** Identity and lineage inputs shared by every in-process child creation. */
export interface ChildCreateInputs {
/** The child's reserved session id. */
readonly sessionId: SessionId
/** The delegating parent agent. */
readonly parent: Agent
/** The resolved delegation depth. */
readonly childDepth: number
/** How many leading seed events came from the parent's log. */
readonly lineageSeedLength: number
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
/**
* Delegation-depth accounting: the recursion budget a parent passes to its
* children. Kept apart from the service so composition helpers can read it
* without importing the registry.
*
* @module @deepseek-ai/dsh-subagent/depth
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth, treating absence as top-level depth zero.
* The persisted session header is authoritative and monotone: runtime
* `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it —
* a resumed child arrives with fresh options, and counting it from zero would
* let it delegate as if it were top-level.
* @param agent - the agent whose header and options carry the depth.
* @returns its non-negative safe-integer depth.
* @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer.
*/
export function delegationDepthOf(agent: Agent): number {
const runtime = agent.options.subagentDepth
if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
// The header value was validated at the session boundary (creation and
// persistence load both construct through the store).
return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}

View File

@@ -0,0 +1,31 @@
/**
* Seeding of a continuable child's durable descriptor event: the model-hidden
* record of the child's declared composition before its first request, so a
* later cold resume can reconstruct it from its own log.
*
* @module @deepseek-ai/dsh-subagent/descriptor-seed
*/
import { Session } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentDescriptorData } from './descriptor.ts'
/**
* Build the child's creation seed: any inherited parent-history prefix followed
* by one model-hidden, between-turn `descriptor` event. Staging through a
* `Session` assigns the sequence number and enforces the same lossless-JSON
* rules the durable log does.
* @param childId - the reserved child session id the staged log belongs to.
* @param seed - the inherited completed-turn prefix, or `undefined` for a fresh child.
* @param descriptor - the snapshotted composition record to persist.
* @returns the complete seed events, contiguous from sequence zero.
*/
export function seedDescriptorTurn(
childId: SessionId,
seed: readonly SessionEvent[] | undefined,
descriptor: SubagentDescriptorData,
): SessionEvent[] {
const staged = new Session(childId, seed)
staged.append('subagent/descriptor', descriptor)
return [...staged.events]
}

View File

@@ -13,11 +13,13 @@
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
* Public operations express caller intent: `start` returns one ready owned run,
* `startContinuable` starts a Task-backed durable child, and `followup` routes
* later content without exposing whether the child is live. Provider resume
* dispatch stays private because only the continuation manager holds the
* resolved descriptor and authorization facts.
* Public operations express caller intent: `start` returns one ready owned
* one-shot run, `startContinuable` establishes a durable continuable child, and
* `followup` delivers later content without exposing whether the child is
* resident. Continuable children never become a {@link SubagentRun}: the
* continuation manager holds their `AgentHandle` directly and orders every turn
* through the child's own inbox, so providers contribute only the detached
* creation spec and see no handle, turn, or teardown.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -32,36 +34,38 @@ import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
SubagentCapabilities,
SubagentProvider,
SubagentProviderResumeRequest,
SubagentProviderStartRequest,
SubagentResult,
SubagentRun,
SubagentStartRequest,
} from './types.ts'
import { SubagentRunId } from './types.ts'
import { SubagentError } from './error.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import SubagentContinuationManager from './continuation.ts'
import type {
ActivationObserver,
ActivationState,
ContinuableStart,
ContinuableStartSpec,
SubagentAuthority,
SubagentFollowupOptions,
SubagentFollowupResult,
} from './continuation.ts'
export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
export type {
ContinuableCreateRequest,
ContinuableCreateSpec,
SubagentCapabilities,
SubagentContinuation,
SubagentProvider,
SubagentProviderResumeRequest,
SubagentProviderStartRequest,
SubagentResult,
SubagentRun,
SubagentStartRequest,
@@ -74,58 +78,28 @@ export {
SUBAGENT_DESCRIPTOR_VERSION,
} from './descriptor.ts'
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
export { seedDescriptorTurn } from './descriptor-seed.ts'
export { SubagentError } from './error.ts'
export { settleRun } from './continuation.ts'
export { settleRun } from './run-settlement.ts'
export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts'
export {
applyChildComposition,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
SubagentDepthError,
} from './child-agent.ts'
export type { ChildComposition } from './child-agent.ts'
export type {
ActivationObserver,
ActivationState,
ContinuableStart,
ContinuableStartSpec,
CoordinatorMessageSource,
SubagentAuthority,
SubagentFollowupOptions,
SubagentFollowupResult,
} from './continuation.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth, treating absence as top-level depth zero.
* The persisted session header is authoritative and monotone: runtime
* `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it —
* a resumed child arrives with fresh options, and counting it from zero would
* let it delegate as if it were top-level.
* @param agent - the agent whose header and options carry the depth.
* @returns its non-negative safe-integer depth.
* @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer.
*/
export function delegationDepthOf(agent: Agent): number {
const runtime = agent.options.subagentDepth
if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
// The header value was validated at the session boundary (creation and
// persistence load both construct through the store).
return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -195,19 +169,18 @@ export interface SubagentRunEndInfo {
readonly lastAssistantMessage?: ContentBlock[]
}
/** Named provider registry with raw and Task-backed continuation operations. */
/** Named provider registry with one-shot runs and continuable-child operations. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
private continuations: SubagentContinuationManager | undefined
constructor(ctx: Context) {
super(ctx, 'subagents')
ctx.inject(['tasks', 'agents'], (childCtx: Context) => {
const manager = new SubagentContinuationManager(
childCtx,
(name, request) => this.startProvider(name, request),
request => this.resumeProvider(request),
)
ctx.inject(['agents'], (childCtx: Context) => {
const manager = new SubagentContinuationManager(childCtx, {
prepareContinuable: (name, request) => this.prepareContinuable(name, request),
observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent),
})
this.continuations = manager
childCtx.effect(() => () => {
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
@@ -217,34 +190,64 @@ export class SubagentService extends Service {
}
/**
* Start one durable continuable child through a Task-backed initial
* activation.
* @param spec - provider, Task label, and delegation request.
* @returns the stable child id and initial activation Task id.
* Establish one durable continuable child and deliver its initial prompt.
* Resolves when the child's inbox accepts that prompt, without waiting for the
* turn to start or for the message to reach the Session log; any earlier
* failure rejects with no ids and rolls back the child entirely.
* @param spec - provider, delegation request, and caller cancellation.
* @returns the durable child id and the accepted prompt's message id.
* @throws when continuation services are unavailable or materialization fails.
*/
startContinuable(spec: ContinuableStartSpec): ContinuableStart {
startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
return this.requireContinuations().startContinuable(spec)
}
/**
* Follow up with a continuable child. A live child is steered and fulfillment
* confirms request admission; an idle child immediately returns a fresh Task
* whose descriptor lookup, authorization, and cold resume may later fail.
* @param parent - live direct parent authorizing the operation.
* Deliver one later message to a continuable child as its next FIFO turn. A
* resident child's Agent inbox accepts it directly (waking a `waiting`
* Activation), while an absent one is cold-resumed from its persisted
* Session. The Agent inbox is the only queue, so parent and user messages
* share one observable order.
* @param authority - trusted parent or user authority for this delivery.
* @param childId - durable child session id.
* @param content - user-role content to deliver.
* @param options - durable attribution and caller cancellation; aborting a
* live-delivery wait cancels the shared activation and awaits quiescence.
* @returns the existing steered Task or newly started Task.
* @throws when continuation services are unavailable or live delivery is not admitted.
* @param options - durable provenance and caller cancellation, which stops the
* operation only before inbox acceptance.
* @returns the accepted message's inbox id.
* @throws when continuation services are unavailable, authority is rejected,
* or the message was not admitted.
*/
followup(
parent: Agent,
authority: SubagentAuthority,
childId: SessionId,
content: ContentBlock[],
options: SubagentFollowupOptions,
): Promise<SubagentFollowupResult> {
return this.requireContinuations().followup(parent, childId, content, options)
): Promise<MessageId> {
return this.requireContinuations().followup(authority, childId, content, options)
}
/**
* Read one durable child's live residency state.
* @param childId - durable child session id.
* @returns its Activation state, or `undefined` when no Activation is live.
* @throws when continuation services are unavailable.
*/
activationState(childId: SessionId): ActivationState | undefined {
return this.requireContinuations().activationState(childId)
}
/**
* Close continuable admission synchronously, then dispose every live
* Activation forest child-first. A host calls this before disposing top-level
* agents so no descendant outlives the runtime that owns its teardown.
* @returns once every live Activation released its `AgentHandle`.
* @throws an aggregate error after all branches settle when any failed.
*/
async drainContinuable(): Promise<void> {
const manager = this.continuations
// Absent continuation services means nothing was ever materialized.
if (manager === undefined) return
await manager.drain()
}
/**
@@ -298,40 +301,32 @@ export class SubagentService extends Service {
* @param request - child prompt, parent, signal, and optional capabilities.
* @returns the ready holder-owned run.
*/
async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise<SubagentRun> {
return this.startProvider(name, request)
}
/** Validate and dispatch one ordinary or service-resolved provider start. */
private async startProvider(
name: string,
request: SubagentProviderStartRequest,
): Promise<SubagentRun> {
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
const provider = this.expectProvider(name)
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
if (request.continuation !== undefined && provider.resume === undefined) {
throw new SubagentError(
`subagent provider "${provider.name}" does not support continuable children (no resume capability)`,
'UNSUPPORTED_CAPABILITY',
)
}
return this.observeRun(name, request.parent, await provider.start(request))
}
/** Dispatch one authorized provider resume and observe its run lifecycle. */
private async resumeProvider(request: SubagentProviderResumeRequest): Promise<SubagentRun> {
const name = request.descriptor.provider
/**
* Resolve one provider's detached continuable-creation contribution. Method
* presence on the provider IS the capability, so a provider without it is
* rejected before the manager reserves any child resources.
*/
private async prepareContinuable(
name: string,
request: ContinuableCreateRequest,
): Promise<ContinuableCreateSpec> {
const provider = this.expectProvider(name)
if (provider.resume === undefined) {
if (provider.prepareContinuable === undefined) {
throw new SubagentError(
`subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`,
`subagent provider "${provider.name}" does not support continuable children `
+ '(no prepareContinuable capability)',
'UNSUPPORTED_CAPABILITY',
)
}
return this.observeRun(name, request.parent, await provider.resume(request))
return provider.prepareContinuable(request)
}
/** Look up a provider for dispatch or fail loud. */
@@ -354,6 +349,41 @@ export class SubagentService extends Service {
return this.continuations
}
/**
* Emit the start/end lifecycle pair for one continuable Activation's
* residency epoch. Observers see the same vocabulary as a one-shot run, so a
* child's start and settlement remain observable without exposing whether the
* manager materialized, woke, or cold-resumed it. Creation failure before
* residency reports only the terminal edge.
*/
private observeActivation(
provider: string,
childId: SessionId,
parent: Agent | undefined,
): ActivationObserver {
const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true }
let started = false
let settled = false
return {
start: (): void => {
started = true
this.emitLifecycle('subagent/start', identity, parent)
},
settle: (child: Agent | undefined, failure: unknown): void => {
// A failure before residency has no start edge to pair, and inventing
// one would report a lifecycle the child never had.
if (settled || !started) return
settled = true
const output = failure === undefined ? lastAssistantOutput(child) : undefined
this.emitLifecycle('subagent/end', {
...identity,
stopReason: failure === undefined ? 'completed' : 'error',
...output === undefined ? {} : { lastAssistantMessage: output },
}, parent)
},
}
}
/** Emit the start/end lifecycle pair for one accepted run and return it. */
private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun {
const runId = SubagentRunId(randomUUID())
@@ -385,14 +415,16 @@ export class SubagentService extends Service {
* Emit lifecycle events with per-listener synchronous and asynchronous
* exception containment. Payloads are borrowed immutable values.
*/
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent | undefined): void
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent | undefined): void
private emitLifecycle(name: 'subagent/provider-removed', info: string): void
private emitLifecycle(
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
parent?: Agent ,
): void {
// A user-resumed continuable child has no delegating parent to key the
// carrier by, so its lifecycle reaches unscoped listeners globally.
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [scopeTarget(this, parent), name, info]
@@ -427,6 +459,18 @@ export class SubagentService extends Service {
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
*/
function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined {
if (child === undefined) return undefined
const message = child.session.events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {

View File

@@ -0,0 +1,71 @@
/**
* Settlement of one ONE-SHOT subagent run into a background-Task outcome. Only
* the one-shot background path uses Tasks; continuable children have no Task,
* no per-message result, and no Task cancellation.
*
* @module @deepseek-ai/dsh-subagent/run-settlement
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
import type { SubagentResult, SubagentRun } from './types.ts'
/** Flatten a child's final output blocks to the task's final text. */
function finalText(blocks: ContentBlock[]): string {
return blocks
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('')
}
/**
* Map a child result to the task outcome: completed carries final text,
* aborted is killed, and every other reason is failed without partial output.
* @param result - child terminal result.
* @returns outcome for the `ctx.tasks` registration.
*/
function runOutcome(result: SubagentResult): TaskOutcome {
switch (result.stopReason) {
case 'completed':
return { status: 'completed', output: finalText(result.output) }
case 'aborted':
return { status: 'killed' }
case 'error':
case 'max-tokens':
case 'refusal':
return { status: 'failed', detail: result.stopReason }
// Merge-extensible reasons remain failures with their raw detail.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/** Render infrastructure failure detail without hiding a durability diagnosis. */
function runFailureDetail(error: unknown): string {
return error instanceof HarnessError && error.code === 'DURABILITY_FAILED'
? error.message
: String(error)
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
* @param run - live run to settle and release.
* @returns outcome after child resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
let outcome: TaskOutcome
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: runFailureDetail(error) }
}
try {
await run.dispose()
} catch (error: unknown) {
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
}
return outcome
}

View File

@@ -6,10 +6,9 @@
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { SubagentDescriptorData } from './descriptor.ts'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
@@ -27,11 +26,12 @@ export function SubagentRunId(id: string): SubagentRunId {
* 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). These static flags cover features needed before a run exists; runtime
* capabilities are optional methods whose presence is the capability — confirmed live steering
* is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each
* flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to
* `maxDepth`; the other names match.
* degradation" rule). These flags describe the ONE-SHOT
* {@link SubagentProvider.start} path, where the provider composes the child;
* continuable children are composed by the continuation manager itself and are
* gated by {@link SubagentProvider.prepareContinuable} instead. Each flag
* corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit`
* to `maxDepth`; the other names match.
*/
export interface SubagentCapabilities {
readonly outputSchema: boolean
@@ -41,10 +41,10 @@ export interface SubagentCapabilities {
}
/**
* What a caller asks for when starting a subagent. The tool layer builds this
* from the model's `{ description, prompt }` plus its own config; the service
* validates {@link SubagentCapabilities} against the named provider and
* resolves a {@link SubagentProviderStartRequest} for dispatch.
* What a caller asks for when starting a ONE-SHOT subagent. The tool layer
* builds this from the model's `{ description, prompt }` plus its own config;
* the service validates {@link SubagentCapabilities} against the named provider
* before dispatching to {@link SubagentProvider.start}.
*/
export interface SubagentStartRequest {
/** Content delivered as the child's user message. */
@@ -96,63 +96,37 @@ export interface SubagentStartRequest {
}
/**
* Provider-facing start request after the service resolves optional
* continuation state. Ordinary callers use {@link SubagentStartRequest}; only
* the Task-backed continuation path can attach a stable child identity and
* durable descriptor.
* What the continuation manager asks a provider for while materializing one
* continuable child's FIRST activation. The manager has already reserved the
* durable child identity and owns every later operation, so this request
* carries only what distinguishes a fresh child from one seeded with parent
* history.
*/
export interface SubagentProviderStartRequest extends SubagentStartRequest {
/**
* Continuable-child state resolved by `ctx.subagents` before provider dispatch.
* The provider MUST publish exactly `sessionId` as the child identity
* instead of allocating one internally, and MUST append the snapshotted,
* model-hidden `subagent/descriptor` before the initial prompt is admitted.
* Requires {@link SubagentProvider.resume} (the
* continuation capability); the service rejects the request otherwise.
*/
readonly continuation?: SubagentContinuation | undefined
}
/**
* The resolved continuable-child identity and durable composition record the
* service attaches before provider dispatch.
*/
export interface SubagentContinuation {
/** Service-allocated stable child session id, published verbatim. */
export interface ContinuableCreateRequest {
/** The reserved durable child session id, for provider diagnostics. */
readonly sessionId: SessionId
/** Snapshotted descriptor persisted in the child log for cold resume. */
readonly descriptor: SubagentDescriptorData
}
/**
* Provider-facing request for reconstructing a persisted continuable child.
* The continuation manager loads the child log, folds and authorizes its
* descriptor, then privately dispatches this resolved request to
* {@link SubagentProvider.resume}. The provider reconstructs the declared
* composition under the live parent's scope and drives one turn with `prompt`.
*/
export interface SubagentProviderResumeRequest {
/** The persisted child session id to resume. */
readonly sessionId: SessionId
/** The follow-up message that starts the resumed activation's turn. */
readonly prompt: ContentBlock[]
/** Attribution retained when the follow-up becomes the resumed turn's user-role message. */
readonly source: MessageSource
/**
* The live parent agent — the direct parent recorded in the persisted child
* header. In-process backends reconstruct the child under this agent's
* currently loaded scope.
*/
/** The delegating parent agent whose history a seeding provider reads. */
readonly parent: Agent
/**
* Activation-owned cancellation signal, created before descriptor lookup.
* Same pre/post-publication contract as {@link SubagentStartRequest.signal}:
* an abort before publication rejects after rollback quiescence, and an
* abort afterward cancels the published child turn.
* Caller cancellation, which owns preparation only until the manager accepts
* the initial prompt into the child's inbox.
*/
readonly signal: AbortSignal
/** The folded durable descriptor whose composition the provider reconstructs. */
readonly descriptor: SubagentDescriptorData
}
/**
* A provider's detached contribution to one continuable child's creation. This
* is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt
* delivery, result, disposal, or resume operation, because the continuation
* manager owns the child's whole lifecycle after preparation.
*/
export interface ContinuableCreateSpec {
/**
* Completed-turn prefix of the parent's log to seed the child session with,
* or absent for a fresh child. Same durable contract as
* `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced.
*/
readonly seed?: readonly SessionEvent[]
}
/**
@@ -196,9 +170,12 @@ export interface SubagentResult {
}
/**
* 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.
* ONE-SHOT child handle returned only after readiness. Consumers await
* {@link result} and must always {@link dispose} to cancel remaining work and
* reach quiescence. A run is one disposable foreground delegation with one
* result; continuable conversations have no run — the continuation manager
* holds their `AgentHandle` directly and orders every turn through the child's
* own inbox.
*/
export interface SubagentRun {
/**
@@ -217,10 +194,8 @@ export interface SubagentRun {
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
* failure resolves with `stopReason: 'error'` so the consumer maps it to an
* `isError` tool result. For a continuable activation, a completed result
* also means the provider confirmed the activation's final state durable.
* Rejects on an infrastructure fault the seam cannot represent as a stop
* reason, including a failed required durability checkpoint.
* `isError` tool result. Rejects on an infrastructure fault the seam cannot
* represent as a stop reason.
*/
readonly result: Promise<SubagentResult>
/**
@@ -228,17 +203,6 @@ export interface SubagentRun {
* Idempotent.
*/
dispose(): Promise<void>
/**
* OPTIONAL (confirmed live-steering capability): submit additional content
* to the active child and fulfill only after a committed request snapshot
* admits it. Rejects when terminal policy, cancellation, disposal, or a lost
* settlement race prevents admission; it never falls through to a queued
* untracked turn or cold resume. A run represents one disposable activation,
* so resuming a settled child goes through {@link SubagentProvider.resume}.
* `source` is retained on the admitted steering message without changing its
* user role in model history.
*/
steer?(content: ContentBlock[], source: MessageSource): Promise<void>
}
/**
@@ -258,23 +222,27 @@ export interface SubagentProvider {
*/
readonly inheritsParentContext: boolean
/**
* Establish a child and return its handle only after publication. The
* service has already validated that every requested start-time capability
* is supported, so an implementation may assume e.g. `request.maxDepth` is
* honorable when present. If setup fails or `request.signal` aborts before
* fulfillment, the provider owns and cleans all partial resources before this
* promise rejects. Ownership transfers to the caller only on fulfillment.
* Establish a ONE-SHOT child and return its handle only after publication.
* The service has already validated that every requested start-time
* capability is supported, so an implementation may assume e.g.
* `request.maxDepth` is honorable when present. If setup fails or
* `request.signal` aborts before fulfillment, the provider owns and cleans
* all partial resources before this promise rejects. Ownership transfers to
* the caller only on fulfillment.
*/
start(request: SubagentProviderStartRequest): Promise<SubagentRun>
start(request: SubagentStartRequest): Promise<SubagentRun>
/**
* OPTIONAL (continuation capability): reconstruct a persisted continuable
* child from its own transcript and declared descriptor, drive one
* follow-up turn, and return a fresh run. Method presence is the capability
* the service rejects continuable starts and cold-resume dispatch on
* providers without it. Same publication contract as {@link start}: if
* reconstruction fails or `request.signal` aborts before fulfillment, the
* provider rolls its creation transaction back to quiescence before
* rejecting; after fulfillment the same signal cancels the published run.
* OPTIONAL (continuable-creation capability): contribute the detached
* creation inputs that distinguish this provider's continuable children —
* today only whether the child session is seeded with parent history. Method
* presence IS the capability: the service rejects continuable starts on
* providers without it, while a provider that has it may still serve
* ordinary one-shot delegations.
*
* This is the provider's ONLY participation in a continuable child. The
* continuation manager owns identity reservation, composition, Agent
* creation, prompt delivery, cold resume, ownership, and disposal, so a
* provider never sees the child's Agent, handle, turns, or teardown.
*/
resume?(request: SubagentProviderResumeRequest): Promise<SubagentRun>
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}

View File

@@ -1,9 +1,9 @@
/**
* The globally named `send_message` tool: a thin model-facing adapter over
* `ctx.subagents.followup()`. It performs no lifecycle routing of its
* own — steer-or-resume orchestration belongs to the subagent service — and it
* lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent`
* instances so multiple delegation tools share one control tool.
* `ctx.subagents.followup()`. It performs no lifecycle routing of its own —
* residency and cold resume belong to the subagent service — and it lives apart
* from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple
* delegation tools share one control tool.
* @module @deepseek-ai/dsh-tool-subagent-control
*/
@@ -24,10 +24,10 @@ export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'send_message',
description:
'Send a follow-up message to a background subagent by its subagent id. If it is still working, the '
+ 'message joins its current task; if it has finished, this starts a new task that continues the same '
+ 'subagent conversation. Either way the response arrives through the returned task id — collect it '
+ 'with `task_output`. A failure means the message was NOT delivered.',
'Send a message to a background subagent by its subagent id, continuing the same conversation. It '
+ 'becomes the subagent\'s next turn: if it is still working, the message waits until its current turn '
+ 'finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its '
+ 'transcript by its id to see what it did. A failure means the message was NOT delivered.',
parameters: {
subagent_id: {
type: 'string',
@@ -45,30 +45,23 @@ export function apply(ctx: Context): void {
type: 'object',
additionalProperties: false,
properties: {
route: {
type: 'string',
required: true,
enum: ['steered', 'started'],
},
taskId: { type: 'string', required: true },
messageId: { type: 'string', required: true },
},
},
render: (args, value) => [{
render: (args, _value) => [{
type: 'text',
text: value.route === 'steered'
? `message delivered to running task ${value.taskId}`
: `message started task ${value.taskId} continuing subagent ${args.subagent_id}`,
text: `message queued as the next turn for subagent ${args.subagent_id}`,
}],
},
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers have no session to authorize Task access with.
// Parent authority requires an exact live calling agent.
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
}
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const result = await ctx.subagents.followup(
parent,
const messageId = await ctx.subagents.followup(
{ kind: 'parent', agent: parent },
SessionId(args.subagent_id),
message,
{
@@ -76,7 +69,7 @@ export function apply(ctx: Context): void {
signal: exec.signal,
},
)
return result
return { messageId }
},
}))
}

View File

@@ -36,9 +36,9 @@ export interface Config {
*/
enableRunInBackground?: boolean
/**
* Background execution policy (default `one-shot`). `continuable` requires
* a provider with persisted resume support and returns both child and Task
* ids; follow-up adapters remain independently optional.
* Background execution policy (default `one-shot`). `continuable` requires a
* provider with the `prepareContinuable` capability and returns the durable
* child id; follow-up adapters remain independently optional.
*/
backgroundMode?: 'one-shot' | 'continuable'
/**
@@ -197,7 +197,7 @@ export function apply(ctx: Context, config: Config): void {
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
if (continuable && provider.resume === undefined) {
if (continuable && provider.prepareContinuable === undefined) {
throw new Error(
`tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``,
)
@@ -206,9 +206,9 @@ export function apply(ctx: Context, config: Config): void {
name: config.toolName ?? 'subagent',
description: wording.description + (backgroundEnabled
? continuable
? ' Set `run_in_background: true` to start a continuable background subagent: you receive its'
+ ' stable subagent id and current task id; collect the result with `task_output` and stop it with'
+ ' `task_kill`.'
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
+ ' you receive its subagent id and it works on its own. It does not report back to you, so read'
+ ' its transcript by that id, or send it more work with `send_message`.'
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
parameters: {
@@ -226,8 +226,8 @@ export function apply(ctx: Context, config: Config): void {
run_in_background: {
type: 'boolean' as const,
description: continuable
? 'Run as a continuable background subagent and return its subagent and task ids; '
+ 'collect with task_output or stop with task_kill.'
? 'Run as a background subagent that keeps its conversation and return its subagent id; '
+ 'send it more work with send_message.'
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
},
} : {},
@@ -241,7 +241,14 @@ export function apply(ctx: Context, config: Config): void {
properties: {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
subagentId: { type: 'string' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'continuable' },
subagentId: { type: 'string', required: true },
},
},
{
@@ -258,10 +265,10 @@ export function apply(ctx: Context, config: Config): void {
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? value.subagentId === undefined
? `started background subagent task ${value.taskId}`
: `started subagent ${value.subagentId} as task ${value.taskId}`
: outputValueText(value.output),
? `started background subagent task ${value.taskId}`
: value.kind === 'continuable'
? `started subagent ${value.subagentId}`
: outputValueText(value.output),
}],
},
async execute(args, exec) {
@@ -288,16 +295,14 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
}
if (continuable) {
const started = ctx.subagents.startContinuable({
// Resolves at inbox acceptance: the child owns its own turns from
// there, so this call neither waits for nor collects a result.
const started = await ctx.subagents.startContinuable({
provider: config.provider,
label: args.description,
request,
signal: exec.signal,
})
return {
kind: 'background' as const,
taskId: started.taskId,
subagentId: started.childId,
}
return { kind: 'continuable' as const, subagentId: started.childId }
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {