refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-fork-in-process/README.md
|
||||
README.md: 74c27ff10c76aa711ed3e954e806c00a27aacfa5
|
||||
README.zh.md: bca890769bab1f898abd0b71fc8aa321b66e93c8
|
||||
61
packages/subagent/subagent-fork-in-process/README.md
Normal file
61
packages/subagent/subagent-fork-in-process/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @deepseek-ai/dsh-subagent-fork-in-process
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The fork provider creates an in-process child seeded with the parent's completed conversation turns. It shares all run mechanics with spawn; the session seed is the only behavioral difference.
|
||||
|
||||
## Seed boundary
|
||||
|
||||
The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session.
|
||||
|
||||
Fork therefore computes the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn.
|
||||
|
||||
The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.
|
||||
|
||||
## Start and capabilities
|
||||
|
||||
`start(request)` passes the completed-turn seed to [`startInProcessRun`](../subagent-in-process-driver/README.md) and awaits child publication. The shared driver owns cancellation, depth, customization, result reading, and disposal.
|
||||
|
||||
Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `providerName` | Registry name on `ctx.subagents` (default `fork`). |
|
||||
See [`dsh-subagent-spawn-in-process`](../subagent-spawn-in-process/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Child-agent history and envelope
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The child receives the parent's balanced completed-turn surface prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited. An optional structured-output request adds its child-only contract. The parent's current in-flight turn is excluded.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. Shipped compositions therefore bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it ([the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)).
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Parent input grows by one data-dependent final result retained until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.
|
||||
- **No shipped composition creates a continuable fork child** — `prepareContinuable` remains implemented and the seam accepts it, but every shipped `cordis.yml` sets `backgroundMode: one-shot` on the fork delegation tool, so the provider's continuable path has no production caller. Reopening it requires the child's system prompt and tool schemas to match the parent's byte for byte, which the [`report` return channel](../tool-subagent-report/README.md) currently prevents. Rationale and the reintroduction condition: [the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md).
|
||||
61
packages/subagent/subagent-fork-in-process/README.zh.md
Normal file
61
packages/subagent/subagent-fork-in-process/README.zh.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @deepseek-ai/dsh-subagent-fork-in-process
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
fork 提供方会创建一个进程内子 agent(智能体),并以父 agent 已完成的对话轮次作为初始内容。它与 spawn 共用全部运行机制;唯一的行为差异是会话初始内容。
|
||||
|
||||
## 初始内容边界
|
||||
|
||||
subagent 启动时,父 agent 当前的工具调用轮次仍未结束:其日志包含 assistant 工具调用,但尚无匹配的工具结果或 `turn/end`。直接复制这份原始日志会给子 agent 一个无效且不平衡的会话。
|
||||
|
||||
因此,fork 会计算截至最后一个 `turn/end` 的连续前缀。子 agent 能看到父 agent 所有已完成轮次,但看不到进行中的轮次。如果父 agent 尚未完成任何轮次,初始内容为空,子 agent 的行为与全新 spawn 相同。
|
||||
|
||||
初始内容只传递对话历史。子 agent 仍会获得全新的扁平注册作用域;它不继承父 agent 的工具限制或权限。
|
||||
|
||||
## 启动与能力
|
||||
|
||||
`start(request)` 将已完成轮次的初始内容传给 [`startInProcessRun`](../subagent-in-process-driver/README.md),并等待子 agent 发布。共享驱动器负责取消、深度、定制、结果读取和 dispose(资源释放)。
|
||||
|
||||
fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 含义 |
|
||||
|---|---|
|
||||
| `providerName` | `ctx.subagents` 上的注册表名称(默认 `fork`)。 |
|
||||
运行生命周期、模型继承与深度跟踪均为共享行为,见 [`dsh-subagent-spawn-in-process`](../subagent-spawn-in-process/README.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 子 agent 历史与包络
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
子 agent 先接收由父 agent 已配平的已完成轮次构成的表层前缀,再逐字接收新的任务内容。配置的 persona 会在子 agent 的全新作用域中遮蔽提示词文本;工具限制会过滤其全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但不影响独立的指导内容。父 agent 的工具视图与权限不会被继承。可选的结构化输出请求会添加仅属于子 agent 的约定。父 agent 当前进行中的轮次会被排除。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
fork 会把保留的已完成历史复制到独立的子 agent 请求中;随后子 agent 独立累积自己的 token。persona 会改变重复提示词的成本,过滤会改变 schema 或生成 SDK 的成本,而首轮 fork 没有继承历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md))。
|
||||
|
||||
### 父 agent 工具结果(间接)
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
父 agent 只通过 `dsh-tool-subagent` 接收子 agent 自身的最终输出,不接收继承的前缀或中间工作。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
父 agent 输入会增加一个取决于数据的最终结果,并保留到压缩(compaction)为止。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。
|
||||
- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)。
|
||||
58
packages/subagent/subagent-fork-in-process/package.json
Normal file
58
packages/subagent/subagent-fork-in-process/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-fork-in-process",
|
||||
"description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/subagent/subagent-fork-in-process"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
94
packages/subagent/subagent-fork-in-process/src/index.ts
Normal file
94
packages/subagent/subagent-fork-in-process/src/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The in-process FORK subagent backend: registers a {@link SubagentProvider} on
|
||||
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a prefix of the
|
||||
* parent's session log — so the child inherits the parent's conversation context instead of
|
||||
* starting fresh. The seed ends at the last `turn/end`: the current tool-call turn is
|
||||
* unbalanced and cannot be replayed as a valid child session.
|
||||
* @module @deepseek-ai/dsh-subagent-fork-in-process
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-in-process-driver'
|
||||
|
||||
export const name = 'subagent-fork-in-process'
|
||||
// `tools` is deliberately NOT injected — same rationale as subagent-spawn-in-process: the
|
||||
// per-run structured runtime gates its capture-tool registration on `tools`
|
||||
// itself, so this backend's apply timing (and the delegation tool's position
|
||||
// in the model-visible tool list) is unchanged by structured output.
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `fork`). */
|
||||
providerName: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
providerName: z.string().default('fork'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The balanced completed-turn prefix of `parent`'s log: every event up to and including the
|
||||
* last `turn/end`. The in-flight turn is excluded; before any completed turn the child starts
|
||||
* fresh. Because live sequence numbers equal array indexes, the result remains a valid seed
|
||||
* beginning at sequence zero.
|
||||
* @param parent - the agent whose session log to slice.
|
||||
* @returns the seed events, contiguous from seq 0; empty when no turn has completed.
|
||||
*/
|
||||
function completedTurnPrefix(parent: Agent): SessionEvent[] {
|
||||
const events = parent.session.events
|
||||
const lastEnd = events.findLast(e => e.type === 'turn/end')
|
||||
if (lastEnd === undefined) return []
|
||||
// seq === array index (the append contract), so slice up to and including it.
|
||||
return events.slice(0, lastEnd.seq + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
|
||||
* in-process structured runtime), plus `toolFilter`/`persona` (scoped
|
||||
* restrict() and a scoped shadowing persona section).
|
||||
*/
|
||||
class ForkInProcessProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
||||
// Context contract: a forked child IS seeded with the parent's completed-turn prefix.
|
||||
readonly inheritsParentContext = true
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(request, {
|
||||
// Only pass a seed when there's a completed turn to inherit; an empty seed
|
||||
// is equivalent to a fresh child, so omit it to keep the session unseeded.
|
||||
...seed.length > 0 ? { seed } : {},
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(fork-continuable-prefix-reuse): no shipped composition calls this —
|
||||
// they bind fork to `backgroundMode: one-shot` because a continuable child's
|
||||
// `report` tool and prompt section precede the inherited history, defeating
|
||||
// the prefix reuse a fork exists for. Reopening needs a byte-identical child
|
||||
// system prompt and tool schemas; see issue #2124 and
|
||||
// .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
|
||||
prepareContinuable(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec> {
|
||||
// The fork prefix is captured ONCE, at creation: it becomes part of the
|
||||
// child's own durable transcript, so a later cold resume replays that
|
||||
// prefix instead of re-forking the parent's newer history.
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return Promise.resolve(seed.length > 0 ? { seed } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new ForkInProcessProvider(config.providerName))
|
||||
}
|
||||
30
packages/subagent/subagent-fork-in-process/src/invariant.ts
Normal file
30
packages/subagent/subagent-fork-in-process/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-fork-in-process`.
|
||||
* @module @deepseek-ai/dsh-subagent-fork-in-process/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork-in-process'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-fork-in-process-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as fork from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
|
||||
/**
|
||||
* The two in-process backends coexist on one context: the SAME parent agent
|
||||
* delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log),
|
||||
* and keeps working itself. This is the multi-provider coexistence the seam
|
||||
* exists for — the named registry lets one runtime hold both transports.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(Spawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(fork, { providerName: 'fork' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('multi-subagent coexistence (spawn + fork on one context)', () => {
|
||||
it('both providers register and coexist', async () => {
|
||||
const { ctx } = await setup([])
|
||||
expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn'])
|
||||
})
|
||||
|
||||
it('the same parent drives a spawn child AND a fork child, then keeps working', async () => {
|
||||
// Script order: parent turn 1, spawn child, fork child, parent turn 2.
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('parent turn one'),
|
||||
textResponse('spawn child reply'),
|
||||
textResponse('fork child reply'),
|
||||
textResponse('parent turn two'),
|
||||
])
|
||||
|
||||
// Parent does one real turn first, so the fork has a completed turn to seed.
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q1' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
// Delegate to a fresh spawn child.
|
||||
const spawnRun = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
|
||||
const spawnResult = await spawnRun.result
|
||||
expect(spawnResult.stopReason).toBe('completed')
|
||||
expect(text(spawnResult.output)).toBe('spawn child reply')
|
||||
|
||||
// Delegate to a fork child (seeded with the parent's turn-1 prefix).
|
||||
const forkRun = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
|
||||
const forkResult = await forkRun.result
|
||||
expect(forkResult.stopReason).toBe('completed')
|
||||
expect(text(forkResult.output)).toBe('fork child reply')
|
||||
|
||||
// The two children are distinct sessions, both lineage-stamped to the parent.
|
||||
const spawnChild = ctx.agents.get(spawnRun.id)!
|
||||
const forkChild = ctx.agents.get(forkRun.id)!
|
||||
expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id)
|
||||
expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id)
|
||||
expect(forkChild.session.header.parentSession).toBe(parent.session.header.id)
|
||||
// The fork child inherited the parent's prefix; the spawn child did not.
|
||||
expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true)
|
||||
|
||||
await spawnRun.dispose()
|
||||
await forkRun.dispose()
|
||||
|
||||
// The parent is unaffected and keeps working after both delegations.
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q2' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
|
||||
expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.message.content)).toBe('parent turn two')
|
||||
// The parent's OWN log never recorded the children's internal steps — its
|
||||
// only subagent-related entries would be tool/call+tool/result IF it had
|
||||
// used the tool, but here we called the service directly, so the parent log
|
||||
// is purely its own two turns.
|
||||
expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import * as fork from '../src/index.ts'
|
||||
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
|
||||
/** A bare `stop` finish that streams no content → the turn ends `completed`
|
||||
* with NO `assistant/message` of its own. */
|
||||
const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
|
||||
|
||||
/**
|
||||
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
|
||||
* real invariant service and package companions. The session contribution replays a seeded child log on
|
||||
* `session/created`, so a malformed (unbalanced) fork seed makes these tests
|
||||
* THROW — that is the regression guard for the completed-turn-prefix boundary.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(fork, { providerName: 'fork' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('dsh-subagent-fork-in-process', () => {
|
||||
it('emits subagent/start only after the seeded child is published', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
let childAtStart: ReturnType<typeof ctx.agents.get>
|
||||
ctx.on('subagent/start', (info) => {
|
||||
if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id)
|
||||
})
|
||||
|
||||
const starting = start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
expect(childAtStart).toBeUndefined()
|
||||
const run = await starting
|
||||
expect(childAtStart).toBe(ctx.agents.get(run.id))
|
||||
expect(childAtStart?.id).toBe(run.id)
|
||||
|
||||
await run.result
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => {
|
||||
// The parent has never completed a turn → empty prefix → the provider omits
|
||||
// the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
|
||||
const { ctx, parent } = await setup([textResponse('fresh child')])
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('fresh child')
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Only the child's own turn — no seeded parent turns.
|
||||
expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
expect(child.session.header.seedLength).toBeUndefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('seeds every completed parent turn through the last turn/end', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q1' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.header.seedLength).toBe(parentPrefixLen)
|
||||
expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end')
|
||||
expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('seeds the child with the parent\'s completed-turn prefix (child inherits context)', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('child answer')
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The child's log STARTS with the parent's prefix (seeded), then its own turn.
|
||||
expect(child.session.events.length).toBeGreaterThan(parentPrefixLen)
|
||||
// The seeded prefix carried the parent's user message.
|
||||
const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message')
|
||||
expect(seededUser).toBeDefined()
|
||||
// Lineage stamped.
|
||||
expect(child.session.header.parentSession).toBe(parent.session.header.id)
|
||||
// The seed boundary is recorded on the header (= the seeded prefix length),
|
||||
// so a reload / replay harness can tell the inherited prefix from the
|
||||
// child's own events.
|
||||
expect(child.session.header.seedLength).toBe(parentPrefixLen)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => {
|
||||
// Drive the parent so it has one completed turn, then start a SECOND turn that is still
|
||||
// open (a hanging model call), and fork while it's in flight. The seed must stop after the
|
||||
// balanced first turn; including the open turn would fail invariant replay during start.
|
||||
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q1' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
// Start a second turn that hangs (open turn/start + open step, never ends).
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } }))
|
||||
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
|
||||
|
||||
// Forking now must NOT throw (the open second turn is excluded from the seed).
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('child')
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The child's seed has exactly the ONE completed parent turn (the open one excluded).
|
||||
const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end')
|
||||
// 1 from the seeded parent turn + 1 from the child's own completed turn.
|
||||
expect(seedTurnEnds.length).toBe(2)
|
||||
|
||||
parent.cancel({ kind: 'user' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('captures structured output through the shipped plugin (seeded child, driver runtime)', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('parent turn'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
])
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'warm up' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
const run = await start(ctx, 'fork', {
|
||||
prompt: [{ type: 'text', text: 'report structured' }],
|
||||
parent,
|
||||
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.structured).toEqual({ answer: 9 })
|
||||
// Run-scoped runtime: nothing stays registered after the settle.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
|
||||
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
|
||||
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
|
||||
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
|
||||
const result = await run.result
|
||||
// The child completed its own (empty) turn — completed, but with NO output
|
||||
// borrowed from the seeded parent prefix.
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(fork, { providerName: 'fork' })
|
||||
expect(ctx.subagents.list()).toEqual(['fork'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('contributes the completed-turn prefix as a continuable child\'s seed', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child answer')])
|
||||
const provider = ctx.subagents.getProvider('fork')!
|
||||
const signal = new AbortController().signal
|
||||
|
||||
// Before any completed parent turn there is nothing to inherit, so the
|
||||
// child starts fresh rather than carrying an empty seed.
|
||||
const fresh = await provider.prepareContinuable!({
|
||||
sessionId: SessionId('continuable-fresh'),
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
expect(fresh.seed).toBeUndefined()
|
||||
|
||||
// Complete one parent turn, then the prefix is captured once at creation.
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
const seeded = await provider.prepareContinuable!({
|
||||
sessionId: SessionId('continuable-seeded'),
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
expect(seeded.seed).toBeDefined()
|
||||
const lastSeeded = seeded.seed!.at(-1)
|
||||
// The seed ends at a completed turn, so it replays as a valid child log.
|
||||
expect(lastSeeded?.type).toBe('turn/end')
|
||||
expect(seeded.seed!.map(event => event.seq)).toEqual(seeded.seed!.map((_event, index) => index))
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in fork).toBe(false)
|
||||
expect(fork.name).toBe('subagent-fork-in-process')
|
||||
expect(fork.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(fork)
|
||||
expect(unwrapped.name).toBe('subagent-fork-in-process')
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
36
packages/subagent/subagent-fork-in-process/tsconfig.json
Normal file
36
packages/subagent/subagent-fork-in-process/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-in-process-driver"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user