diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a0cbac6789..f07dfcf18a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -918,7 +918,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 820a453ceb..d0ff7002b0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -301,7 +301,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -311,7 +311,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -321,7 +321,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:89`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -331,7 +331,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:100`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 52f9cdf131..ee953e37c7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -264,7 +264,7 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 18cb22c32d..815438f215 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -69,6 +69,7 @@ interface SubagentStopReasonMap { ```ts type-equiv interface SubagentRun { readonly id: SessionId + readonly localAgent: Agent | undefined readonly result: Promise dispose(): Promise sendMessage?(content: ContentBlock[]): void @@ -76,7 +77,7 @@ interface SubagentRun { } ``` -A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope; `parentSession` is the durable transport-neutral lineage. A remote provider instead returns a parent-scoped lifecycle id and does not publish a local child. +A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. ## The provider seam: `SubagentProvider` @@ -91,7 +92,7 @@ interface SubagentProvider { } ``` -`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6d75d54f3d..49e8d778cf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:89`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:100`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 604aa7cf7f..f49092f99d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -279,6 +279,7 @@ flowchart TD pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent + pkg_subagent --> pkg_brand pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session @@ -493,7 +494,7 @@ flowchart TD | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 71640498c9..ed5c0babe9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1028,7 +1028,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 136ee893c6..7bb4c0b4ea 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -288,8 +289,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's // child lookup yields undefined and it simply runs the hook. - ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) - ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -324,7 +325,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 9d4004c1ff..0431486d57 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -231,7 +232,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const injected: string[] = [] const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -247,7 +248,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const warn = vi.fn(); ctx.logger.warn = warn as never const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -291,7 +292,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -691,7 +692,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index b7b10ecb97..a10a87a940 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let disposal: Promise | undefined return { id, + localAgent: undefined, result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 018f93051f..c5c85ec232 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -175,6 +175,7 @@ export async function startInProcessRun( return { id: childId, + localAgent: child, result, dispose(): Promise { request.signal.removeEventListener('abort', onAbort) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 45e0976ed7..17edd0e3c7 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -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. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 84d2c963d5..aea05553e4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -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:^", diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 00762cc413..8f3549ac7c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -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 } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index e5ba8fd84d..1b1645d89b 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -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 diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index ad7dbaafff..66008a923b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -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()) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 27a603049e..dbb78812d4 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -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(), } diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index c2e94c521f..4ea30d166a 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider { }) return { id, + localAgent: undefined, result, dispose(): Promise { flags.cancelled = true diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 6d9f007e66..3d19b601c4 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It classifies subagent completions through live parent ownership or durable parent lineage and retains parent-scoped provider/id counts after child disposal. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It forwards subagent completions only when the lifecycle payload's `local` flag was snapshotted from the provider's exact in-process child; reusable provider names, child ids, and durable lineage never establish locality. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 91cc49571a..5246795a31 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -19,7 +19,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' -import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent' +import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' @@ -68,15 +68,6 @@ function subagentParentOf(carrier: Scoped): Agent { return carrierKeyOf(carrier) as Agent } -/** Whether the live id names a local child related to this exact delegating parent. */ -function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean { - const child = ctx.agents.get(id) - return child !== undefined && ( - ctx.agents.isOwnedBy(id, parent) - || child.session.header.parentSession === parent.session.id - ) -} - /** * The SDK server over a booted harness context. Constructing it subscribes to * session and subagent lifecycle events, forwarding durable session @@ -92,7 +83,6 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly localRuns = new Map>>() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -116,36 +106,12 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // In-process providers publish the child before start. Count starts related - // by exact runtime ownership or durable parent lineage so provider-owned - // roots remain local, completions survive child disposal, and reused ids - // need no settlement-order assumption. - const localRuns = this.localRuns - this.disposers.push(ctx.on('subagent/start', function (this: Scoped, info: SubagentRunInfo) { - const parent = subagentParentOf(this) - if (!isLocalChild(ctx, info.id, parent)) return - const providerRuns = localRuns.get(info.provider) ?? new Map>() - const parentRuns = providerRuns.get(info.id) ?? new Map() - parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1) - providerRuns.set(info.id, parentRuns) - localRuns.set(info.provider, providerRuns) - })) this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { const parent = subagentParentOf(this) - const providerRuns = localRuns.get(info.provider) - const parentRuns = providerRuns?.get(info.id) - const pendingCount = parentRuns?.get(parent) - if (pendingCount !== undefined) { - if (pendingCount === 1) parentRuns?.delete(parent) - else parentRuns?.set(parent, pendingCount - 1) - if (parentRuns?.size === 0) providerRuns?.delete(info.id) - if (providerRuns?.size === 0) localRuns.delete(info.provider) - } - // This protocol reports LOCAL child sessions. A lineage-bearing child - // has the session/created-driven start notification above. A remote run - // has neither a cached local start nor a live child related to this - // parent; an unrelated local agent with the same id never makes it local. - if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return + // This protocol reports only in-process child sessions. The service + // snapshots the provider's exact run provenance through child disposal; + // matching ids or parent lineage alone never establishes locality. + if (!info.local) return transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), @@ -223,7 +189,6 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.localRuns.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts index ed4dfbb9db..54727f571b 100644 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -71,6 +71,7 @@ try { start() { return Promise.resolve({ id: child.agent.id, + localAgent: child.agent, result: result.promise, dispose() { return Promise.resolve(); }, }); diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 3fe73c904e..5b9259dd85 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -70,7 +70,7 @@ async function makeHarness(storageDir: string) { async function settleSubagent( ctx: Context, parent: Agent, - info: SubagentRunEndInfo, + info: Omit & { localAgent: Agent | undefined }, beforeSettle?: () => Promise, ): Promise { const result = Promise.withResolvers() @@ -81,6 +81,7 @@ async function settleSubagent( async start() { return { id: info.id, + localAgent: info.localAgent, result: result.promise, dispose: () => Promise.resolve(), } @@ -294,12 +295,14 @@ describe('HarnessSdkServer', () => { await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), + localAgent: handle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], }, () => handle.dispose()) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('parentless-child-session'), + localAgent: parentlessHandle.agent, stopReason: 'error', }, () => parentlessHandle.dispose()) @@ -335,7 +338,7 @@ describe('HarnessSdkServer', () => { } }) - it('ignores a remote run id that collides with an unrelated local agent', async () => { + it('ignores a remote run id that collides with a local child of the same parent', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-')) const ctx = await makeHarness(storageDir) try { @@ -346,24 +349,26 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const unrelatedHandle = await ctx.agents.create({ + const collidingChild = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('remote-run-id'), - meta: { cwd: storageDir }, + meta: { cwd: storageDir, parentSession: SessionId('collision-parent') }, agentOptions: { model: 'deepseek' }, }) await settleSubagent(ctx, parentHandle.agent, { provider: 'remote', id: SessionId('remote-run-id'), + localAgent: undefined, stopReason: 'completed', lastAssistantMessage: [], - }, () => unrelatedHandle.dispose()) + }) expect(transport.notifications.some(notification => notification.method === 'subagent.finished' && notification.params?.agentId === 'remote-run-id', )).toBe(false) + await collidingChild.dispose() await parentHandle.dispose() await server.shutdown() } finally { @@ -392,12 +397,14 @@ describe('HarnessSdkServer', () => { await settleSubagent(ctx, parentHandle.agent, { provider: 'continuation', id: SessionId('continuation-child'), + localAgent: childHandle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'first' }], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'continuation', id: SessionId('continuation-child'), + localAgent: childHandle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'second' }], }, () => childHandle.dispose()) @@ -436,6 +443,7 @@ describe('HarnessSdkServer', () => { const replacement = Promise.withResolvers() const results = [first.promise, sameLifetime.promise, replacement.promise] let starts = 0 + let currentLocalAgent = oldChild.agent const disposeProvider = ctx.subagents.registerProvider({ name: 'reused', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -444,7 +452,7 @@ describe('HarnessSdkServer', () => { const result = results[starts] starts += 1 if (result === undefined) throw new Error('unexpected fourth reused-id run') - return Promise.resolve({ id: SessionId('reused-child'), result, dispose: () => Promise.resolve() }) + return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() }) }, }) @@ -471,6 +479,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, agentOptions: { model: 'deepseek' }, }) + currentLocalAgent = newChild.agent const secondRun = await ctx.subagents.start('reused', { parent: newParent.agent, prompt: [], @@ -512,7 +521,99 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live lineage and ignores runs without a local child session', async () => { + it('keeps locality bound to the accepted run across provider re-registration', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parent = await ctx.agents.create({ + sessionId: SessionId('provider-reuse-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('provider-reuse-child'), + meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const localResult = Promise.withResolvers() + const remoteResult = Promise.withResolvers() + const unregisterLocal = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: child.agent, + result: localResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const localRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + unregisterLocal() + + const unregisterRemote = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: undefined, + result: remoteResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const remoteRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' }) + await remoteRun.result + await Promise.resolve() + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.lastAssistantMessage !== undefined, + )).toBe(false) + + await child.dispose() + localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' }) + await localRun.result + await Promise.resolve() + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'provider-reuse-child', + )).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'reused-provider', + agentId: 'provider-reuse-child', + parentSessionId: 'provider-reuse-parent', + childSessionId: 'provider-reuse-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'local' }], + }, + }]) + + await localRun.dispose() + await remoteRun.dispose() + unregisterRemote() + await parent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('uses explicit local provenance when start was missed and ignores remote runs', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -529,6 +630,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + const fallbackChild = handle.agent failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, @@ -541,12 +643,13 @@ describe('HarnessSdkServer', () => { inheritsParentContext: true, start: () => Promise.resolve({ id: SessionId('fallback-child-session'), + localAgent: fallbackChild, result: missedStartResult.promise, dispose: () => Promise.resolve(), }), }) - // Start before the server subscribes, so the terminal fallback must use - // the still-live registry entry rather than a cached start record. + // Start before the server subscribes. The terminal payload still carries + // this run's exact local child without reconstructing it from ids. const missedStartRun = await ctx.subagents.start('fork', { parent: parentHandle.agent, prompt: [], @@ -560,22 +663,25 @@ describe('HarnessSdkServer', () => { await Promise.resolve() await missedStartRun.dispose() disposeMissedStartProvider() - // The server also missed this agent's creation, but observes the start; - // recover its lineage from the still-live registry entry. + // The server also missed this agent's creation but sees the exact child + // on the run lifecycle payload. await settleSubagent(ctx, parentHandle.agent, { provider: 'fork-live-fallback', id: SessionId('fallback-child-session'), + localAgent: fallbackChild, stopReason: 'completed', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: SessionId('failed-child-session'), + localAgent: failedHandle.agent, stopReason: 'error', }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: SessionId('missing-child-agent'), + localAgent: undefined, stopReason: 'error', }) @@ -800,6 +906,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(4) + expect(on).toHaveBeenCalledTimes(3) }) }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 5905161e6a..2f52cd7b6b 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -105,6 +105,7 @@ class StubProvider implements SubagentProvider { if (request.signal.aborted) throw new Error('child start aborted before publication') return { id: SessionId(`stub-child-${index}`), + localAgent: undefined, result: terminal.promise, dispose: () => { controlled.disposeCalls += 1 @@ -362,6 +363,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('reject-child'), + localAgent: undefined, result: Promise.reject(new Error('backend exploded')), dispose: () => Promise.resolve(), }), @@ -396,6 +398,7 @@ describe('dsh-workflow-workerthread', () => { } as unknown as SubagentResult const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({ id: SessionId('raw-invalid-child'), + localAgent: undefined, result: Promise.resolve(invalid), dispose: () => Promise.resolve(), }) @@ -419,6 +422,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('bad-dispose-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -440,6 +444,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('trap-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -767,6 +772,7 @@ describe('dsh-workflow-workerthread', () => { }, { once: true }) return { id: SessionId('signal-only-child'), + localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -1088,6 +1094,7 @@ describe('dsh-workflow-workerthread', () => { ready.resolve({ id: SessionId('late-ready-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'aborted' }), dispose: () => { disposeCalls += 1 @@ -1124,6 +1131,7 @@ describe('dsh-workflow-workerthread', () => { }, { once: true }) return { id: SessionId('doomed-child'), + localAgent: undefined, result: new Promise(() => { /* never settles; the reap is the teardown */ }), dispose: () => Promise.reject(new Error('dispose exploded during reap')), } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7b3ab2b95..47d998fedc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1441,6 +1441,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index aafaf28484..1301a6a508 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -383,7 +383,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c - `info` — the run identity and terminal outcome. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L109) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112) ### subagent/provider-added @@ -397,7 +397,7 @@ A provider became resolvable in the registry. - `provider` — the registered provider. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L83) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L86) ### subagent/provider-removed @@ -411,7 +411,7 @@ A provider left the registry. Accepted runs remain holder-owned. - `name` — the provider name that no longer resolves. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L89) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L92) ### subagent/start @@ -425,7 +425,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( - `info` — the provider and ready child identity. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L100) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L103) ## system-prompt/* diff --git a/website/zh-CN/api/harness/subagents.md b/website/zh-CN/api/harness/subagents.md index 54fe5aa76e..52632e820d 100644 --- a/website/zh-CN/api/harness/subagents.md +++ b/website/zh-CN/api/harness/subagents.md @@ -6,7 +6,7 @@ Named provider registry and capability-checked start surface. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L153) ### ctx.subagents.registerProvider(provider) @@ -20,7 +20,7 @@ Register a provider under its name. Registration is effect-scoped and HMR safe; **Returns** the exact Cordis effect disposer. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L167) ### ctx.subagents.getProvider(name) @@ -34,7 +34,7 @@ Look up a provider by name. **Returns** the provider, or undefined when absent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L179) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L190) ### ctx.subagents.list() @@ -46,7 +46,7 @@ List registered provider names in insertion order. **Returns** the registered names. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L187) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L198) ### ctx.subagents.start(name, request) @@ -61,4 +61,4 @@ Establish a ready child on the named provider. Capability and semantic checks ru **Returns** the ready holder-owned run. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L200) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211)