fix(subagent): identify lifecycle runs explicitly
This commit is contained in:
@@ -918,7 +918,7 @@ export interface Config {
|
|||||||
|
|
||||||
Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
|
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`
|
## `@deepseek-ai/dsh-subagent-spawn`
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
|||||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
'subagent/end'(this: Scoped<SubagentService>, 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
|
### `subagent/provider-added` — emit
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ A provider became resolvable in the registry.
|
|||||||
'subagent/provider-added'(provider: SubagentProvider): void
|
'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
|
### `subagent/provider-removed` — emit
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
|||||||
'subagent/provider-removed'(name: string): void
|
'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
|
### `subagent/start` — emit
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
|||||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
'subagent/start'(this: Scoped<SubagentService>, 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/*`
|
## `system-prompt/*`
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ list(): string[]
|
|||||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
## `ctx.systemPrompt` — `SystemPrompt`
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface SubagentStopReasonMap {
|
|||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
interface SubagentRun {
|
interface SubagentRun {
|
||||||
readonly id: SessionId
|
readonly id: SessionId
|
||||||
|
readonly localAgent: Agent | undefined
|
||||||
readonly result: Promise<SubagentResult>
|
readonly result: Promise<SubagentResult>
|
||||||
dispose(): Promise<void>
|
dispose(): Promise<void>
|
||||||
sendMessage?(content: ContentBlock[]): void
|
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`
|
## 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
|
## In-process backends: depth and seed
|
||||||
|
|
||||||
|
|||||||
@@ -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/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/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) |
|
| `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/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:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
| `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:89`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`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:100`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
| `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/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`) | - |
|
| `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`) | - |
|
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ flowchart TD
|
|||||||
pkg_tool_skill --> pkg_skill
|
pkg_tool_skill --> pkg_skill
|
||||||
pkg_tool_skill --> pkg_tools
|
pkg_tool_skill --> pkg_tools
|
||||||
pkg_subagent --> pkg_agent
|
pkg_subagent --> pkg_agent
|
||||||
|
pkg_subagent --> pkg_brand
|
||||||
pkg_subagent --> pkg_llm
|
pkg_subagent --> pkg_llm
|
||||||
pkg_subagent --> pkg_scope
|
pkg_subagent --> pkg_scope
|
||||||
pkg_subagent --> pkg_session
|
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`](../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-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) |
|
| [`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) |
|
| [`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) |
|
| [`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) |
|
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||||
|
|||||||
@@ -1028,7 +1028,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SubagentRun',
|
name: 'SubagentRun',
|
||||||
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
|
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SubagentStartRequest',
|
name: 'SubagentStartRequest',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|||||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
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 * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
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
|
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||||
// bridge just listens). No child agent is registered, so SubagentStart's
|
// bridge just listens). No child agent is registered, so SubagentStart's
|
||||||
// child lookup yields undefined and it simply runs the hook.
|
// child lookup yields undefined and it simply runs the hook.
|
||||||
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 })
|
||||||
ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
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
|
// Both hooks run async (detached .then); poll for their marker files rather
|
||||||
// than a fixed sleep that flakes under load.
|
// 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 { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
|
||||||
const warn = vi.fn()
|
const warn = vi.fn()
|
||||||
ctx.logger.warn = warn as never
|
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))
|
await waitFor(() => existsSync(marker))
|
||||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||||
await hooks.dispose()
|
await hooks.dispose()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|||||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
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 * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
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 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<typeof ctx.agents.register>[0]
|
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<typeof ctx.agents.register>[0]
|
||||||
ctx.agents.register(child)
|
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'))
|
await waitFor(() => injected.includes('child guidance'))
|
||||||
expect(injected).toContain('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 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<typeof ctx.agents.register>[0]
|
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<typeof ctx.agents.register>[0]
|
||||||
ctx.agents.register(child)
|
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')))
|
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('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 s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const ctx = await harness(path, new MockAdapter([]))
|
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))
|
await waitFor(() => existsSync(marker))
|
||||||
expect(existsSync(marker)).toBe(true)
|
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.
|
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
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' } })
|
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))
|
await waitFor(() => existsSync(marker))
|
||||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
|||||||
let disposal: Promise<void> | undefined
|
let disposal: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
localAgent: undefined,
|
||||||
result,
|
result,
|
||||||
dispose(): Promise<void> {
|
dispose(): Promise<void> {
|
||||||
if (disposal !== undefined) return disposal
|
if (disposal !== undefined) return disposal
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export async function startInProcessRun(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: childId,
|
id: childId,
|
||||||
|
localAgent: child,
|
||||||
result,
|
result,
|
||||||
dispose(): Promise<void> {
|
dispose(): Promise<void> {
|
||||||
request.signal.removeEventListener('abort', onAbort)
|
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.
|
`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.
|
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",
|
"license": "BSD-3-Clause",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||||
|
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||||
"@deepseek-ai/dsh-session": "workspace:^",
|
"@deepseek-ai/dsh-session": "workspace:^",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
* @module @deepseek-ai/dsh-subagent
|
* @module @deepseek-ai/dsh-subagent
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||||
@@ -43,7 +44,9 @@ import type {
|
|||||||
SubagentRun,
|
SubagentRun,
|
||||||
SubagentStartRequest,
|
SubagentStartRequest,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
import { SubagentRunId } from './types.ts'
|
||||||
|
|
||||||
|
export { SubagentRunId } from './types.ts'
|
||||||
export type {
|
export type {
|
||||||
SubagentCapabilities,
|
SubagentCapabilities,
|
||||||
SubagentProvider,
|
SubagentProvider,
|
||||||
@@ -112,18 +115,26 @@ declare module 'cordis' {
|
|||||||
|
|
||||||
/** Observe-only identifying detail for a ready subagent run. */
|
/** Observe-only identifying detail for a ready subagent run. */
|
||||||
export interface SubagentRunInfo {
|
export interface SubagentRunInfo {
|
||||||
|
/** Unique identity shared with the paired terminal event. */
|
||||||
|
readonly runId: SubagentRunId
|
||||||
/** The provider that established the run. */
|
/** The provider that established the run. */
|
||||||
readonly provider: string
|
readonly provider: string
|
||||||
/** The child agent's id. */
|
/** The child agent's id. */
|
||||||
readonly id: SessionId
|
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. */
|
/** Observe-only outcome detail for a settled subagent run. */
|
||||||
export interface SubagentRunEndInfo {
|
export interface SubagentRunEndInfo {
|
||||||
|
/** Unique identity shared with the paired start event. */
|
||||||
|
readonly runId: SubagentRunId
|
||||||
/** The provider that ran it. */
|
/** The provider that ran it. */
|
||||||
readonly provider: string
|
readonly provider: string
|
||||||
/** The child agent's id. */
|
/** The child agent's id. */
|
||||||
readonly id: SessionId
|
readonly id: SessionId
|
||||||
|
/** Whether the provider exposed an exact published in-process child. */
|
||||||
|
readonly local: boolean
|
||||||
/** The terminal stop reason. */
|
/** The terminal stop reason. */
|
||||||
readonly stopReason: SubagentResult['stopReason']
|
readonly stopReason: SubagentResult['stopReason']
|
||||||
/** The child's final assistant output, absent on infrastructure rejection. */
|
/** The child's final assistant output, absent on infrastructure rejection. */
|
||||||
@@ -208,22 +219,28 @@ export class SubagentService extends Service {
|
|||||||
|
|
||||||
const parent = request.parent
|
const parent = request.parent
|
||||||
const run = await provider.start(request)
|
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
|
// Attach the terminal observer before dispatching start. Promise reactions
|
||||||
// still run after this synchronous start emission, preserving start → end.
|
// still run after this synchronous start emission, preserving start → end.
|
||||||
void run.result.then(
|
void run.result.then(
|
||||||
(result) => {
|
(result) => {
|
||||||
this.emitLifecycle('subagent/end', {
|
this.emitLifecycle('subagent/end', {
|
||||||
provider: name,
|
...lifecycleIdentity,
|
||||||
id: run.id,
|
|
||||||
stopReason: result.stopReason,
|
stopReason: result.stopReason,
|
||||||
lastAssistantMessage: result.output,
|
lastAssistantMessage: result.output,
|
||||||
}, parent)
|
}, 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
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
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 { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
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
|
* 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
|
* {@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.
|
* remote provider mints an id unique in the parent namespace.
|
||||||
*/
|
*/
|
||||||
readonly id: SessionId
|
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
|
* Resolves with the child's terminal {@link SubagentResult} when the run
|
||||||
* settles. Does NOT reject on a child-level failure — a model/transport
|
* settles. Does NOT reject on a child-level failure — a model/transport
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class StubProvider implements SubagentProvider {
|
|||||||
this.startCount += 1
|
this.startCount += 1
|
||||||
return {
|
return {
|
||||||
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve(this.outcome),
|
result: Promise.resolve(this.outcome),
|
||||||
async dispose() {},
|
async dispose() {},
|
||||||
}
|
}
|
||||||
@@ -137,13 +138,14 @@ describe('SubagentService', () => {
|
|||||||
const parent = fakeParent('delegator')
|
const parent = fakeParent('delegator')
|
||||||
const events: string[] = []
|
const events: string[] = []
|
||||||
const keys: unknown[] = []
|
const keys: unknown[] = []
|
||||||
ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
|
const runIds: string[] = []
|
||||||
ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
|
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 }))
|
const starting = subagents.start('deferred', baseRequest({ parent }))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
expect(events).toEqual([])
|
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
|
const run = await starting
|
||||||
expect(events).toEqual(['start'])
|
expect(events).toEqual(['start'])
|
||||||
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
||||||
@@ -151,6 +153,21 @@ describe('SubagentService', () => {
|
|||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
expect(events).toEqual(['start', 'end'])
|
expect(events).toEqual(['start', 'end'])
|
||||||
expect(keys).toEqual([parent, parent])
|
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 () => {
|
it('emits no run lifecycle when provider startup rejects', async () => {
|
||||||
@@ -192,7 +209,7 @@ describe('SubagentService', () => {
|
|||||||
capabilities: NO_CAPS,
|
capabilities: NO_CAPS,
|
||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
async start() {
|
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())
|
const failedRun = await subagents.start('infra', baseRequest())
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: SessionId('weird-child'),
|
id: SessionId('weird-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}),
|
}),
|
||||||
@@ -171,6 +172,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: SessionId('capture-child'),
|
id: SessionId('capture-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -200,6 +202,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: SessionId('bare-child'),
|
id: SessionId('bare-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -327,6 +330,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: SessionId('spy-child'),
|
id: SessionId('spy-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => void disposed(),
|
dispose: async () => void disposed(),
|
||||||
}),
|
}),
|
||||||
@@ -349,6 +353,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: SessionId('spy-child'),
|
id: SessionId('spy-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||||
dispose: async () => void disposed(),
|
dispose: async () => void disposed(),
|
||||||
}),
|
}),
|
||||||
@@ -380,6 +385,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
}, { once: true })
|
}, { once: true })
|
||||||
return {
|
return {
|
||||||
id: SessionId('spy-child'),
|
id: SessionId('spy-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result,
|
result,
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -472,6 +478,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: SessionId('capture2-child'),
|
id: SessionId('capture2-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -529,6 +536,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: SessionId('capture3-child'),
|
id: SessionId('capture3-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -558,6 +566,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: SessionId('capture4-child'),
|
id: SessionId('capture4-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -732,6 +741,7 @@ describe('dsh-tool-subagent background mode', () => {
|
|||||||
}, { once: true })
|
}, { once: true })
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
localAgent: undefined,
|
||||||
result,
|
result,
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
@@ -771,6 +781,7 @@ describe('dsh-tool-subagent background mode', () => {
|
|||||||
const order: string[] = []
|
const order: string[] = []
|
||||||
const completed = await settleRun({
|
const completed = await settleRun({
|
||||||
id: SessionId('child-1'),
|
id: SessionId('child-1'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||||
})
|
})
|
||||||
@@ -782,6 +793,7 @@ describe('dsh-tool-subagent background mode', () => {
|
|||||||
let disposed = false
|
let disposed = false
|
||||||
const failed = await settleRun({
|
const failed = await settleRun({
|
||||||
id: SessionId('child-2'),
|
id: SessionId('child-2'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.reject(new Error('transport gone')),
|
result: Promise.reject(new Error('transport gone')),
|
||||||
dispose() { disposed = true; return Promise.resolve() },
|
dispose() { disposed = true; return Promise.resolve() },
|
||||||
})
|
})
|
||||||
@@ -790,6 +802,7 @@ describe('dsh-tool-subagent background mode', () => {
|
|||||||
|
|
||||||
const disposeFailed = await settleRun({
|
const disposeFailed = await settleRun({
|
||||||
id: SessionId('child-3'),
|
id: SessionId('child-3'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||||
dispose: () => Promise.reject(new Error('reap failed')),
|
dispose: () => Promise.reject(new Error('reap failed')),
|
||||||
})
|
})
|
||||||
@@ -797,6 +810,7 @@ describe('dsh-tool-subagent background mode', () => {
|
|||||||
|
|
||||||
const bothFailed = await settleRun({
|
const bothFailed = await settleRun({
|
||||||
id: SessionId('child-4'),
|
id: SessionId('child-4'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.reject(new Error('result failed')),
|
result: Promise.reject(new Error('result failed')),
|
||||||
dispose: () => Promise.reject(new Error('reap failed')),
|
dispose: () => Promise.reject(new Error('reap failed')),
|
||||||
})
|
})
|
||||||
@@ -832,6 +846,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
|
|||||||
starts += 1
|
starts += 1
|
||||||
return {
|
return {
|
||||||
id: SessionId('probe-child'),
|
id: SessionId('probe-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
localAgent: undefined,
|
||||||
result,
|
result,
|
||||||
dispose(): Promise<void> {
|
dispose(): Promise<void> {
|
||||||
flags.cancelled = true
|
flags.cancelled = true
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve
|
|||||||
|
|
||||||
## Wiring
|
## 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
|
## Config
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
|||||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||||
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
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 * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||||
import type { JsonRpcTransportPeer } from './transport.ts'
|
import type { JsonRpcTransportPeer } from './transport.ts'
|
||||||
|
|
||||||
@@ -68,15 +68,6 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
|
|||||||
return carrierKeyOf(carrier) as 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
|
* The SDK server over a booted harness context. Constructing it subscribes to
|
||||||
* session and subagent lifecycle events, forwarding durable session
|
* session and subagent lifecycle events, forwarding durable session
|
||||||
@@ -92,7 +83,6 @@ export class HarnessSdkServer {
|
|||||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||||
private readonly sessions = new Map<string, SessionRecord>()
|
private readonly sessions = new Map<string, SessionRecord>()
|
||||||
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
|
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
|
||||||
private readonly localRuns = new Map<string, Map<SessionId, Map<Agent, number>>>()
|
|
||||||
private readonly disposers: (() => void)[] = []
|
private readonly disposers: (() => void)[] = []
|
||||||
private shutdownTask: Promise<Record<string, never>> | undefined
|
private shutdownTask: Promise<Record<string, never>> | undefined
|
||||||
private shuttingDown = false
|
private shuttingDown = false
|
||||||
@@ -116,36 +106,12 @@ export class HarnessSdkServer {
|
|||||||
childSessionId: String(session.id),
|
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<SubagentService>, info: SubagentRunInfo) {
|
|
||||||
const parent = subagentParentOf(this)
|
|
||||||
if (!isLocalChild(ctx, info.id, parent)) return
|
|
||||||
const providerRuns = localRuns.get(info.provider) ?? new Map<SessionId, Map<Agent, number>>()
|
|
||||||
const parentRuns = providerRuns.get(info.id) ?? new Map<Agent, number>()
|
|
||||||
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<SubagentService>, info: SubagentRunEndInfo) {
|
this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
|
||||||
const parent = subagentParentOf(this)
|
const parent = subagentParentOf(this)
|
||||||
const providerRuns = localRuns.get(info.provider)
|
// This protocol reports only in-process child sessions. The service
|
||||||
const parentRuns = providerRuns?.get(info.id)
|
// snapshots the provider's exact run provenance through child disposal;
|
||||||
const pendingCount = parentRuns?.get(parent)
|
// matching ids or parent lineage alone never establishes locality.
|
||||||
if (pendingCount !== undefined) {
|
if (!info.local) return
|
||||||
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
|
|
||||||
transport.notify('subagent.finished', {
|
transport.notify('subagent.finished', {
|
||||||
provider: info.provider,
|
provider: info.provider,
|
||||||
agentId: String(info.id),
|
agentId: String(info.id),
|
||||||
@@ -223,7 +189,6 @@ export class HarnessSdkServer {
|
|||||||
this.sessionCreations.clear()
|
this.sessionCreations.clear()
|
||||||
const records = [...this.sessions.values()]
|
const records = [...this.sessions.values()]
|
||||||
this.sessions.clear()
|
this.sessions.clear()
|
||||||
this.localRuns.clear()
|
|
||||||
const failures: unknown[] = []
|
const failures: unknown[] = []
|
||||||
while (this.disposers.length > 0) {
|
while (this.disposers.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ try {
|
|||||||
start() {
|
start() {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: child.agent.id,
|
id: child.agent.id,
|
||||||
|
localAgent: child.agent,
|
||||||
result: result.promise,
|
result: result.promise,
|
||||||
dispose() { return Promise.resolve(); },
|
dispose() { return Promise.resolve(); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function makeHarness(storageDir: string) {
|
|||||||
async function settleSubagent(
|
async function settleSubagent(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
parent: Agent,
|
parent: Agent,
|
||||||
info: SubagentRunEndInfo,
|
info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
|
||||||
beforeSettle?: () => Promise<void>,
|
beforeSettle?: () => Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = Promise.withResolvers<SubagentResult>()
|
const result = Promise.withResolvers<SubagentResult>()
|
||||||
@@ -81,6 +81,7 @@ async function settleSubagent(
|
|||||||
async start() {
|
async start() {
|
||||||
return {
|
return {
|
||||||
id: info.id,
|
id: info.id,
|
||||||
|
localAgent: info.localAgent,
|
||||||
result: result.promise,
|
result: result.promise,
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
@@ -294,12 +295,14 @@ describe('HarnessSdkServer', () => {
|
|||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'spawn',
|
provider: 'spawn',
|
||||||
id: SessionId('child-session'),
|
id: SessionId('child-session'),
|
||||||
|
localAgent: handle.agent,
|
||||||
stopReason: 'completed',
|
stopReason: 'completed',
|
||||||
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
||||||
}, () => handle.dispose())
|
}, () => handle.dispose())
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'spawn',
|
provider: 'spawn',
|
||||||
id: SessionId('parentless-child-session'),
|
id: SessionId('parentless-child-session'),
|
||||||
|
localAgent: parentlessHandle.agent,
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
}, () => parentlessHandle.dispose())
|
}, () => 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 storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-'))
|
||||||
const ctx = await makeHarness(storageDir)
|
const ctx = await makeHarness(storageDir)
|
||||||
try {
|
try {
|
||||||
@@ -346,24 +349,26 @@ describe('HarnessSdkServer', () => {
|
|||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
const unrelatedHandle = await ctx.agents.create({
|
const collidingChild = await parentHandle.agent.ctx.agents.create({
|
||||||
sessionId: SessionId('remote-run-id'),
|
sessionId: SessionId('remote-run-id'),
|
||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
|
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'remote',
|
provider: 'remote',
|
||||||
id: SessionId('remote-run-id'),
|
id: SessionId('remote-run-id'),
|
||||||
|
localAgent: undefined,
|
||||||
stopReason: 'completed',
|
stopReason: 'completed',
|
||||||
lastAssistantMessage: [],
|
lastAssistantMessage: [],
|
||||||
}, () => unrelatedHandle.dispose())
|
})
|
||||||
|
|
||||||
expect(transport.notifications.some(notification =>
|
expect(transport.notifications.some(notification =>
|
||||||
notification.method === 'subagent.finished'
|
notification.method === 'subagent.finished'
|
||||||
&& notification.params?.agentId === 'remote-run-id',
|
&& notification.params?.agentId === 'remote-run-id',
|
||||||
)).toBe(false)
|
)).toBe(false)
|
||||||
|
|
||||||
|
await collidingChild.dispose()
|
||||||
await parentHandle.dispose()
|
await parentHandle.dispose()
|
||||||
await server.shutdown()
|
await server.shutdown()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -392,12 +397,14 @@ describe('HarnessSdkServer', () => {
|
|||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'continuation',
|
provider: 'continuation',
|
||||||
id: SessionId('continuation-child'),
|
id: SessionId('continuation-child'),
|
||||||
|
localAgent: childHandle.agent,
|
||||||
stopReason: 'completed',
|
stopReason: 'completed',
|
||||||
lastAssistantMessage: [{ type: 'text', text: 'first' }],
|
lastAssistantMessage: [{ type: 'text', text: 'first' }],
|
||||||
})
|
})
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'continuation',
|
provider: 'continuation',
|
||||||
id: SessionId('continuation-child'),
|
id: SessionId('continuation-child'),
|
||||||
|
localAgent: childHandle.agent,
|
||||||
stopReason: 'completed',
|
stopReason: 'completed',
|
||||||
lastAssistantMessage: [{ type: 'text', text: 'second' }],
|
lastAssistantMessage: [{ type: 'text', text: 'second' }],
|
||||||
}, () => childHandle.dispose())
|
}, () => childHandle.dispose())
|
||||||
@@ -436,6 +443,7 @@ describe('HarnessSdkServer', () => {
|
|||||||
const replacement = Promise.withResolvers<SubagentResult>()
|
const replacement = Promise.withResolvers<SubagentResult>()
|
||||||
const results = [first.promise, sameLifetime.promise, replacement.promise]
|
const results = [first.promise, sameLifetime.promise, replacement.promise]
|
||||||
let starts = 0
|
let starts = 0
|
||||||
|
let currentLocalAgent = oldChild.agent
|
||||||
const disposeProvider = ctx.subagents.registerProvider({
|
const disposeProvider = ctx.subagents.registerProvider({
|
||||||
name: 'reused',
|
name: 'reused',
|
||||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||||
@@ -444,7 +452,7 @@ describe('HarnessSdkServer', () => {
|
|||||||
const result = results[starts]
|
const result = results[starts]
|
||||||
starts += 1
|
starts += 1
|
||||||
if (result === undefined) throw new Error('unexpected fourth reused-id run')
|
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') },
|
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
|
currentLocalAgent = newChild.agent
|
||||||
const secondRun = await ctx.subagents.start('reused', {
|
const secondRun = await ctx.subagents.start('reused', {
|
||||||
parent: newParent.agent,
|
parent: newParent.agent,
|
||||||
prompt: [],
|
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<SubagentResult>()
|
||||||
|
const remoteResult = Promise.withResolvers<SubagentResult>()
|
||||||
|
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 storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
|
||||||
const ctx = await makeHarness(storageDir)
|
const ctx = await makeHarness(storageDir)
|
||||||
let parentHandle: AgentHandle | undefined
|
let parentHandle: AgentHandle | undefined
|
||||||
@@ -529,6 +630,7 @@ describe('HarnessSdkServer', () => {
|
|||||||
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
||||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||||
})
|
})
|
||||||
|
const fallbackChild = handle.agent
|
||||||
failedHandle = await parentHandle.agent.ctx.agents.create({
|
failedHandle = await parentHandle.agent.ctx.agents.create({
|
||||||
sessionId: SessionId('failed-child-session'),
|
sessionId: SessionId('failed-child-session'),
|
||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir },
|
||||||
@@ -541,12 +643,13 @@ describe('HarnessSdkServer', () => {
|
|||||||
inheritsParentContext: true,
|
inheritsParentContext: true,
|
||||||
start: () => Promise.resolve({
|
start: () => Promise.resolve({
|
||||||
id: SessionId('fallback-child-session'),
|
id: SessionId('fallback-child-session'),
|
||||||
|
localAgent: fallbackChild,
|
||||||
result: missedStartResult.promise,
|
result: missedStartResult.promise,
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
// Start before the server subscribes, so the terminal fallback must use
|
// Start before the server subscribes. The terminal payload still carries
|
||||||
// the still-live registry entry rather than a cached start record.
|
// this run's exact local child without reconstructing it from ids.
|
||||||
const missedStartRun = await ctx.subagents.start('fork', {
|
const missedStartRun = await ctx.subagents.start('fork', {
|
||||||
parent: parentHandle.agent,
|
parent: parentHandle.agent,
|
||||||
prompt: [],
|
prompt: [],
|
||||||
@@ -560,22 +663,25 @@ describe('HarnessSdkServer', () => {
|
|||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await missedStartRun.dispose()
|
await missedStartRun.dispose()
|
||||||
disposeMissedStartProvider()
|
disposeMissedStartProvider()
|
||||||
// The server also missed this agent's creation, but observes the start;
|
// The server also missed this agent's creation but sees the exact child
|
||||||
// recover its lineage from the still-live registry entry.
|
// on the run lifecycle payload.
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'fork-live-fallback',
|
provider: 'fork-live-fallback',
|
||||||
id: SessionId('fallback-child-session'),
|
id: SessionId('fallback-child-session'),
|
||||||
|
localAgent: fallbackChild,
|
||||||
stopReason: 'completed',
|
stopReason: 'completed',
|
||||||
lastAssistantMessage: [],
|
lastAssistantMessage: [],
|
||||||
})
|
})
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
id: SessionId('failed-child-session'),
|
id: SessionId('failed-child-session'),
|
||||||
|
localAgent: failedHandle.agent,
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
})
|
})
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
id: SessionId('missing-child-agent'),
|
id: SessionId('missing-child-agent'),
|
||||||
|
localAgent: undefined,
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -800,6 +906,6 @@ describe('HarnessSdkServer', () => {
|
|||||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||||
|
|
||||||
await expect(server.shutdown()).rejects.toBe(listenerFailure)
|
await expect(server.shutdown()).rejects.toBe(listenerFailure)
|
||||||
expect(on).toHaveBeenCalledTimes(4)
|
expect(on).toHaveBeenCalledTimes(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ class StubProvider implements SubagentProvider {
|
|||||||
if (request.signal.aborted) throw new Error('child start aborted before publication')
|
if (request.signal.aborted) throw new Error('child start aborted before publication')
|
||||||
return {
|
return {
|
||||||
id: SessionId(`stub-child-${index}`),
|
id: SessionId(`stub-child-${index}`),
|
||||||
|
localAgent: undefined,
|
||||||
result: terminal.promise,
|
result: terminal.promise,
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
controlled.disposeCalls += 1
|
controlled.disposeCalls += 1
|
||||||
@@ -362,6 +363,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: SessionId('reject-child'),
|
id: SessionId('reject-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.reject(new Error('backend exploded')),
|
result: Promise.reject(new Error('backend exploded')),
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
}),
|
}),
|
||||||
@@ -396,6 +398,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
} as unknown as SubagentResult
|
} as unknown as SubagentResult
|
||||||
const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({
|
const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({
|
||||||
id: SessionId('raw-invalid-child'),
|
id: SessionId('raw-invalid-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve(invalid),
|
result: Promise.resolve(invalid),
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
@@ -419,6 +422,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: SessionId('bad-dispose-child'),
|
id: SessionId('bad-dispose-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
||||||
cancel: () => { /* settled already */ },
|
cancel: () => { /* settled already */ },
|
||||||
dispose: () => { throw new Error('dispose exploded') },
|
dispose: () => { throw new Error('dispose exploded') },
|
||||||
@@ -440,6 +444,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: SessionId('trap-child'),
|
id: SessionId('trap-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
||||||
cancel: () => { /* settled already */ },
|
cancel: () => { /* settled already */ },
|
||||||
// The rejection VALUE's own coercion throws: a warn built with bare
|
// The rejection VALUE's own coercion throws: a warn built with bare
|
||||||
@@ -767,6 +772,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
}, { once: true })
|
}, { once: true })
|
||||||
return {
|
return {
|
||||||
id: SessionId('signal-only-child'),
|
id: SessionId('signal-only-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result,
|
result,
|
||||||
dispose: () => Promise.resolve(),
|
dispose: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
@@ -1088,6 +1094,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
|
|
||||||
ready.resolve({
|
ready.resolve({
|
||||||
id: SessionId('late-ready-child'),
|
id: SessionId('late-ready-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
|
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
disposeCalls += 1
|
disposeCalls += 1
|
||||||
@@ -1124,6 +1131,7 @@ describe('dsh-workflow-workerthread', () => {
|
|||||||
}, { once: true })
|
}, { once: true })
|
||||||
return {
|
return {
|
||||||
id: SessionId('doomed-child'),
|
id: SessionId('doomed-child'),
|
||||||
|
localAgent: undefined,
|
||||||
result: new Promise(() => { /* never settles; the reap is the teardown */ }),
|
result: new Promise(() => { /* never settles; the reap is the teardown */ }),
|
||||||
dispose: () => Promise.reject(new Error('dispose exploded during reap')),
|
dispose: () => Promise.reject(new Error('dispose exploded during reap')),
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1441,6 +1441,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-agent':
|
'@deepseek-ai/dsh-agent':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../core/agent
|
version: link:../../core/agent
|
||||||
|
'@deepseek-ai/dsh-brand':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../util/brand
|
||||||
'@deepseek-ai/dsh-llm':
|
'@deepseek-ai/dsh-llm':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../llm/llm
|
version: link:../../llm/llm
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
|||||||
|
|
||||||
- `info` — the run identity and terminal outcome.
|
- `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
|
### subagent/provider-added
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@ A provider became resolvable in the registry.
|
|||||||
|
|
||||||
- `provider` — the registered provider.
|
- `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
|
### 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.
|
- `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
|
### 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.
|
- `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/*
|
## system-prompt/*
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Named provider registry and capability-checked start surface.
|
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)
|
### 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.
|
**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)
|
### ctx.subagents.getProvider(name)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Look up a provider by name.
|
|||||||
|
|
||||||
**Returns** the provider, or undefined when absent.
|
**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()
|
### ctx.subagents.list()
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ List registered provider names in insertion order.
|
|||||||
|
|
||||||
**Returns** the registered names.
|
**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)
|
### 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.
|
**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)
|
||||||
|
|||||||
Reference in New Issue
Block a user