feat(subagent): continuable background subagents
Implement the continuable background subagents RFC: a durable child session with a series of Task-backed activations, each disposing its run before the Task settles. - dsh-subagent: rename SubagentRun.sendMessage to strict steer, drop run-level resume, add SubagentProvider.resume dispatch via SubagentService.resume, the continuation start field, and the versioned model-hidden subagent/descriptor session event. - dsh-subagent-inprocess/-spawn/-fork: publish the control-allocated child id, append the descriptor inside the initial turn, implement cold resume from the child's own transcript under the live parent scope, and strict running-only steer. - dsh-subagent-control (new): SubagentControlService owning stable child ids, descriptor snapshot/fold/authorization, Task-backed activation with settle-then-dispose ordering, the process-local active-run association, and steer-or-resume sendMessage routing. - dsh-tool-subagent: background route branches on the provider's resume capability (continuable via the control service; one-shot task for ACP), returning both child and task ids. - dsh-tool-subagent-control (new): the globally named send_message tool rendering steered/started routes. Keyless coverage spans Task ownership and disposal ordering, running delivery, cold follow-up, descriptor rejection and rollback, known-id reconstruction, kill during lookup, admission races, and a new subagent-continuable ACP snapshot scenario.
This commit is contained in:
@@ -13,12 +13,13 @@
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Scope: the seam stays collection-agnostic — a run is started and its
|
||||
* `result` awaited, whether the consumer blocks on it (foreground) or
|
||||
* registers it as a `ctx.tasks` background task (the generic runtime owns
|
||||
* ids/polling/stop; this seam gains nothing task-shaped). Steering
|
||||
* ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
|
||||
* unused.
|
||||
* Scope: the seam stays collection-, Task-, and persistence-agnostic — a run
|
||||
* is started or resumed and its `result` awaited, whether the consumer blocks
|
||||
* on it (foreground) or registers it as a `ctx.tasks` background task (the
|
||||
* generic runtime owns ids/polling/stop; this seam gains nothing task-shaped).
|
||||
* Durable continuable-child ids, descriptor lookup, and Task association
|
||||
* belong to `@deepseek-ai/dsh-subagent-control`; this service only validates
|
||||
* and dispatches `start`/`resume` and observes run lifecycle.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
SubagentResumeRequest,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
@@ -50,13 +52,21 @@ export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
export type {
|
||||
SubagentCapabilities,
|
||||
SubagentContinuation,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
SubagentResumeRequest,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
SubagentStopReasonMap,
|
||||
} from './types.ts'
|
||||
export {
|
||||
foldSubagentDescriptor,
|
||||
snapshotSubagentDescriptor,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
} from './descriptor.ts'
|
||||
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-agent' {
|
||||
interface AgentOptions {
|
||||
@@ -237,16 +247,52 @@ export class SubagentService extends Service {
|
||||
* @returns the ready holder-owned run.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a persisted continuable child through the named provider's
|
||||
* `resume` capability, with the same run lifecycle observation as
|
||||
* {@link start}. The caller (the control service) has already loaded the
|
||||
* child, folded its descriptor, and authorized the parent; this method owns
|
||||
* only capability-checked dispatch.
|
||||
* @param name - the provider recorded in the child's descriptor.
|
||||
* @param request - the fully resolved resume request.
|
||||
* @returns the fresh holder-owned run for the resumed activation.
|
||||
*/
|
||||
async resume(name: string, request: SubagentResumeRequest): Promise<SubagentRun> {
|
||||
const provider = this.expectProvider(name)
|
||||
if (provider.resume === undefined) {
|
||||
throw new SubagentError(
|
||||
`subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`,
|
||||
'UNSUPPORTED_CAPABILITY',
|
||||
)
|
||||
}
|
||||
return this.observeRun(name, request.parent, await provider.resume(request))
|
||||
}
|
||||
|
||||
/** Look up a provider for dispatch or fail loud. */
|
||||
private expectProvider(name: string): SubagentProvider {
|
||||
const provider = this.providers.get(name)
|
||||
if (provider === undefined) {
|
||||
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
||||
}
|
||||
this.assertCapabilities(provider, request)
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
|
||||
return provider
|
||||
}
|
||||
|
||||
const parent = request.parent
|
||||
const run = await provider.start(request)
|
||||
/** 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())
|
||||
const lifecycleIdentity = {
|
||||
runId,
|
||||
|
||||
Reference in New Issue
Block a user