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:
37
packages/subagent/subagent-control/README.md
Normal file
37
packages/subagent/subagent-control/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-subagent-control
|
||||
|
||||
The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic.
|
||||
|
||||
## Activation lifecycle
|
||||
|
||||
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent.
|
||||
|
||||
`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
|
||||
|
||||
Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface).
|
||||
|
||||
The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Task completion and output
|
||||
|
||||
#### What the model sees
|
||||
|
||||
None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces.
|
||||
|
||||
#### Token effect
|
||||
|
||||
None beyond the consuming tools' own results.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this service appends nothing to any model-visible sequence.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered.
|
||||
- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet.
|
||||
- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern.
|
||||
- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle.
|
||||
- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved.
|
||||
55
packages/subagent/subagent-control/package.json
Normal file
55
packages/subagent/subagent-control/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-control",
|
||||
"description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
442
packages/subagent/subagent-control/src/index.ts
Normal file
442
packages/subagent/subagent-control/src/index.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Continuable-subagent control service (`ctx.subagentControl`): stable child
|
||||
* ids, descriptor persistence and lookup by known child id, Task-backed
|
||||
* activation, and steer-or-resume message routing. The low-level
|
||||
* `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic;
|
||||
* this service owns the policy that binds one durable child session to a
|
||||
* series of disposable Task-backed activations.
|
||||
*
|
||||
* Every continuable activation — initial or resumed, parent- or human-started
|
||||
* — has exactly one Task and one result. Task settlement awaits the child
|
||||
* result, disposes the run, and only then records the outcome, so a terminal
|
||||
* Task leaves the durable child session but no live child Agent. Cancellation
|
||||
* targets the whole activation: parent and human messages that joined one
|
||||
* turn share its result and its `killed` outcome.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-control
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
subagentControl: SubagentControlService
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed error for control-service routing, authorization, and delivery failures. */
|
||||
export class SubagentControlError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'SubagentControlError'
|
||||
}
|
||||
}
|
||||
|
||||
/** What a caller asks for when starting a continuable background child. */
|
||||
export interface ContinuableStartSpec {
|
||||
/** The `ctx.subagents` provider to establish the child on. */
|
||||
readonly provider: string
|
||||
/** One-line model-facing Task label (the delegation description). */
|
||||
readonly label: string
|
||||
/**
|
||||
* The delegation request. The service resolves the stable child id and the
|
||||
* durable descriptor, then supplies the Task-owned cancellation signal and
|
||||
* `continuation` itself.
|
||||
*/
|
||||
readonly request: Omit<SubagentStartRequest, 'signal' | 'continuation'>
|
||||
}
|
||||
|
||||
/** Identities returned by {@link SubagentControlService.startContinuable}. */
|
||||
export interface ContinuableStart {
|
||||
/** The durable child session id, stable across activations. */
|
||||
readonly childId: SessionId
|
||||
/** The initial activation's Task id. */
|
||||
readonly taskId: TaskId
|
||||
}
|
||||
|
||||
/**
|
||||
* How {@link SubagentControlService.sendMessage} delivered a message:
|
||||
* `steered` joined the running activation's existing Task without creating a
|
||||
* Task of its own; `started` created a fresh Task that cold-resumes the
|
||||
* durable child with the message. Failure is an exception, never a result —
|
||||
* an undelivered message throws.
|
||||
*/
|
||||
export type SendMessageResult =
|
||||
| { readonly route: 'steered'; readonly taskId: TaskId }
|
||||
| { readonly route: 'started'; readonly taskId: TaskId }
|
||||
|
||||
/**
|
||||
* One child's current process-local activation: its Task and, after provider
|
||||
* publication, its run. Installed before any provider or persistence await
|
||||
* and removed only after run disposal and Task terminal publication. This
|
||||
* exists solely so parent and human senders can find the same activation — it
|
||||
* is not a durable catalog, admission reservation, or run-state machine.
|
||||
*/
|
||||
interface ActiveActivation {
|
||||
/** Assigned in the same synchronous frame as the install, when the Task registers. */
|
||||
taskId: TaskId | undefined
|
||||
/** Filled when the provider publishes; `undefined` while starting or resuming. */
|
||||
run: SubagentRun | undefined
|
||||
/** Resolved by the completion listener when the Task's terminal snapshot is recorded. */
|
||||
readonly terminal: PromiseWithResolvers<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: String(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
|
||||
}
|
||||
|
||||
/** 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('')
|
||||
}
|
||||
|
||||
/**
|
||||
* The continuable-subagent orchestration service. Tool schema and UI adapters
|
||||
* are consumers of this one contract: parent and human messages route through
|
||||
* {@link sendMessage} and share one activation result and cancellation
|
||||
* boundary, while foreground one-shot delegation keeps calling
|
||||
* `ctx.subagents.start()` directly.
|
||||
*/
|
||||
export class SubagentControlService extends Service {
|
||||
static inject = ['subagents', 'tasks', 'agents']
|
||||
|
||||
/** Child session id → its current activation. Process-local, never durable. */
|
||||
private activations = new Map<SessionId, ActiveActivation>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subagentControl')
|
||||
// Terminal publication is one of the two removal conditions. The exact
|
||||
// Task id pins the resolution to this activation, never a later same-child one.
|
||||
ctx.tasks.onTaskDone((snapshot) => {
|
||||
for (const activation of this.activations.values()) {
|
||||
if (activation.taskId === snapshot.id) activation.terminal.resolve()
|
||||
}
|
||||
})
|
||||
ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a continuable background child: allocate its stable session id,
|
||||
* snapshot its durable descriptor, and register the initial activation's
|
||||
* Task. A synchronous validation failure (a non-JSON descriptor input,
|
||||
* missing persistence, Task preflight) throws without creating a Task; the
|
||||
* method otherwise returns both identities immediately, without waiting for
|
||||
* child publication or descriptor durability. Asynchronous startup failure
|
||||
* settles the returned Task as `failed` (or `killed` when cancelled) after
|
||||
* any published run is disposed, which can leave an unmaterialized child id
|
||||
* that later by-id operations report as unavailable.
|
||||
* @param spec - provider, Task label, and the delegation request.
|
||||
* @returns the stable child id and the initial activation's Task id.
|
||||
*/
|
||||
startContinuable(spec: ContinuableStartSpec): ContinuableStart {
|
||||
this.requirePersistence()
|
||||
const childId = SessionId(randomUUID())
|
||||
const request = spec.request
|
||||
// Snapshot before Task creation: invalid descriptor JSON rejects the call
|
||||
// with no Task, and the detached value is what reaches the child log.
|
||||
const agentProvider = request.agentOptions?.provider ?? request.parent.options.provider
|
||||
const agentModel = request.agentOptions?.model ?? request.parent.options.model
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
provider: spec.provider,
|
||||
...agentProvider !== undefined ? { agentProvider } : {},
|
||||
...agentModel !== undefined ? { agentModel } : {},
|
||||
...request.persona !== undefined ? { persona: request.persona } : {},
|
||||
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
|
||||
})
|
||||
const taskId = this.startActivation(childId, spec.label, request.parent, signal =>
|
||||
this.ctx.subagents.start(spec.provider, {
|
||||
...request,
|
||||
signal,
|
||||
continuation: { sessionId: childId, descriptor },
|
||||
}))
|
||||
return { childId, taskId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one message to a known continuable child: steer its running
|
||||
* activation, or cold-resume the durable session into a fresh Task-backed
|
||||
* activation. The two routes are reported distinctly so timing-dependent
|
||||
* routing is observable. A throw means the message was NOT delivered — in
|
||||
* particular, losing a race with Task settlement does not fall through to
|
||||
* cold resume within the same call; a later retry after Task terminal may
|
||||
* start the next activation. The started Task owns descriptor lookup and
|
||||
* direct-parent authorization (its AbortSignal exists before that lookup),
|
||||
* so an unknown, foreign, or descriptor-less child settles the started Task
|
||||
* as `failed` with a detail reporting the id as unavailable.
|
||||
* @param parent - the live parent agent sending the message (model tool or
|
||||
* human adapter); Task access is authorized by its session id.
|
||||
* @param childId - the stable child session id.
|
||||
* @param message - the content to deliver.
|
||||
* @returns whether the message `steered` the existing Task or `started` a new one.
|
||||
*/
|
||||
sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult {
|
||||
this.assertOwnership(childId)
|
||||
const activation = this.activations.get(childId)
|
||||
if (activation !== undefined) {
|
||||
return { route: 'steered', taskId: this.steerActivation(activation, parent, childId, message) }
|
||||
}
|
||||
return { route: 'started', taskId: this.resumeActivation(parent, childId, message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous ownership compare before any by-id routing: a live registry
|
||||
* Agent outside the association — or different from the associated run's
|
||||
* agent — was started by something else. Fail instead of adopting an idle
|
||||
* Agent or attaching an untracked turn.
|
||||
*/
|
||||
private assertOwnership(childId: SessionId): void {
|
||||
const live = this.ctx.agents.get(childId)
|
||||
if (live === undefined) return
|
||||
const activation = this.activations.get(childId)
|
||||
if (activation === undefined) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`,
|
||||
'OWNERSHIP_CONFLICT',
|
||||
)
|
||||
}
|
||||
if (activation.run !== undefined && activation.run.localAgent !== live) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`,
|
||||
'OWNERSHIP_CONFLICT',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver to the running activation's Task through strict live steering. */
|
||||
private steerActivation(
|
||||
activation: ActiveActivation,
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
): TaskId {
|
||||
const taskId = activation.taskId
|
||||
/* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */
|
||||
if (taskId === undefined) {
|
||||
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
|
||||
}
|
||||
// Owner-session authorization plus the live status for the strict check.
|
||||
const snapshot = this.ctx.tasks.get(taskId, parent)
|
||||
if (snapshot.status !== 'running') {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered `
|
||||
+ '— retry after it settles to start the next activation',
|
||||
'NOT_DELIVERED',
|
||||
)
|
||||
}
|
||||
const run = activation.run
|
||||
if (run === undefined) {
|
||||
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
|
||||
}
|
||||
if (run.steer === undefined) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" provider does not accept live delivery; the message was not delivered`,
|
||||
'NOT_DELIVERED',
|
||||
)
|
||||
}
|
||||
try {
|
||||
run.steer(message)
|
||||
} catch (error: unknown) {
|
||||
// Strict steering lost the race with turn settlement. Deliberately no
|
||||
// cold-resume fallback here: that would attach the message to a turn the
|
||||
// caller did not observe.
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" stopped before delivery; the message was not delivered`,
|
||||
'NOT_DELIVERED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
return taskId
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-resume a persisted child into a fresh Task-backed activation. The
|
||||
* Task owns its `AbortController` before descriptor lookup: the load,
|
||||
* direct-parent authorization, and descriptor fold run inside the
|
||||
* activation, with cancellation rechecked after the un-signalled
|
||||
* persistence await so an early `task_kill` prevents any later child work.
|
||||
*/
|
||||
private resumeActivation(parent: Agent, childId: SessionId, message: ContentBlock[]): TaskId {
|
||||
const persistence = this.requirePersistence()
|
||||
return this.startActivation(childId, resumeLabel(message), parent, async (signal) => {
|
||||
let loaded: Awaited<ReturnType<typeof persistence.load>>
|
||||
try {
|
||||
loaded = await persistence.load(childId)
|
||||
} catch (error: unknown) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" is unavailable`,
|
||||
'NOT_RESUMABLE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
// The persistence seam takes no signal; recheck before any child work.
|
||||
if (signal.aborted) throw new SubagentControlError('subagent resume was cancelled during lookup', 'CANCELLED')
|
||||
// Authorize the persisted header before folding: only the direct parent
|
||||
// recorded at creation may continue this child.
|
||||
if (loaded.meta.parentSession !== parent.id) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" belongs to another parent session`,
|
||||
'UNAUTHORIZED',
|
||||
)
|
||||
}
|
||||
// Fold only the child's own suffix: a fork seed replays the parent's
|
||||
// log, which may carry an ANCESTOR's descriptor when the parent is
|
||||
// itself a continuable child.
|
||||
const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
|
||||
if (descriptor === undefined) {
|
||||
throw new SubagentControlError(
|
||||
`subagent "${childId}" has no supported continuation descriptor`,
|
||||
'NOT_RESUMABLE',
|
||||
)
|
||||
}
|
||||
return this.ctx.subagents.resume(descriptor.provider, {
|
||||
sessionId: childId,
|
||||
prompt: message,
|
||||
parent,
|
||||
signal,
|
||||
descriptor,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the activation association, register its Task, and bind the two
|
||||
* removal conditions. The association is installed before any persistence
|
||||
* or provider await — the producer body runs synchronously up to its first
|
||||
* await — and removed only after run disposal (the producer settled) and
|
||||
* Task terminal publication. This synchronous install admits one activation
|
||||
* per child in this process; a competing untracked publication still loses
|
||||
* at the Agent registry collision boundary inside the provider.
|
||||
*/
|
||||
private startActivation(
|
||||
childId: SessionId,
|
||||
label: string,
|
||||
owner: Agent,
|
||||
begin: (signal: AbortSignal) => Promise<SubagentRun>,
|
||||
): TaskId {
|
||||
const activation: ActiveActivation = {
|
||||
taskId: undefined,
|
||||
run: undefined,
|
||||
terminal: Promise.withResolvers<void>(),
|
||||
}
|
||||
this.activations.set(childId, activation)
|
||||
let taskId: TaskId
|
||||
try {
|
||||
taskId = this.ctx.tasks.start({
|
||||
kind: 'subagent',
|
||||
label,
|
||||
owner,
|
||||
run: (): TaskHooks => {
|
||||
const controller = new AbortController()
|
||||
const done = (async (): Promise<TaskOutcome> => {
|
||||
try {
|
||||
const run = await begin(controller.signal)
|
||||
activation.run = run
|
||||
return await settleRun(run)
|
||||
} catch (error: unknown) {
|
||||
// A pre-publication abort rejects only after the provider's
|
||||
// creation transaction rolled back to quiescence, so recording
|
||||
// `killed` here honors the settlement-after-rollback contract.
|
||||
return controller.signal.aborted
|
||||
? { status: 'killed' }
|
||||
: { status: 'failed', detail: String(error) }
|
||||
}
|
||||
})()
|
||||
void Promise.allSettled([done, activation.terminal.promise]).then(() => {
|
||||
/* v8 ignore else -- service teardown clears the map while a producer is still settling. */
|
||||
if (this.activations.get(childId) === activation) this.activations.delete(childId)
|
||||
})
|
||||
return {
|
||||
cancel: (reason?: string) => {
|
||||
// Cancellation targets the whole activation: every message that
|
||||
// joined this turn shares the `killed` outcome.
|
||||
controller.abort(reason ?? 'subagent activation killed')
|
||||
},
|
||||
done,
|
||||
// No readOutput: the child session owns intermediate detail.
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// Task preflight failed; nothing started, so the install rolls back.
|
||||
this.activations.delete(childId)
|
||||
throw error
|
||||
}
|
||||
// Same synchronous frame as the install: an observer that can run at all
|
||||
// runs after this assignment.
|
||||
activation.taskId = taskId
|
||||
return taskId
|
||||
}
|
||||
|
||||
/** Resolve the persistence service continuable children require, or fail loud. */
|
||||
private requirePersistence(): SessionPersistence {
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new SubagentControlError(
|
||||
'continuable subagents require session persistence (load a dsh-session-persistence backend)',
|
||||
'PERSISTENCE_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
return persistence
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a resumed activation's Task label from its message. */
|
||||
function resumeLabel(message: ContentBlock[]): string {
|
||||
const text = finalText(message).trim().replace(/\s+/g, ' ')
|
||||
if (text.length === 0) return 'subagent follow-up'
|
||||
return text.length > 80 ? `${text.slice(0, 79)}…` : text
|
||||
}
|
||||
|
||||
export default SubagentControlService
|
||||
32
packages/subagent/subagent-control/src/invariant.ts
Normal file
32
packages/subagent/subagent-control/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`.
|
||||
* @module @deepseek-ai/dsh-subagent-control/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-control-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the activation association is deliberately private
|
||||
* process-local routing state with no event stream of its own; the run
|
||||
* lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`,
|
||||
* and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,539 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
chunks: StreamChunk[]
|
||||
gate?: Promise<void>
|
||||
}
|
||||
|
||||
/** Adapter whose entries can hold a model call open until the test releases it. */
|
||||
class GatedAdapter extends LlmAdapter {
|
||||
constructor(private script: GatedEntry[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('GatedAdapter: script exhausted')
|
||||
if (entry.gate) await entry.gate
|
||||
for (const chunk of entry.chunks) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */
|
||||
async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (options.persistence !== false) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
}
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
await ctx.plugin(SubagentControlService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
async function setup(script: Script, options: { persistence?: boolean } = {}) {
|
||||
const adapter = new MockAdapter(script)
|
||||
const { ctx, parent } = await setupWith(adapter, options)
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
function startSpec(parent: Agent, provider = 'spawn') {
|
||||
return {
|
||||
provider,
|
||||
label: 'delegated work',
|
||||
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
|
||||
}
|
||||
}
|
||||
|
||||
async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) {
|
||||
return ctx.tasks.wait(taskId, 5_000, parent)
|
||||
}
|
||||
|
||||
function message(text: string) {
|
||||
return [{ type: 'text' as const, text }]
|
||||
}
|
||||
|
||||
describe('SubagentControlService.startContinuable', () => {
|
||||
it('returns both identities immediately; the Task settles with the child result after disposal', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
expect(started.childId).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(started.taskId).toBe('subagent-1')
|
||||
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
expect(ctx.tasks.read(started.taskId, parent).text).toBe('first answer')
|
||||
// Disposal ordering: the terminal Task leaves no live child Agent.
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('answer')])
|
||||
const seen: SessionEvent[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.id !== SessionId('parent')) seen.push(event)
|
||||
})
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor')
|
||||
const turnStartIndex = seen.findIndex(event => event.type === 'turn/start')
|
||||
const firstAssistant = seen.findIndex(event => event.type === 'assistant/message')
|
||||
expect(descriptorIndex).toBeGreaterThan(turnStartIndex)
|
||||
expect(descriptorIndex).toBeLessThan(firstAssistant)
|
||||
const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'>
|
||||
expect(descriptor.data).toEqual({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
provider: 'spawn',
|
||||
agentProvider: 'mock',
|
||||
agentModel: 'mock',
|
||||
})
|
||||
// Model-hidden: the descriptor never carries surface metadata.
|
||||
expect('surfaceOp' in descriptor).toBe(false)
|
||||
|
||||
// The durable log kept the exact control-allocated id.
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.meta.id).toBe(started.childId)
|
||||
expect(loaded.meta.parentSession).toBe(SessionId('parent'))
|
||||
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects synchronously with no Task when persistence is not configured', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')], { persistence: false })
|
||||
expect(() => ctx.subagentControl.startContinuable(startSpec(parent)))
|
||||
.toThrow(/require session persistence/)
|
||||
expect(ctx.tasks.list(parent)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-JSON descriptor input synchronously with no Task', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
const spec = startSpec(parent)
|
||||
expect(() => ctx.subagentControl.startContinuable({
|
||||
...spec,
|
||||
// A symbol survives the static ToolRestriction type only through this
|
||||
// cast — exactly the durable-boundary input the snapshot rejects.
|
||||
request: { ...spec.request, toolFilter: { deny: [Symbol('boom') as unknown as string] } },
|
||||
})).toThrow(/not losslessly JSON-serializable/)
|
||||
expect(ctx.tasks.list(parent)).toEqual([])
|
||||
})
|
||||
|
||||
it('settles the Task as failed when provider startup fails after the ids were returned', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
const spec = {
|
||||
provider: 'spawn',
|
||||
label: 'broken delegation',
|
||||
request: {
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
// The spawn provider enforces depth: parent depth 0 → child depth 1 > 0.
|
||||
maxDepth: 0,
|
||||
},
|
||||
}
|
||||
const started = ctx.subagentControl.startContinuable(spec)
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('maxDepth')
|
||||
// The unmaterialized child id is reported unavailable on later use.
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('hello?'))
|
||||
expect(followUp.route).toBe('started')
|
||||
const failed = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(failed.status).toBe('failed')
|
||||
expect(failed.detail).toContain('unavailable')
|
||||
})
|
||||
|
||||
it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => {
|
||||
const { ctx, parent } = await setup(['hang'])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
// Let the child publish and begin its turn.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
expect(ctx.agents.get(started.childId)).toBeDefined()
|
||||
expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested')
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('killed')
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentControlService.sendMessage', () => {
|
||||
it('steers a running activation into the existing Task without creating a second Task', async () => {
|
||||
// Hold the child's first model call open so the child is observably
|
||||
// running when the message arrives; the steered content then drives a
|
||||
// second step in the SAME turn.
|
||||
let releaseFirst!: () => void
|
||||
const gate = new Promise<void>((resolve) => { releaseFirst = resolve })
|
||||
const { ctx, parent } = await setupWith(new GatedAdapter([
|
||||
{ chunks: textResponse('first step answer'), gate },
|
||||
{ chunks: textResponse('steered turn answer') },
|
||||
]))
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
// Wait for the child agent to publish and enter running.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (ctx.agents.get(started.childId)?.status === 'running') {
|
||||
clearInterval(timer)
|
||||
resolve()
|
||||
}
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y'))
|
||||
expect(delivered).toEqual({ route: 'steered', taskId: started.taskId })
|
||||
releaseFirst()
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
// Exactly one Task exists: steering created none.
|
||||
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId])
|
||||
// The steered content joined the SAME child turn and drove another step.
|
||||
const output = ctx.tasks.read(started.taskId, parent)
|
||||
expect(output.text).toBe('steered turn answer')
|
||||
})
|
||||
|
||||
it('cold-resumes a settled child into a fresh Task and reports `started`', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('and then?'))
|
||||
expect(followUp.route).toBe('started')
|
||||
expect(followUp.taskId).not.toBe(started.taskId)
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
expect(ctx.tasks.read(followUp.taskId, parent).text).toBe('second answer')
|
||||
// Fresh activation disposed again: durable child, no live Agent.
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
|
||||
// The durable transcript accumulated BOTH activations' turns.
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message')
|
||||
expect(userMessages.map(event => (event.data.content[0] as { text: string }).text))
|
||||
.toEqual(['child task', 'and then?'])
|
||||
})
|
||||
|
||||
it('reconstructs the declared composition on cold resume', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const spec = {
|
||||
provider: 'spawn',
|
||||
label: 'scoped delegation',
|
||||
request: {
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
persona: 'You are the resumable child.',
|
||||
toolFilter: { deny: [] as string[] },
|
||||
},
|
||||
}
|
||||
const started = ctx.subagentControl.startContinuable(spec)
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
const descriptor = loaded.events.find((event): event is SessionEvent<'subagent/descriptor'> => event.type === 'subagent/descriptor')
|
||||
expect(descriptor?.data.persona).toBe('You are the resumable child.')
|
||||
expect(descriptor?.data.toolFilter).toEqual({ deny: [] })
|
||||
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('continue'))
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
// The resumed child's system prompt carried the persona back.
|
||||
const resumed = await ctx.sessionPersistence.load(started.childId)
|
||||
const headers = resumed.events.filter((event): event is SessionEvent<'request/header'> => event.type === 'request/header')
|
||||
expect(headers.at(-1)?.data.header.system).toContain('You are the resumable child.')
|
||||
})
|
||||
|
||||
it('fork children resume from their own transcript without re-forking parent history', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('parent turn one'),
|
||||
textResponse('fork first answer'),
|
||||
textResponse('parent turn two'),
|
||||
textResponse('fork second answer'),
|
||||
])
|
||||
parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork'))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const firstLoad = await ctx.sessionPersistence.load(started.childId)
|
||||
const seedLength = firstLoad.meta.seedLength ?? 0
|
||||
expect(seedLength).toBeGreaterThan(0)
|
||||
|
||||
// The parent gains NEW history the resume must not re-fork.
|
||||
parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up'))
|
||||
await waitTerminal(ctx, followUp.taskId, parent)
|
||||
const resumed = await ctx.sessionPersistence.load(started.childId)
|
||||
// The persisted seed boundary is unchanged and parent turn two is absent.
|
||||
expect(resumed.meta.seedLength).toBe(seedLength)
|
||||
const texts = resumed.events
|
||||
.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message')
|
||||
.map(event => (event.data.content[0] as { text: string }).text)
|
||||
expect(texts).toContain('parent question one')
|
||||
expect(texts).not.toContain('parent question two')
|
||||
})
|
||||
|
||||
it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('go on'))
|
||||
|
||||
const childAgents: Agent[] = []
|
||||
const stop = ctx.on('agent/created', (agent: Agent) => {
|
||||
if (agent.id === started.childId) childAgents.push(agent)
|
||||
})
|
||||
await waitTerminal(ctx, followUp.taskId, parent)
|
||||
stop()
|
||||
// The resumed runtime options carry no depth, so the header keeps the floor.
|
||||
const resumedChild = childAgents.at(-1)
|
||||
expect(resumedChild).toBeDefined()
|
||||
expect(resumedChild!.session.header.delegationDepth).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')])
|
||||
const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' })
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(otherParent))
|
||||
await waitTerminal(ctx, started.taskId, otherParent)
|
||||
|
||||
const attempt = ctx.subagentControl.sendMessage(parent, started.childId, message('mine now'))
|
||||
expect(attempt.route).toBe('started')
|
||||
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('another parent session')
|
||||
})
|
||||
|
||||
it('rejects a persisted child with no descriptor as not resumable', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('plain child')])
|
||||
// A plain (non-continuable) child session persisted under this parent.
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('plain-child'),
|
||||
meta: { parentSession: parent.id, delegationDepth: 1 },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.followup(createUserMessage({ content: message('do something'), source: { kind: 'user' } }))
|
||||
await handle.agent.whenIdle()
|
||||
await handle.dispose()
|
||||
|
||||
const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?'))
|
||||
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('continuation descriptor')
|
||||
})
|
||||
|
||||
it('rejects delivery to a live agent outside control-service ownership', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
// A live child created around the control service.
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('rogue-child'),
|
||||
meta: { parentSession: parent.id },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello')))
|
||||
.toThrow(SubagentControlError)
|
||||
expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello')))
|
||||
.toThrow(/outside control-service ownership.*not delivered/)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('does not fall through to cold resume when strict steering loses the settlement race', async () => {
|
||||
// Deterministic race: hold run disposal open so the association still
|
||||
// names a run whose child turn has already ended.
|
||||
const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')])
|
||||
let releaseDispose!: () => void
|
||||
const disposeGate = new Promise<void>((resolve) => { releaseDispose = resolve })
|
||||
const realStart = ctx.subagents.start.bind(ctx.subagents)
|
||||
ctx.subagents.start = async (name, request) => {
|
||||
const run = await realStart(name, request)
|
||||
const realDispose = run.dispose.bind(run)
|
||||
return {
|
||||
...run,
|
||||
...run.steer !== undefined ? { steer: run.steer.bind(run) } : {},
|
||||
dispose: async () => {
|
||||
await disposeGate
|
||||
return realDispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
// Wait for the child to finish its turn while the run remains undisposed
|
||||
// and the association therefore still holds.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
const child = ctx.agents.get(started.childId)
|
||||
if (child !== undefined && child.status === 'idle'
|
||||
&& child.session.events.some(event => event.type === 'turn/end')) {
|
||||
clearInterval(timer)
|
||||
resolve()
|
||||
}
|
||||
}, 5)
|
||||
})
|
||||
|
||||
// Strict steering finds the settled child, fails loud, and does NOT start
|
||||
// a cold resume within this call.
|
||||
expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('too late?')))
|
||||
.toThrow(/not delivered/)
|
||||
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId])
|
||||
releaseDispose()
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
// AFTER the Task settles, retry legitimately starts the next activation.
|
||||
const retry = ctx.subagentControl.sendMessage(parent, started.childId, message('retry'))
|
||||
expect(retry.route).toBe('started')
|
||||
await waitTerminal(ctx, retry.taskId, parent)
|
||||
})
|
||||
|
||||
it('each follow-up Task result is fenced to the parent session', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('more'))
|
||||
const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
|
||||
expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/)
|
||||
})
|
||||
|
||||
it('kills a cold-resume activation during descriptor lookup without starting child work', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
// Make the persistence load hang until the kill lands.
|
||||
const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
|
||||
let releaseLoad!: () => void
|
||||
const gate = new Promise<void>((resolve) => { releaseLoad = resolve })
|
||||
ctx.sessionPersistence.load = async (id) => {
|
||||
await gate
|
||||
return realLoad(id)
|
||||
}
|
||||
|
||||
const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up'))
|
||||
expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested')
|
||||
releaseLoad()
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(snapshot.status).toBe('killed')
|
||||
// Cancellation during lookup prevented any child publication.
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')])
|
||||
const started = ctx.subagentControl.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
|
||||
let releaseLoad!: () => void
|
||||
const gate = new Promise<void>((resolve) => { releaseLoad = resolve })
|
||||
ctx.sessionPersistence.load = async (id) => {
|
||||
await gate
|
||||
return realLoad(id)
|
||||
}
|
||||
|
||||
const first = ctx.subagentControl.sendMessage(parent, started.childId, message('first follow-up'))
|
||||
expect(first.route).toBe('started')
|
||||
// The association is installed synchronously, so the competing caller
|
||||
// observes the pending activation instead of starting a duplicate resume.
|
||||
expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('second follow-up')))
|
||||
.toThrow(/not delivered/)
|
||||
releaseLoad()
|
||||
const snapshot = await waitTerminal(ctx, first.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
// Exactly one follow-up Task was created.
|
||||
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId, first.taskId])
|
||||
})
|
||||
})
|
||||
|
||||
describe('outcome mapping helpers', () => {
|
||||
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
|
||||
const output = [{ type: 'text' as const, text: 'partial' }]
|
||||
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
|
||||
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
|
||||
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
|
||||
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
|
||||
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
|
||||
// Merge-extensible: an unknown reason is failed-with-detail, never success.
|
||||
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
|
||||
})
|
||||
|
||||
it('settleRun disposes the run before reporting, on both result paths', async () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: SessionId('child-1'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
order.push('reported')
|
||||
expect(completed).toEqual({ status: 'completed', output: 'ok' })
|
||||
expect(order).toEqual(['dispose', 'reported'])
|
||||
|
||||
// An infrastructure rejection still disposes and reports failed.
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: SessionId('child-2'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
|
||||
expect(disposed).toBe(true)
|
||||
|
||||
const disposeFailed = await settleRun({
|
||||
id: SessionId('child-3'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
|
||||
|
||||
const bothFailed = await settleRun({
|
||||
id: SessionId('child-4'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('result failed')),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
expect(bothFailed).toEqual({
|
||||
status: 'failed',
|
||||
detail: 'Error: result failed; dispose failed: Error: reap failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
39
packages/subagent/subagent-control/tsconfig.json
Normal file
39
packages/subagent/subagent-control/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user