feat(subagent): durable child catalog and list_agents

Implements the durable-subagent-catalog RFC: SubagentControlService.listChildren()
enumerates a parent's direct continuable children from one sessionQuery trace,
validates each child's sole subagent/descriptor event (now carrying the durable
creation label), and returns one ordered SubagentListEntry[] with per-child
corrupt/unsupported/unavailable diagnostics. The list_agents tool ships as a
separately loadable plugin of dsh-tool-subagent-control requiring sessionQuery
at load; send_message stays usable without it.
This commit is contained in:
Dudu-0223
2026-07-26 02:32:34 +08:00
committed by Tianyi Cui
parent c7acc8fc6c
commit 4240c7dd7b
55 changed files with 1128 additions and 94 deletions

View File

@@ -896,6 +896,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>',
jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */',
},
{
signature: 'async listChildren(parentSessionId: SessionId): Promise<SubagentListEntry[]>',
jsDoc: '/**\n * Enumerate one session\'s direct continuable children from the durable,\n * live-preferred corpus without loading or resuming an Agent. The lineage\n * trace supplies stable candidate order and live status; each candidate is\n * then inspected independently for exactly one supported descriptor in its\n * own suffix.\n * @param parentSessionId - parent whose direct children are listed.\n * @returns child and diagnostic entries in lineage-trace order.\n */',
},
{
signature: 'registerProvider(provider: SubagentProvider): () => void',
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',
@@ -1809,7 +1813,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ContinuableStartSpec',
declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly request: Omit<SubagentStartRequest, \'signal\' | \'outputSchema\'>;\n readonly signal: AbortSignal;\n}',
declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit<SubagentStartRequest, \'signal\' | \'outputSchema\'>;\n readonly signal: AbortSignal;\n}',
},
{
name: 'CreateAgentOptions',
@@ -2691,6 +2695,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentFollowupOptions',
declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}',
},
{
name: 'SubagentListEntry',
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly label: string;\n readonly status: \'running\' | \'complete\';\n} | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
},
{
name: 'SubagentProvider',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\n}',

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -34,6 +34,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -42,6 +43,9 @@
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-session-query": {
"optional": true
},
"@deepseek-ai/dsh-tasks": {
"optional": true
}
@@ -54,6 +58,7 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -59,6 +59,8 @@ declare module '@deepseek-ai/dsh-llm' {
export interface ContinuableStartSpec {
/** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
readonly provider: string
/** The initial delegation's short `description`, persisted as the child's creation label. */
readonly label: string
/**
* The delegation request. The manager reserves the stable child id, resolves
* the durable descriptor, and composes the child itself.
@@ -297,6 +299,7 @@ export class SubagentContinuationManager {
const agentModel = request.agentOptions?.model ?? parent.options.model
const descriptor = snapshotSubagentDescriptor({
provider: spec.provider,
label: spec.label,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...request.persona !== undefined ? { persona: request.persona } : {},

View File

@@ -47,6 +47,12 @@ export interface SubagentDescriptorData {
readonly version: number
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
/**
* The initial delegation's short `description`, kept as the child's durable
* creation label so enumeration can identify the conversation without
* replaying parent tool results or exposing the child prompt.
*/
readonly label: string
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
@@ -61,6 +67,8 @@ export interface SubagentDescriptorData {
export interface SubagentDescriptorInput {
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
/** The initial delegation's short `description`, the durable creation label. */
readonly label: string
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
@@ -74,6 +82,7 @@ export interface SubagentDescriptorInput {
const DESCRIPTOR_KEYS = new Set([
'version',
'provider',
'label',
'agentProvider',
'agentModel',
'persona',
@@ -151,6 +160,10 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
}
const label = value['label']
if (typeof label !== 'string') {
throw new Error('persisted subagent descriptor label must be a string')
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
@@ -160,6 +173,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider,
label,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...persona !== undefined ? { persona } : {},
@@ -180,6 +194,7 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba
const candidate: SubagentDescriptorData = {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: input.provider,
label: input.label,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},

View File

@@ -36,6 +36,11 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
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 {
assertSessionHeadersCompatible,
SessionQueryError,
} from '@deepseek-ai/dsh-session-query'
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
@@ -47,6 +52,7 @@ import type {
SubagentStartRequest,
} from './types.ts'
import { SubagentError } from './error.ts'
import { foldSubagentDescriptor } from './descriptor.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
@@ -96,6 +102,28 @@ export type {
} from './continuation.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
/**
* One direct-child enumeration result. Descriptor-less ordinary children are
* omitted; a per-child inspection failure remains visible as a diagnostic.
*/
export type SubagentListEntry =
| {
readonly kind: 'child'
/** Durable child session id, stable across Activations. */
readonly id: SessionId
/** Durable creation label from the child's descriptor. */
readonly label: string
/** Whether the child is currently live or exists only in persistence. */
readonly status: 'running' | 'complete'
}
| {
readonly kind: 'diagnostic'
/** Traced candidate session id. */
readonly id: SessionId
/** Fixed reason the candidate could not be returned as a child. */
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -218,6 +246,80 @@ export class SubagentService extends Service {
await manager.drainDescendants(parents)
}
/**
* Enumerate one session's direct continuable children from the durable,
* live-preferred corpus without loading or resuming an Agent. The lineage
* trace supplies stable candidate order and live status; each candidate is
* then inspected independently for exactly one supported descriptor in its
* own suffix.
* @param parentSessionId - parent whose direct children are listed.
* @returns child and diagnostic entries in lineage-trace order.
*/
async listChildren(parentSessionId: SessionId): Promise<SubagentListEntry[]> {
const query = this.ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
'listing subagents requires session query (load a dsh-session-query backend)',
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
)
}
const trace = await query.traceSession(parentSessionId)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
const entry = await this.inspectChild(query, parentSessionId, node.session)
if (entry !== undefined) entries.push(entry)
}
return entries
}
/** Inspect one traced candidate without materializing its Agent. */
private async inspectChild(
query: SessionQueryService,
parentSessionId: SessionId,
candidate: SessionRecord,
): Promise<SubagentListEntry | undefined> {
const childId = candidate.header.id
try {
const records = await query.listEvents(childId)
// Fork seeds replay ancestor events, so only this child's suffix owns its descriptor.
const seedLength = candidate.header.seedLength ?? 0
const descriptorSeqs = records
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
.map(record => record.seq)
if (descriptorSeqs.length === 0) return undefined
if (descriptorSeqs.length > 1) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
// The length-one branch proves this index exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const seq = descriptorSeqs[0]!
const window = await query.readEvent({ sessionId: childId, seq })
assertSessionHeadersCompatible(window.session, candidate.header)
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
let descriptor: ReturnType<typeof foldSubagentDescriptor>
try {
descriptor = foldSubagentDescriptor([window.target])
} catch {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
if (descriptor === undefined) {
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
}
return {
kind: 'child',
id: childId,
label: descriptor.label,
status: candidate.live ? 'running' : 'complete',
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error)
if (reason === undefined) throw error
return { kind: 'diagnostic', id: childId, reason }
}
}
/**
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that
@@ -349,3 +451,19 @@ export class SubagentService extends Service {
}
export default SubagentService
/** Map isolated session-query failures to the fixed child diagnostic taxonomy. */
function perChildDiagnosticReason(error: unknown): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':
return 'unavailable'
case 'SESSION_QUERY_INVALID_SURFACE':
case 'SESSION_QUERY_SOURCE_CONFLICT':
return 'corrupt'
default:
return undefined
}
}

View File

@@ -88,6 +88,7 @@ const testSignal = new AbortController().signal
function startSpec(parent: Agent, provider = 'spawn', signal: AbortSignal = testSignal) {
return {
provider,
label: 'child task',
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
signal,
}

View File

@@ -0,0 +1,402 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import SubagentService, {
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
} from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the continuable stack plus a concrete session-query service. */
async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
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' })
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
const testSignal = new AbortController().signal
/** Start one continuable child through the real service path and await Activation release. */
async function startChild(
ctx: Context,
parent: ReturnType<Context['agentLoop']['create']>,
label: string,
): Promise<SessionId> {
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label,
request: { prompt: [{ type: 'text', text: `task: ${label}` }], parent },
signal: testSignal,
})
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)).toBeUndefined()
}, { timeout: 5_000 })
return started.childId
}
/** Author one persisted child session directly against the persistence backend. */
async function authorChild(
ctx: Context,
id: string,
header: Partial<SessionHeader>,
events: SessionEvent[],
): Promise<SessionId> {
const sessionId = SessionId(id)
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
...header,
})
await ctx.sessionPersistence.append(sessionId, events)
return sessionId
}
/** Minimal complete-turn child log with one descriptor payload. */
function childEvents(descriptor: unknown): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptor },
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
}
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
return { version, provider: 'spawn', label }
}
describe('SubagentService.listChildren', () => {
it('fails loud before any work when session query is not loaded', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
)
})
it('lists a persisted continuable child as complete with its durable label', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'summarize the doc')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'summarize the doc', status: 'complete' },
])
})
it('accepts a persisted (non-live) parent target after restart', async () => {
const { ctx } = await setup([])
// A parent that exists only in persistence — the restart shape.
const coldParent = SessionId('00000000-0000-4000-8000-00000000cccc')
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: coldParent,
createdAt: 1,
})
await ctx.sessionPersistence.append(coldParent, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', {
parentSession: coldParent,
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'persisted parent case', status: 'complete' },
])
})
it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => {
const { ctx, parent } = await setup([])
// Authored headers pin the ordering key deterministically: same createdAt
// ties break on id, different createdAt orders ascending.
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
parentSession: parent.id,
createdAt: 9,
}, childEvents(descriptorPayload('late child')))
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
parentSession: parent.id,
createdAt: 5,
}, childEvents(descriptorPayload('tie b')))
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
parentSession: parent.id,
createdAt: 5,
}, childEvents(descriptorPayload('tie a')))
// An ordinary session fork shares parentSession but has no descriptor.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
})
it('reports a live child as running while keeping settled siblings complete', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const settled = await startChild(ctx, parent, 'settled child')
// A live child session outside persistence: publish a live session with a
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'child', id: settled, label: 'settled child', status: 'complete' })
expect(entries).toContainEqual({ kind: 'child', id: liveId, label: 'live child', status: 'running' })
})
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const healthy = await startChild(ctx, parent, 'healthy sibling')
const events = childEvents(descriptorPayload('twice'))
events.splice(3, 0, {
type: 'subagent/descriptor',
seq: 3,
time: 3,
data: descriptorPayload('twice again'),
} as SessionEvent)
events[4] = { ...events[4]!, seq: 4 }
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
parentSession: parent.id,
}, events)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', status: 'complete' })
})
it('diagnoses an invalid child event surface as corrupt', async () => {
const { ctx, parent } = await setup([])
// The surface-eligible user/message lacks its required surfaceOp, so the
// per-child listEvents fold fails with SESSION_QUERY_INVALID_SURFACE.
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
parentSession: parent.id,
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
},
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
] as SessionEvent[])
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
})
it('diagnoses a malformed descriptor payload as corrupt', async () => {
const { ctx, parent } = await setup([])
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 7 }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
})
it('diagnoses an unknown descriptor version as unsupported', async () => {
const { ctx, parent } = await setup([])
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
parentSession: parent.id,
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
})
it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
const { ctx, parent } = await setup([])
// A fork child whose seed replays a parent log containing a descriptor:
// the seed's descriptor is the ANCESTOR's, not this child's.
const seed = childEvents(descriptorPayload('ancestor label'))
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
parentSession: parent.id,
seedLength: seed.length,
}, seed)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([])
})
it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
const { ctx, parent } = await setup([])
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'not-mounted', label: 'orphan provider' }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: foreign, label: 'orphan provider', status: 'complete' },
])
})
it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'flaky storage')
const query = ctx.get('sessionQuery')!
const originalListEvents = query.listEvents.bind(query)
query.listEvents = (sessionId) => {
if (sessionId === childId) {
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
}
return originalListEvents(sessionId)
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('maps a mid-scan disappearance to unavailable', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'vanishing child')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'reparented child')
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
query.readEvent = async (request) => {
const window = await originalReadEvent(request)
return {
...window,
session: { ...window.session, parentSession: SessionId('someone-else') },
}
}
const entries = await ctx.subagents.listChildren(parent.id)
// The exact read's conflicting immutable header is per-child corruption.
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'shifted log')
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
query.readEvent = async (request) => {
const window = await originalReadEvent(request)
return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('fails the whole call when the initial trace fails', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never listed')
const query = ctx.get('sessionQuery')!
query.traceSession = () =>
Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
)
})
it('propagates an unrecognized per-child failure as an operation failure', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'strange failure')
const query = ctx.get('sessionQuery')!
query.listEvents = () => Promise.reject(new Error('not a query failure'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
})
it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'misconfigured query')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
)
})
it('lists compacted and uncompacted children identically', async () => {
const { ctx, parent } = await setup([])
const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', {
parentSession: parent.id,
createdAt: 1,
}, childEvents(descriptorPayload('twin child')))
// The compacted twin: a compaction checkpoint replaces the whole surface,
// while the append-only log retains the model-hidden descriptor event.
const compactedEvents = childEvents(descriptorPayload('twin child'))
compactedEvents.push({
type: 'user/message',
seq: 4,
time: 5,
data: createUserMessage({
content: [{ type: 'text', text: 'summary of everything' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
})
const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', {
parentSession: parent.id,
createdAt: 2,
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: plain, label: 'twin child', status: 'complete' },
{ kind: 'child', id: compacted, label: 'twin child', status: 'complete' },
])
})
it('excludes grandchildren: only direct descendants are candidates', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
parentSession: childId,
}, childEvents(descriptorPayload('grandchild')))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'direct child', status: 'complete' },
])
})
it('returns an empty array for a parent with no children', async () => {
const { ctx, parent } = await setup([])
await ctx.sessions.flush(parent.session)
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
})
it('SubagentError from listChildren is typed with its stable code', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
expect(caught).toBeInstanceOf(SubagentError)
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
})
})

