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:
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
packages/subagent/subagent/src/child-agent.ts
Normal file
128
packages/subagent/subagent/src/child-agent.ts
Normal 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
51
packages/subagent/subagent/src/depth.ts
Normal file
51
packages/subagent/subagent/src/depth.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
31
packages/subagent/subagent/src/descriptor-seed.ts
Normal file
31
packages/subagent/subagent/src/descriptor-seed.ts
Normal 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]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
71
packages/subagent/subagent/src/run-settlement.ts
Normal file
71
packages/subagent/subagent/src/run-settlement.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user