fix(subagent): identify lifecycle runs explicitly

This commit is contained in:
Tianyi Cui
2026-07-18 14:26:14 +08:00
parent 5af8b38b36
commit 0f85ae0a9d
26 changed files with 253 additions and 93 deletions

View File

@@ -50,9 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -31,6 +32,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -28,6 +28,7 @@
* @module @deepseek-ai/dsh-subagent
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
@@ -43,7 +44,9 @@ import type {
SubagentRun,
SubagentStartRequest,
} from './types.ts'
import { SubagentRunId } from './types.ts'
export { SubagentRunId } from './types.ts'
export type {
SubagentCapabilities,
SubagentProvider,
@@ -112,18 +115,26 @@ declare module 'cordis' {
/** Observe-only identifying detail for a ready subagent run. */
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Whether the provider exposed an exact published in-process child. */
readonly local: boolean
}
/** Observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The provider that ran it. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Whether the provider exposed an exact published in-process child. */
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
@@ -208,22 +219,28 @@ export class SubagentService extends Service {
const parent = request.parent
const run = await provider.start(request)
const runId = SubagentRunId(randomUUID())
const lifecycleIdentity = {
runId,
provider: name,
id: run.id,
local: run.localAgent !== undefined,
}
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
this.emitLifecycle('subagent/end', {
provider: name,
id: run.id,
...lifecycleIdentity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
}, parent)
},
() => {
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent)
},
)
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
this.emitLifecycle('subagent/start', lifecycleIdentity, parent)
return run
}

View File

@@ -7,10 +7,23 @@
*/
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
/**
* Brand a string as a {@link SubagentRunId}.
* @param id - the raw id string (the service mints UUIDs; tests may pass fixtures).
* @returns the same string, branded.
*/
export function SubagentRunId(id: string): SubagentRunId {
return id as SubagentRunId
}
/**
* Which START-TIME features a provider supports. Checked by the service before delegating to
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
@@ -139,6 +152,12 @@ export interface SubagentRun {
* remote provider mints an id unique in the parent namespace.
*/
readonly id: SessionId
/**
* The exact published in-process child, or `undefined` for a remote run.
* When present, its id is {@link id}; the provider retains no ownership
* implication beyond the run's ordinary {@link dispose} contract.
*/
readonly localAgent: Agent | undefined
/**
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport

View File

@@ -48,6 +48,7 @@ class StubProvider implements SubagentProvider {
this.startCount += 1
return {
id: SessionId(`child:${this.name}:${request.parent.id}`),
localAgent: undefined,
result: Promise.resolve(this.outcome),
async dispose() {},
}
@@ -137,13 +138,14 @@ describe('SubagentService', () => {
const parent = fakeParent('delegator')
const events: string[] = []
const keys: unknown[] = []
ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
const runIds: string[] = []
ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
const starting = subagents.start('deferred', baseRequest({ parent }))
await Promise.resolve()
expect(events).toEqual([])
ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} })
ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} })
const run = await starting
expect(events).toEqual(['start'])
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
@@ -151,6 +153,21 @@ describe('SubagentService', () => {
await Promise.resolve()
expect(events).toEqual(['start', 'end'])
expect(keys).toEqual([parent, parent])
expect(runIds[0]).toBe(runIds[1])
})
it('mints distinct lifecycle identities when provider and child ids repeat', async () => {
const { ctx, subagents } = await service()
subagents.registerProvider(new StubProvider('reused'))
const runIds: string[] = []
ctx.on('subagent/start', info => void runIds.push(info.runId))
const first = await subagents.start('reused', baseRequest())
const second = await subagents.start('reused', baseRequest())
await Promise.all([first.result, second.result])
expect(runIds).toHaveLength(2)
expect(new Set(runIds).size).toBe(2)
})
it('emits no run lifecycle when provider startup rejects', async () => {
@@ -192,7 +209,7 @@ describe('SubagentService', () => {
capabilities: NO_CAPS,
inheritsParentContext: false,
async start() {
return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} }
return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} }
},
})
const failedRun = await subagents.start('infra', baseRequest())