View File

@@ -130,6 +130,7 @@ describe('SubagentService', () => {
const { subagents } = await service()
await expect(subagents.startContinuable({
provider: 'unused',
label: 'unused child',
request: baseRequest(),
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
@@ -298,15 +299,17 @@ describe('subagent descriptors', () => {
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ provider: 'spawn' })
const minimal = snapshotSubagentDescriptor({ provider: 'spawn', label: 'child work' })
expect(minimal).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
label: 'child work',
})
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
const complete = {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
label: 'complete child',
agentProvider: 'deepseek',
agentModel: 'chat',
persona: 'reviewer',
@@ -314,6 +317,7 @@ describe('subagent descriptors', () => {
}
expect(snapshotSubagentDescriptor({
provider: complete.provider,
label: complete.label,
agentProvider: complete.agentProvider,
agentModel: complete.agentModel,
persona: complete.persona,
@@ -321,16 +325,17 @@ describe('subagent descriptors', () => {
})).toEqual(complete)
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }),
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { allow: ['read'] } }),
])).toMatchObject({ toolFilter: { allow: ['read'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { deny: ['bash'] } }),
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { deny: ['bash'] } }),
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
])).toBeUndefined()
expect(() => snapshotSubagentDescriptor({
provider: 'spawn',
label: 'bad',
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
})).toThrow('not losslessly JSON-serializable')
})
@@ -343,15 +348,17 @@ describe('subagent descriptors', () => {
['string version', { version: '1', provider: 'spawn' }, 'version must be a number'],
['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'],
['missing provider', { version: 1 }, 'provider must be a string'],
['missing label', { version: 1, provider: 'spawn' }, 'label must be a string'],
['invalid label', { version: 1, provider: 'spawn', label: 7 }, 'label must be a string'],
['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'],
['invalid agent provider', { version: 1, provider: 'spawn', agentProvider: 7 }, 'agentProvider must be a string'],
['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'],
['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'],
['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'],
['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
['invalid agent provider', { version: 1, provider: 'spawn', label: 'l', agentProvider: 7 }, 'agentProvider must be a string'],
['invalid agent model', { version: 1, provider: 'spawn', label: 'l', agentModel: [] }, 'agentModel must be a string'],
['invalid persona', { version: 1, provider: 'spawn', label: 'l', persona: {} }, 'persona must be a string'],
['non-object tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: [] }, 'toolFilter must be an object'],
['unknown tool-filter field', { version: 1, provider: 'spawn', label: 'l', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
['empty tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
['non-array allow list', { version: 1, provider: 'spawn', label: 'l', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
['non-string deny item', { version: 1, provider: 'spawn', label: 'l', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => {
expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail)
})

View File

@@ -29,6 +29,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../tasks/tasks"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md
README.md: 5023862cba39769248a9f6cbe935d6397df39266
README.zh.md: a5812704609edd38aedc344b4c64044fbf32c8a8
README.md: 1fe0e49006d12b95e6c6e38cab7895238b3d3c98
README.zh.md: 9d2d46888b0790548df35cb54613ee2334143db7

View File

@@ -2,10 +2,12 @@
English | [中文](README.zh.md)
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents` and additionally requires `sessionQuery` at load. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work.
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered.
`list_agents` takes no arguments, derives the parent id from the calling agent, and renders `ctx.subagents.listChildren()`'s complete entry array without a cursor. It is discovery only: durable identity comes from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
## Model Experience
### Tool schema
@@ -36,7 +38,23 @@ One short acknowledgement per call; the child's response never returns through t
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Listing result
#### What the model sees
One line per entry in the trace's stable order: `<id> [<status>] — <label>` for a child (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`), and `(no subagents)` for an empty result. Diagnostics never expose descriptor contents.
#### Token effect
Grows linearly with the parent's direct continuable children; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
#### KV Cache effect
Append-only; each result follows the reusable request prefix.
## Known Limitations and Deferred Work
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work on that turn lands in the durable child Session, read by its subagent id, and is neither delivered back nor collected through this tool.
- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it.
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease.
- **No pagination or deletion** — the complete stably ordered set is returned, and persisted children remain listed for as long as their sessions remain in persistence; a service-level bound or delete operation is a later product decision.

View File

@@ -2,10 +2,12 @@
[English](README.md) | 中文
可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包package只注册一共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载工具不会决定委派工具是否启动可继续工作。
可选的全局具名 `send_message` `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,并在加载时额外要求 `sessionQuery`。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent智能体的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
`list_agents` 不接受参数,从调用 Agent 推导 parent id并在没有 cursor 的情况下渲染 `ctx.subagents.listChildren()` 的完整条目数组。它只负责发现:持久化身份来自每个 child 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
## 模型体验
### 工具 schema
@@ -36,7 +38,23 @@
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
### 列表结果
#### 模型看到的内容
每个条目按追踪结果的稳定顺序占一行child 使用 `<id> [<status>] — <label>``running` 表示逻辑会话存活,`complete` 表示只存在于持久化存储中且可由 `send_message` 恢复),无法读取的候选使用 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`),空结果使用 `(no subagents)`。Diagnostic 绝不暴露描述符内容。
#### Token 影响
随 parent 的直接可继续 child 数量线性增长;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
#### KV Cache 影响
仅追加;每个结果都位于可复用请求前缀之后。
## 已知限制与延期工作
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 在该轮次的工作会落入持久化子 agent Session按其 subagent id 读取,既不会回传,也不会通过本工具收集。
- **不对当前轮次进行 steering**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
- **列表是快照,而非投递承诺**它可能与发布、dispose 或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child跨进程准确性需要共享租约。
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-subagent-control",
"description": "Globally named send_message tool over ctx.subagents continuations",
"description": "Globally named send_message and list_agents tools over ctx.subagents continuations",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./list-agents": {
"types": "./lib/types/list-agents.d.ts",
"default": "./lib/types/list-agents.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -30,6 +35,7 @@
"@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-query": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -43,9 +49,10 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,76 @@
/**
* The globally named `list_agents` tool: a thin model-facing adapter over
* `ctx.subagents.listChildren()`. It is separately loadable from the
* root `send_message` plugin because it additionally requires the session
* query service — a deployment may use `send_message` without loading session
* query, and this plugin catches that misconfiguration at load.
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent-list-agents'
export const inject = ['tools', 'subagents', 'sessionQuery']
/**
* Register the `list_agents` tool.
* @param ctx - context carrying the tool registry, subagent service, and session query.
*/
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'list_agents',
description:
'List your background subagents: every subagent you started that can receive `send_message`, '
+ 'whether it is still working (running) or has finished its current turn (complete — a follow-up '
+ 'message starts a new turn on the same conversation). Children that could not be read are '
+ 'reported as diagnostics instead of being silently dropped.',
parameters: {},
output: {
schema: {
type: 'array',
items: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, enum: ['child'] },
id: { type: 'string', required: true },
label: { type: 'string', required: true },
status: { type: 'string', required: true, enum: ['running', 'complete'] },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, enum: ['diagnostic'] },
id: { type: 'string', required: true },
reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
},
},
],
},
},
render: (_args, entries) => [{
type: 'text',
text: entries.length === 0
? '(no subagents)'
: entries.map(entry => entry.kind === 'child'
? `${entry.id} [${entry.status}] — ${entry.label}`
: `${entry.id} [diagnostic: ${entry.reason}]`).join('\n'),
}],
},
async execute(_args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers have no session whose children could be listed.
throw new Error('list_agents requires a calling agent (exec.agent was undefined)')
}
return await ctx.subagents.listChildren(parent.id)
},
}))
}

