fix(subagent): identify lifecycle runs explicitly
This commit is contained in:
@@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
let disposal: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
if (disposal !== undefined) return disposal
|
||||
|
||||
@@ -175,6 +175,7 @@ export async function startInProcessRun(
|
||||
|
||||
return {
|
||||
id: childId,
|
||||
localAgent: child,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('weird-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
dispose: async () => {},
|
||||
}),
|
||||
@@ -171,6 +172,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -200,6 +202,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('bare-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -327,6 +330,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
@@ -349,6 +353,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
@@ -380,6 +385,7 @@ describe('dsh-tool-subagent', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -472,6 +478,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture2-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -529,6 +536,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture3-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -558,6 +566,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture4-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -732,6 +741,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
@@ -771,6 +781,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
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() },
|
||||
})
|
||||
@@ -782,6 +793,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
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() },
|
||||
})
|
||||
@@ -790,6 +802,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const disposeFailed = await settleRun({
|
||||
id: SessionId('child-3'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
@@ -797,6 +810,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const bothFailed = await settleRun({
|
||||
id: SessionId('child-4'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('result failed')),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
@@ -832,6 +846,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
|
||||
starts += 1
|
||||
return {
|
||||
id: SessionId('probe-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user