View File

@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
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 JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
import * as tool from '../src/list-agents.ts'
const testToolSignal = new AbortController().signal
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
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(TestSessionQueryService)
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
let calls = 0
function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++calls}`),
name,
arguments: args,
...agent !== undefined ? { agent: agent as never } : {},
})
}
/** Wait until a continuable child released its current Activation. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
expect(ctx.agents.get(childId)).toBeUndefined()
}, { timeout: 5_000 })
}
describe('dsh-tool-subagent-control/list-agents', () => {
it('registers list_agents once, globally, with no parameters', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props)).toEqual([])
expect(schemas[0]!.description).toContain('send_message')
})
it('renders the empty result as (no subagents)', async () => {
const { ctx, parent } = await setup([])
await ctx.sessions.flush(parent.session)
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe('(no subagents)')
})
it('renders children and diagnostics in array order with the fixed text forms', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'real child',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
// Pin the render deterministically past the service: the tool is a thin
// adapter, so its fixed text forms are what this test pins.
const entries: SubagentListEntry[] = [
{ kind: 'child', id: started.childId, label: 'real child', status: 'complete' },
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
]
ctx.subagents.listChildren = () => Promise.resolve(entries)
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
`${started.childId} [complete] — real child\nbroken-child [diagnostic: corrupt]`,
)
})
it('lists a real settled child end-to-end with its durable label', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'summarize the doc',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`${started.childId} [complete] — summarize the doc`)
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'list_agents', {})
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
it('unregisters with its plugin fiber (HMR safety)', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(TestSessionQueryService)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
})
it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-list-agents')
expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
expect(typeof tool.apply).toBe('function')
})
})

View File

@@ -82,6 +82,7 @@ describe('dsh-tool-subagent-control', () => {
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child task',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})
@@ -109,6 +110,7 @@ describe('dsh-tool-subagent-control', () => {
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'long work',
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
signal: testToolSignal,
})
@@ -144,6 +146,7 @@ describe('dsh-tool-subagent-control', () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child task',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})

View File

@@ -26,6 +26,9 @@
{
"path": "../subagent"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../support/invariants"
}

View File

@@ -299,6 +299,7 @@ export function apply(ctx: Context, config: Config): void {
// 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,
})

View File

@@ -32,6 +32,9 @@
{
"path": "../../util/brand"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../support/invariants"
}