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:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -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-in-process-driver/README.md
README.md: 47a5c09fc1c80c5dc3062be82e7355b874a627d3
README.zh.md: bcd6a2cf31c351722dfefe55e8b8ff75d4252c40

View File

@@ -0,0 +1,116 @@
# @deepseek-ai/dsh-subagent-in-process-driver
English | [中文](README.zh.md)
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here.
## Start contract
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills only after the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, so the caller never receives a half-created handle.
The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output.
The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
## Cancellation and ownership
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
## Spawn and fork inputs
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output.
Depth enforcement is internal to `startInProcessRun`: it reads the parent depth via `delegationDepthOf` (the persisted `SessionHeader.delegationDepth` is authoritative; runtime `AgentOptions.subagentDepth` may deepen but never lower it, so a resumed child keeps its budget), treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. The child depth is written to the child header, so it survives persistence and resume.
## Structured output
`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope:
- A `structured_output` tool registered with the requested schema validates and stages the model's value.
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
- Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child.
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
- A monotonic tool guard blocks later calls after capture, and the structured-output execution's `concludeTurn()` marker ends the turn after the result commits.
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.
## Model Experience
### Child-agent request
#### What the model sees
The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and Code Mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed.
#### Token effect
Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance.
#### KV Cache effect
Independent of the parent request cache. The child's later history is append-only, while persona, tool-filter, generated-SDK, provider, or model changes establish a different child prefix.
### Structured-output system prompt, schema, and results
#### What the model sees
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
##### Structured-output instruction
```markdown
When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result.
```
#### Token effect
Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result.
#### KV Cache effect
Prefix-stable inside the child while the structured-output instruction and schema are unchanged. Changing the schema or capability may invalidate the child's cache from that early segment; results append in child and parent histories.
### Parent start error, indirectly
#### What the model sees
Through `dsh-tool-subagent`, invalid depth state becomes exactly `Error: agent subagentDepth must be a non-negative safe integer`, `Error: subagent child depth exceeds the safe-integer range`, or `Error: subagent depth <attempted> exceeds maxDepth <max>`. A pre-publication cancellation passes its abort reason through the registry's `Error: <message>` wrapper.
#### Token effect
Zero tokens on a successful start; only the failed parent tool call retains this text.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Parent result, indirectly
#### What the model sees
The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result.
#### Token effect
The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session.
#### 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
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime.

View File

@@ -0,0 +1,116 @@
# @deepseek-ai/dsh-subagent-in-process-driver
[English](README.md) | 中文
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。
## 启动约定
`startInProcessRun(request, options): Promise<SubagentRun>` 只在子 agent 发布到 `ctx.agents` 后才兑现。启动被拒绝时,agent 工厂的未发布创建事务已经完全停稳,因此调用方绝不会收到创建到一半的句柄。
驱动器按以下顺序运行:
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
## 取消与所有权
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过经记忆化的完全停稳事务停止循环、移除 agent 和会话,并撤销作用域内的注册。取消流程会接管所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
## spawn 与 fork 输入
`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供已配平的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。
深度强制在 `startInProcessRun` 内部完成:它通过 `delegationDepthOf` 读取父 agent 深度(持久化的 `SessionHeader.delegationDepth` 具有权威性;运行时 `AgentOptions.subagentDepth` 可以加深但绝不能降低该值,因此恢复后的子 agent 会保留预算),缺失值按顶层深度零处理,拒绝格式错误的存储值,并报告尝试的子 agent 深度超过 `maxDepth`。超过安全整数范围、无法表示的深度会触发 `RangeError`。子 agent 深度写入子 agent header,因此会在持久化和恢复后保留。
## 结构化输出
`attachStructuredRuntime(childCtx, schema)` 会在子 agent 作用域中安装完整约定:
- 使用请求 schema 注册的 `structured_output` 工具会校验并暂存模型值。
- 一个顺序为 190 的系统提示词段会告诉子 agent,该工具调用就是终态答案。
- 两项贡献都是普通的子 agent 作用域注册。专家级 `system-prompt/assemble` 监听器可以替换它们,因此负责为该子 agent 保留结构化输出协议。
- `tools/result` 观察器只会在该次执行的权威最终工具结果成功后提交暂存值;Code Mode 子分派外层的 `run_code` 结果也包括在内。
- 单调工具防护会在捕获值后阻止后续调用,结构化输出执行的 `concludeTurn()` 标记则在结果提交后结束轮次。
正常结束却始终未提交必需结构化值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。
## 模型体验
### 子 agent 请求
#### 模型看到的内容
共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行和 Code Mode SDK 绑定。父 agent 的限制不会被继承,独立的工具指导段仍会保留。spawn 不提供历史;fork 提供平衡的初始内容。
#### Token 影响
子 agent 输入与父 agent 隔离,并通过子 agent 自身的步骤增长。persona 会改变重复提示词文本;过滤会改变 schema 或生成 SDK 的成本,但不影响独立注册的指导内容。
#### KV Cache 影响
与父 agent 请求缓存相互独立。子 agent 后续历史仅追加,而 persona、工具过滤、生成 SDK、提供方或模型变化会建立不同的子 agent 前缀。
### 结构化输出系统提示词、schema 与结果
#### 模型看到的内容
结构化运行会添加下方的结构化输出指令。它还会添加子 agent 作用域的 `structured_output` 定义,其精确描述为 `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.`,参数使用请求的 schema。该仅运行时存在的定义不在已生成并随产品发布的[工具包索引](../../../docs/tool-catalog.md#tool-package-map)中。其规范确认值是 `{ recorded: true }`,渲染为 `Structured output recorded.`;后续调用会变为 ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``。
##### 结构化输出指令
```markdown
When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result.
```
#### Token 影响
固定指令和能力产生的 token 开销仅由该子 agent 承担。结果文本进入子 agent 历史,而只有捕获的值会成为父 agent 结果。
#### KV Cache 影响
只要结构化输出指令和 schema 不变,子 agent 内部的前缀就保持稳定。更改 schema 或能力可能从该早期片段开始使子 agent 缓存失效;结果会分别追加到子 agent 和父 agent 历史中。
### 父 agent 启动错误(间接)
#### 模型看到的内容
通过 `dsh-tool-subagent`,无效深度状态会精确变为 `Error: agent subagentDepth must be a non-negative safe integer`、`Error: subagent child depth exceeds the safe-integer range` 或 `Error: subagent depth <attempted> exceeds maxDepth <max>`。发布前取消的中止原因会通过注册表的 `Error: <message>` 包装传递。
#### Token 影响
启动成功时为零 token;只有失败的父 agent 工具调用会保留这段文本。
#### KV Cache 影响
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### 父 agent 结果(间接)
#### 模型看到的内容
驱动器只提取子 agent 自身最后的 assistant 输出或捕获的结构化值;作为初始内容的父 agent 消息和子 agent 中间工作不会成为结果。
#### Token 影响
父 agent 通过消费方接收一个依赖数据的结果;其他所有子 agent token 都留在子 agent 会话中。
#### KV Cache 影响
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
- **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。

View File

@@ -0,0 +1,63 @@
{
"name": "@deepseek-ai/dsh-subagent-in-process-driver",
"description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)",
"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-in-process-driver"
},
"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-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@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-agent-presets": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,233 @@
/**
* Shared driver for in-process ONE-SHOT subagent providers. The agent factory's
* creation transaction owns unpublished setup and rollback; after publication
* the returned AgentHandle is the one quiescent lifecycle owner held by the
* provider's caller.
*
* Continuable children never come through here: the continuation manager
* composes and drives them directly, so this driver owns exactly one turn with
* one result.
*
* @module @deepseek-ai/dsh-subagent-in-process-driver
*/
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import {
appendDelegatedPolicyOverrides,
applyChildComposition,
assertSubagentMaxDepth,
captureDelegatedPolicyOverrides,
childSessionMeta,
finalAssistantOutput,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
import type {
ResolvedSubagentStartRequest,
SubagentDescriptorData,
SubagentResult,
SubagentRun,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import {
attachStructuredRuntime,
type StructuredAttachment,
} from './structured.ts'
export {
STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION,
} from './structured.ts'
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
case 'completed':
return 'completed'
case 'max-tokens':
return 'max-tokens'
case 'aborted':
return 'aborted'
// A pre-step rejection discarded the claimed prompt: the task was
// declined, and the caller must not read the run as done.
case 'blocked':
return 'refusal'
case 'error':
case 'interrupted':
default:
return 'error'
}
}
/** Extra inputs the spawn and fork providers supply to the shared driver. */
export interface InProcessRunOptions {
/** Completed-turn seed for fork, or undefined for a fresh spawn. */
readonly seed?: SessionEvent[]
}
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/** Append one one-shot descriptor inside the child's initial turn before its first request. */
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false
childCtx.on('agent/pre-step', async ({ agent }, next) => {
const decision = await next()
if (!appended && decision.kind === 'enter') {
appended = true
agent.session.append('subagent/descriptor', descriptor)
}
return decision
})
}
/**
* Establish and drive one in-process one-shot child. Fulfillment means the agent
* is already published in the registry and transfers its turn, cancellation,
* and disposal work through the returned run. Rejection means the agent
* factory's unpublished creation transaction reached quiescence without
* publishing a child. Every start appends its resolved descriptor inside the
* child's initial turn.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a published holder-owned run.
*/
export async function startInProcessRun(
request: ResolvedSubagentStartRequest,
options: InProcessRunOptions,
): Promise<SubagentRun> {
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const childDepth = resolveChildDepth(parent, request.maxDepth)
const childId = SessionId(randomUUID())
const seed = options.seed
const activationBoundary = seed?.length ?? 0
// Capture before the first await: a later parent switch belongs to the
// parent's future.
const inherited = captureDelegatedPolicyOverrides(parent)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
applyChildComposition(childCtx, parent, {
persona: request.persona,
toolFilter: request.toolFilter,
})
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
attachDescriptorAppend(childCtx, request.descriptor)
}
const handle = await parent.ctx.agents.create({
sessionId: childId,
meta: childSessionMeta(parent, childDepth, activationBoundary),
...seed !== undefined ? { seed } : {},
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
signal: request.signal,
setup,
})
return drivePublishedRun(
handle,
request.signal,
request.prompt,
childId,
activationBoundary,
structured,
)
}
/**
* Wrap a published child in the single run lifecycle that owns signal handoff,
* one turn, result settlement, and quiescent disposal.
*/
function drivePublishedRun(
handle: AgentHandle,
signal: AbortSignal,
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
structured: StructuredAttachment | undefined,
): SubagentRun {
const child = handle.agent
const flags = { cancelled: false }
const onAbort = (): void => {
flags.cancelled = true
child.cancel({ kind: 'parent' })
}
signal.addEventListener('abort', onAbort, { once: true })
// Agent creation detaches its creation-only listener before returning. The
// post-registration check closes that handoff without treating an already
// published child as a failed start.
if (signal.aborted) onAbort()
const result: Promise<SubagentResult> = (async () => {
try {
if (!flags.cancelled) {
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
await child.whenIdle()
}
return readResult(
child,
boundary,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
)
} finally {
signal.removeEventListener('abort', onAbort)
}
})()
return {
id: childId,
localAgent: child,
result,
async dispose(): Promise<void> {
signal.removeEventListener('abort', onAbort)
flags.cancelled = true
const settlements = await Promise.allSettled([handle.dispose(), result])
const disposal = settlements[0]
// The result channel owns run faults; disposal reports only failure to
// release the published handle after both operations settle.
if (disposal.status === 'rejected') throw disposal.reason
},
}
}
/** Read one settled child's result from events after its activation boundary. */
function readResult(
child: Agent,
boundary: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(boundary)
// `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its
// awaited first turn almost immediately, and the owner's own teardown is the
// `cancelled` flag below. A cancellation with no accounting turn resolves
// `error` through `toStopReason(undefined)`, which never overstates success.
const lastEnd = foldConsumedWork(own).end
// The seam's canonical selection rule; a partial answer survives cancel and truncation.
const output: ContentBlock[] = finalAssistantOutput(own) ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded
if (structured !== undefined) {
if (structured.captured !== undefined) {
return { output, structured: structured.captured.value, stopReason }
}
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
}
return { output, stopReason }
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-in-process-driver`.
* @module @deepseek-ai/dsh-subagent-in-process-driver/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-in-process-driver'
/** Cordis companion plugin name. */
export const name = 'subagent-in-process-driver-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 */

View File

@@ -0,0 +1,142 @@
/**
* Child-scoped structured-output tool, prompt instruction, terminal guard, and authoritative
* result capture for in-process subagents. Each child registers its real schema on its own
* scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt
* contribution is ordinary reconstructed request state.
*
* Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also
* waits for the enclosing `run_code` result. The terminal result marker and monotonic tool
* guard prevent later calls from reopening a completed structured run.
* @module @deepseek-ai/dsh-subagent-in-process-driver/structured
*/
import type { Context } from '@deepseek-ai/cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/**
* The instruction registered as the child's trailing (order-190, the end of
* the tool-guidance band) scoped prompt section: the demand travels with the
* tool, as ordinary prompt state of exactly one agent.
*/
export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
+ 'Do not finish with a plain text answer: only the tool call counts as your result.'
/** One structured run's live handle: read the captured value once the child settles. */
export interface StructuredAttachment {
/**
* The captured value, once the child called the tool with valid arguments
* and the authoritative final tool result accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
captured(): { value: unknown } | undefined
}
/**
* Attach the scoped capture tool, instruction, and enforcement to a child during
* its creation window. Child disposal removes every registration.
* @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the trusted, already-asserted schema subset to enforce (see
* `assertObjectJsonSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSchema): StructuredAttachment {
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* authoritative `tools/result` notification. The execution object's identity
* uniquely identifies a trip through the pipeline: adapter call ids may
* repeat across steps, but another execution can never reach this WeakMap
* entry. This is distinct from the opaque `ToolExecutionToken` used to
* correlate nested transports. The final notification always deletes its own
* stage, whether the result succeeded or failed.
*/
const staged = new WeakMap<ToolExecution, { value: unknown }>()
/** Successful nested capture waiting for its enclosing transport to commit. */
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
let captured: { value: unknown } | undefined
const schemaEntry: ToolSchema = {
name: STRUCTURED_OUTPUT_TOOL,
description:
'Report your final structured result. Call this exactly once, when your answer is complete; '
+ 'the arguments must match this tool\'s parameter schema exactly.',
// ToolSchema.parameters is the wire-level JSON Schema object; the
// asserted subset type is structurally exactly that.
parameters: schema as unknown as Record<string, unknown>,
}
childCtx.tools.register({
...schemaEntry,
output: {
schema: {
type: 'object',
properties: { recorded: { type: 'boolean', const: true } },
required: ['recorded'],
additionalProperties: false,
},
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
},
execute(args: unknown, exec: ToolRunContext): Promise<{ recorded: true }> {
const violations = validateJsonSchemaValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Two-phase commit, keyed by THIS execution: later transformable
// waterfalls may still turn the success into an error. ToolRuntime has
// already frozen model-bound arguments at the actual input boundary.
staged.set(exec, { value: args })
exec.concludeTurn()
return Promise.resolve({ recorded: true })
},
})
childCtx.systemPrompt.section({
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
order: 190,
text: STRUCTURED_OUTPUT_INSTRUCTION,
})
// Terminal WITHIN the step. Guards run after the whole pre-execute
// waterfall and compose monotonically (deny or abstain, never allow), so a
// later prepended listener cannot resurrect dispatch. Calls that precede
// capture in the same response remain untouched.
childCtx.tools.guard(exec => captured === undefined && pending === undefined
? undefined
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
// The capture COMMIT observes the immutable, authoritative result after the
// complete pipeline and outer error normalization. This notification cannot
// transform the outcome, so there is no wrapper outside the commit verdict.
childCtx.on('tools/result', function (this: unknown, exec, result) {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
const entry = staged.get(exec)
if (entry === undefined) return
staged.delete(exec)
if (result.isError) return
if (exec.parent === undefined) {
/* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
if (captured === undefined) captured = { value: entry.value }
} else {
/* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */
if (captured === undefined && pending === undefined) {
pending = { parent: exec.parent, value: entry.value }
}
}
return
}
if (pending?.parent !== exec.token) return
const entry = pending
pending = undefined
if (result.isError) return
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
if (captured === undefined) captured = { value: entry.value }
})
return { captured: () => captured }
}

View File

@@ -0,0 +1,20 @@
// A preset row standing in for the agent-plane tool rows a real preset mounts.
// Import-free on purpose — the Loader resolves entry modules through Node's ESM
// resolver, which cannot see this workspace's TypeScript sources.
export const name = 'preset-tool'
export const inject = ['tools', 'systemPrompt']
export function apply(ctx, config) {
ctx.effect(() => ctx.tools.register({
name: config.tool,
description: `fixture tool ${config.tool}`,
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
execute: () => Promise.resolve(config.tool),
}))
ctx.effect(() => ctx.systemPrompt.section({
name: `preset:${config.tool}`,
order: 10,
text: `section for ${config.tool}`,
}))
}

View File

@@ -0,0 +1,5 @@
# Agent-plane composition: the model-facing row lives here, not in the host.
- id: only
name: ../../plugins/preset-tool.js
config:
tool: preset_only

View File

@@ -0,0 +1,6 @@
# A second agent-plane composition, so a switch is a real switch: the tool a
# joined child sees has to change with it.
- id: only
name: ../../plugins/preset-tool.js
config:
tool: reviewing_only

View File

@@ -0,0 +1,255 @@
/**
* Delegation policy through child session events appended before publication:
* the parent's sandbox override plus the pinned `approval/policy: never`.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
const contexts: Context[] = []
let workspace: string
beforeEach(async () => {
workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
})
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
await rm(workspace, { recursive: true, force: true })
})
async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
await ctx.plugin(ToolFs)
await ctx.plugin(ApprovalService)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(
SessionId('parent'),
{ provider: 'mock', model: 'mock' },
{ cwd: workspace },
)
return { ctx, parent }
}
function spawnRequest(parent: Agent) {
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal: new AbortController().signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'spawn',
label: 'child task',
}),
}
}
function toolResultTexts(agent: Agent): string[] {
return agent.session.events
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
.map(event => event.data.message.content
.flatMap(block => block.content)
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(''))
}
describe('in-process policy inheritance', () => {
it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'spawn-blocked.txt')
setSandboxMode(parent.session, 'read-only')
// No parent approval override: the child pin must not depend on one.
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
const parentLogLength = parent.session.events.length
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
const result = await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(result.stopReason).toBe('completed')
expect(child.session.events.slice(0, 2)).toMatchObject([
{ type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
{ type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
expect(child.session.header.seedLength).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
expect(ctx.approval.overrideOf(child.session)).toBe('never')
const request = child.session.events.find(
(event): event is SessionEvent<'request/header'> => event.type === 'request/header',
)
const runtimeContext = child.session.events.find(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
)
if (request === undefined || runtimeContext === undefined) throw new Error('child request lacks its runtime policy context')
expect(runtimeContext.seq).toBeLessThan(request.seq)
const contextText = runtimeContext.data.content
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
expect(contextText).toContain('Current DSH file policy: read-only')
expect(contextText).toContain('Approval prompts are disabled')
// The statement rides runtime context; the system prompt stays uniform.
expect(contextText).toContain('You are a delegated subagent')
expect(request.data.header.system).not.toContain('Approval prompts are disabled')
expect(request.data.header.system).not.toContain('You are a delegated subagent')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()
}
})
it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'fork-blocked.txt')
setSandboxMode(parent.session, 'workspace-write')
const seed = [...parent.session.events]
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), { seed })
try {
await run.result
const child = run.localAgent as Agent
expect(child.session.header.seedLength).toBe(1)
expect(child.session.firstLiveSeq).toBe(seed.length)
// seq 1 is the constructor's end-seed marker.
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
{ seq: 2, data: { mode: 'read-only', source: 'delegation' } },
])
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
setSandboxMode(child.session, 'danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
} finally {
await run.dispose()
}
})
it('captures policy at delegation before asynchronous child creation', async () => {
const script: Script = [textResponse('child done')]
const { ctx, parent } = await setupWalled(script)
setSandboxMode(parent.session, 'read-only')
const starting = startInProcessRun(spawnRequest(parent), {})
setSandboxMode(parent.session, 'danger-full-access')
const run = await starting
try {
await run.result
const child = run.localAgent as Agent
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
} finally {
await run.dispose()
}
})
it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const allowed = join(workspace, 'default-allowed.txt')
script.push(
toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
expect(await readFile(allowed, 'utf8')).toBe('fine')
expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false)
expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ seq: 0, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
} finally {
await run.dispose()
}
})
it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
// A granting answerer proves the pin resolves before any answerer runs.
let consulted = false
ctx.on('approval/request', () => {
consulted = true
return Promise.resolve('allowed-once' as const)
})
const blocked = join(workspace, 'escalation-blocked.txt')
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', {
file_path: blocked,
content: 'escaped',
sandbox_permissions: 'workspace-write',
justification: 'test escalation from a delegated child',
}),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(consulted).toBe(false)
expect(toolResultTexts(child).join('\n'))
.toContain('the user rejected escalating this operation to "workspace-write"')
const asked = child.session.events.find(
(event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
)
const decided = child.session.events.find(
(event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
)
expect(asked?.data.toolName).toBe('write')
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
} finally {
await run.dispose()
}
})
})

View File

@@ -0,0 +1,135 @@
/**
* Composition inheritance: a child runs on the preset its parent runs on.
*
* With every model-facing row on the agent plane, the tool registry's global
* layer is empty, so a child that joins no preset reaches the model with no
* tools at all. These assert the model-visible result — the schemas in the
* child's own request — rather than the join that produces it.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import AgentPresets from '@deepseek-ai/dsh-agent-presets'
import { SessionId } from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }]
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
})
/** A host composition carrying no model-facing rows, plus the preset roster. */
async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> {
const ctx = new Context()
contexts.push(ctx)
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false })
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('parent'),
agentOptions: { provider: 'mock', model: 'mock' },
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'),
})
return { ctx, adapter, parent: handle.agent }
}
/** The one-shot spawn request shape both in-process providers build. */
function spawnRequest(parent: Agent) {
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal: new AbortController().signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot' as const,
provider: 'spawn',
label: 'child task',
}),
}
}
describe('a child agent composed in-process', () => {
it('reaches the model with its parent\'s preset tools', async () => {
const { ctx, adapter, parent } = await setupPresetHost()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
const childRequest = adapter.requests.at(-1)
expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only'])
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only'])
await run.dispose()
})
it('carries its parent\'s prompt sections', async () => {
const { parent } = await setupPresetHost()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
expect(run.localAgent?.session.events.some(event =>
event.type === 'request/header'
&& JSON.stringify(event.data).includes('section for preset_only'))).toBe(true)
await run.dispose()
})
it('records the composition it ran under on the child header', async () => {
const { parent } = await setupPresetHost()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
// Without this the child's own history reads back under the deployment
// default, which is a different tool set than the one it actually used.
expect(run.localAgent?.session.header.agentPreset).toBe('coding')
await run.dispose()
})
it('honours a tool filter over the preset tools it inherited', async () => {
const { ctx, parent } = await setupPresetHost()
const run = await startInProcessRun(
{ ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } },
{},
)
await run.result
// The capability filter is the only thing bounding a delegated child, and
// every tool it can name now arrives from the preset rather than the host.
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([])
await run.dispose()
})
it('follows a parent that switched preset while blank', async () => {
const { ctx, parent } = await setupPresetHost()
// A DIFFERENT preset, so the assertion below distinguishes reading the
// parent's live scope chain from reading its creation header — re-linking
// to the same id would pass either way.
await ctx.agentPresets.recompose(parent.ctx, 'reviewing')
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only'])
expect(run.localAgent?.session.header.agentPreset).toBe('reviewing')
await run.dispose()
})
})

View File

@@ -0,0 +1,757 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
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 ResolvedSubagentStartRequest,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import {
STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_TOOL,
} from '../src/structured.ts'
const testToolSignal = new AbortController().signal
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)
}
interface CodeRunRequestLike {
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
}
interface SetupOptions {
toolMode?: ToolConfig['mode']
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
}
const SCHEMA: ObjectJsonSchema = {
type: 'object',
properties: { answer: { type: 'number' }, note: { type: 'string' } },
required: ['answer'],
}
/**
* Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading
* spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while
* this fixture isolates driver behavior and scripts the child's `structured_output` calls.
*/
async function setup(script: Script, options: SetupOptions = {}) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await mountAgentLoopTestDependencies(ctx, {
tools: { mode: options.toolMode ?? 'native' },
})
if (options.toolMode === 'code' || options.toolMode === 'both') {
ctx.provide('codeRuntime', {
language: 'typescript',
isolation: 'test',
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
} as never)
}
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
const disposeProvider = ctx.subagents.registerProvider({
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent, adapter, disposeProvider }
}
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
return {
label: 'produce the answer',
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
signal: new AbortController().signal,
outputSchema: SCHEMA,
...extra,
}
}
/** The tool names of one recorded model request. */
function toolNames(request: GenerateOptions): string[] {
return (request.tools ?? []).map(tool => tool.name)
}
describe('in-process structured output', () => {
it('captures a valid structured_output call and surfaces result.structured', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
])
let acknowledgement: unknown
ctx.on('tools/result', (exec, toolResult) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42, note: 'done' })
expect(acknowledgement).toEqual({ recorded: true })
await run.dispose()
})
it('stops the turn after a successful capture — no extra model step is spent', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
textResponse('MUST NOT BE CONSUMED'),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// The structured tool marks its successful result as turn-concluding.
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => {
// One model response carrying structured_output FIRST and a side-effecting
// call after it: the continuation veto only fires at step end, so without
// the pre-execute deny the trailing call would still run after the final
// answer was accepted.
const response = [
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 5 })
// The deny skipped dispatch entirely: the probe body never ran.
expect(sideEffectRan).toBe(false)
await run.dispose()
})
it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
const response = [
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
// runs after the waterfall and can only deny, so the body still cannot run.
ctx.on('tools/pre-execute', async (_exec, next) => {
await next()
return { kind: 'allow' as const }
}, { prepend: true })
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
expect(sideEffectRan).toBe(false)
const child = ctx.agents.get(run.id)
const sideEffectResult = child?.session.events.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === 'c2')
expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
const response = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'side_effect', arguments: '{}' } },
...toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 6 }).map(chunk =>
'index' in chunk ? { ...chunk, index: 1 } : chunk),
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
// window after the terminal answer landed.
expect(sideEffectRan).toBe(true)
expect(result.structured).toEqual({ answer: 6 })
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
// The child's log carries the isError tool/result for the invalid call.
const child = ctx.agents.get(run.id)!
const results = child.session.events.filter(e => e.type === 'tool/result')
expect(results.length).toBe(2)
expect(results[0]!.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('here is my answer in prose'),
textResponse('MUST NOT BE CONSUMED'),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// Exactly one model request and one caller-supplied user message: no nudge turn exists.
expect(adapter.requests.length).toBe(1)
const child = ctx.agents.get(run.id)!
expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1)
await run.dispose()
})
it('an errored child keeps its honest error result (no capture expected)', async () => {
// Script exhaustion on the first call → the child turn errors.
const { ctx, parent, adapter } = await setup([])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
const { ctx, parent } = await setup([textResponse('prose, no capture')])
const controller = new AbortController()
const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
// Cancel synchronously inside the turn's end recording: the cancel
// contract outranks the schema shortfall, so the result maps to aborted.
ctx.on('session/event', (session, event) => {
const child = ctx.agents.get(run.id)
if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema/)
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
const { ctx, parent } = await setup([])
// Semantic assertion runs before provider startup.
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('continues after the blocked capture'),
])
// A PostToolUse-style hook turns the tool body's provisional success into
// the authoritative final error observed by the commit notification.
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
}
return next()
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// No capture was committed: the run reports the schema shortfall...
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
// ...the logged tool result is the blocked isError with the feedback...
const child = ctx.agents.get(run.id)!
const results = child.session.events.filter(e => e.type === 'tool/result')
expect(results[0]!.data.message.content[0].isError).toBe(true)
expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook')
// ...and the turn CONTINUED past the blocked call (no captured veto):
// the model got to react to the failure with a second step.
expect(adapter.requests.length).toBe(2)
await run.dispose()
})
it('a post-execute accept-with-replacement still commits the capture', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
])
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'recorded (rewritten)' }] })
}
return next()
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 8 })
await run.dispose()
})
it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
textResponse('capture was rejected'),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after attachment and prepended, so it wraps every listener
// the child installed. It delegates first, then converts the apparent
// capture success into the pipeline's authoritative failure.
ctx.on('tools/post-execute', async (exec, _result, next) => {
const downstream = await next()
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
}, { prepend: true })
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
const child = ctx.agents.get(run.id)
const captureResult = child?.session.events.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === 'c1')
expect(captureResult?.type === 'tool/result' && captureResult.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
// A context-wide section stands in for the deployment persona: the
// instruction must APPEND to the other scoped and global sections, not
// replace them (AgentOptions has no prompt field — the instruction is an
// ordinary child-scoped prompt registration).
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests.at(-1)!
expect(childRequest.system).toContain('You are a counter.')
expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
await run.dispose()
})
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return { logs: [], value: 'captured' }
},
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 12 })
const request = adapter.requests[0]!
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
expect(request.system).toContain('interface ToolArgsMap')
expect(request.system).toContain('interface ToolOutputMap')
expect(request.system).toContain('recorded: true;')
expect(request.system).toContain('Promise<ToolOutputMap[K]>')
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
await run.dispose()
})
it('discards a nested capture when the enclosing run_code execution fails', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")', description: 'Capture then fail the program' }),
textResponse('outer code failed'),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return {
logs: [],
error: { kind: 'runtime', message: 'boom after capture' },
} as never
},
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
expect(adapter.requests).toHaveLength(2)
const child = ctx.agents.get(run.id)!
const outer = child.session.events.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === CallId('c1'))
expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
textResponse('outer code was blocked'),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return { logs: [], value: 'captured' }
},
})
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
: next())
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
expect(adapter.requests).toHaveLength(2)
await run.dispose()
})
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('parent answer'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// The loop always assembles a base prompt (the harness identity section),
// so the instruction APPENDS — never replaces.
const childSystem = adapter.requests.at(-1)!.system!
expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
await run.dispose()
})
describe('scoped registration (each child owns its capture tool)', () => {
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await parent.whenIdle()
// Scoped registration: the global view has no capture tool, ever.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
})
it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
const { ctx, parent, adapter } = await setup([
// Parent turn (a plain agent): must NOT see the tool.
textResponse('parent answer'),
// Child turn: must see it, with the run's schema.
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests[1]!
expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
expect(entry.parameters).toEqual(SCHEMA)
await run.dispose()
})
it('two concurrent structured children each see their OWN schema', async () => {
const otherSchema: ObjectJsonSchema = {
type: 'object',
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
required: ['verdict'],
}
const { ctx, parent, adapter } = await setup([
(options: GenerateOptions) => {
// Answer with whatever schema this child was given — proves each
// request carried the right one regardless of scheduling order.
const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
? { verdict: 'real' }
: { answer: 1 }
return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
},
(options: GenerateOptions) => {
const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
? { verdict: 'real' }
: { answer: 1 }
return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
},
])
const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
const [a, b] = await Promise.all([runA.result, runB.result])
expect(a.structured).toEqual({ answer: 1 })
expect(b.structured).toEqual({ verdict: 'real' })
const schemas = adapter.requests.map(request =>
request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
expect(schemas).toContainEqual(SCHEMA)
expect(schemas).toContainEqual(otherSchema)
await runA.dispose()
await runB.dispose()
})
it('places the capture tool and instruction in their canonical orders', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
// A global tool sorts lexicographically after structured_output, while a
// global section above the 190 band follows the capture instruction.
ctx.tools.register(defineContentToolFixture({
name: 'zz_probe',
description: 'probe',
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
}))
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const request = adapter.requests[0]!
const names = toolNames(request)
expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0)
expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe'))
const system = request.system ?? ''
const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)
expect(instructionAt).toBeGreaterThanOrEqual(0)
expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt)
await run.dispose()
})
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
const { parent, adapter } = await setup([textResponse('plain')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
await parent.whenIdle()
const request = adapter.requests[0]!
expect(request.tools).toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 0))
})
it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
const { ctx, parent, disposeProvider } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
])
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A backend hot-reload mid-run must not unregister the capture tool out
// from under the live child: the registration rides the CHILD's fiber.
disposeProvider()
const result = await run.result
expect(result.structured).toEqual({ answer: 4 })
const child = ctx.agents.get(run.id)!
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
await run.dispose()
// Child disposed ⇒ its scoped registrations are gone.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
})
})
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
const { ctx, parent } = await setup([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
agent: parent,
})
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
const { ctx } = await setup([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
})
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a failed execution stage is discarded and never promoted by a later call', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A prepended post-execute listener blocks the first capture without
// delegating. The final-result notification discards that execution's
// stage when it observes the error.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
blocks -= 1
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
}
return next()
}, { prepend: true })
const result = await run.result
const child = ctx.agents.get(run.id)!
// The blocked capture must NOT surface as structured success…
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// …and a LATER invalid call (its own body staged nothing) must not
// resurrect c1's discarded value: drive the pipeline directly.
const invalid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c2' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 'not-a-number' },
agent: child,
})
expect(invalid.isError).toBe(true)
// A fresh valid call still captures ITS OWN value.
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c3' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 9 },
agent: child,
})
expect(valid.isError).toBeFalsy()
await run.dispose()
})
it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Block the first capture after its body stages a value. Its final error
// discards that execution's stage.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
blocks -= 1
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
}
return next()
}, { prepend: true })
await run.result
const child = ctx.agents.get(run.id)!
// A SECOND capture call with the SAME call id whose body never stages
// (invalid args throw before the stage): the discarded value must not ride
// its acceptance.
const reused = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 'not-a-number' },
agent: child,
})
expect(reused.isError).toBe(true)
// Nothing was ever committed: a fresh valid call is still required.
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 5 },
agent: child,
})
expect(valid.isError).toBeFalsy()
await run.dispose()
})
it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Discard the first capture's stage via a final post-execute block.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
blocks -= 1
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
}
return next()
}, { prepend: true })
await run.result
const child = ctx.agents.get(run.id)!
// A prepended pre-execute deny skips the body, while the denied call still
// reaches the final notification with the same adapter-minted call id.
const offDeny = ctx.on('tools/pre-execute', (exec) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
}
return undefined as never
}, { prepend: true })
const denied = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 2 },
agent: child,
})
expect(denied.isError).toBe(true)
offDeny()
// The discarded value was never promoted: a fresh valid call is required
// (and succeeds, proving the runtime is not wedged).
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 5 },
agent: child,
})
expect(valid.isError).toBeFalsy()
await run.dispose()
})
})

View File

@@ -0,0 +1,381 @@
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { type Agent, type AgentOptions } 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, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } 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)
}
async function setup(script: Script, parentOptions: Partial<AgentOptions> = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock', ...parentOptions })
return { ctx, parent, adapter }
}
function request(parent: Agent, signal = new AbortController().signal) {
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'test',
label: 'child task',
}),
}
}
function text(blocks: readonly { type: string; text?: string }[]): string {
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('startInProcessRun', () => {
it('returns only after publication, drives a fresh child, and disposes it', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
const run = await startInProcessRun(request(parent), {})
expect(ctx.agents.get(run.id)).toBeDefined()
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('driver answer')
expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1)
await run.dispose()
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => {
const { ctx } = await setup([textResponse('driver answer')])
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' })
const run = await startInProcessRun({
...request(parent),
agentOptions: { provider: 'mock', model: 'mock' },
}, {})
const child = ctx.agents.get(run.id)!
expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' })
expect(child.session.header.cwd).toBe('/workspace')
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
await run.dispose()
})
it('reports a prompt a pre-step rejection discarded as refusal, not completion', async () => {
const { ctx, parent } = await setup([])
// A UserPromptSubmit deny or a policy plugin: the child claims its prompt,
// the rejection discards it, and the turn closes `blocked` with no step.
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
if (subject === parent) return next()
return { kind: 'reject' as const }
})
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
await run.dispose()
})
it('does not add a final durability checkpoint to a foreground run', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
let flushes = 0
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined) return
flushes++
throw new Error('disk full')
})
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(flushes).toBe(0)
await run.dispose()
})
it('keeps published run and handle disposal failures on separate channels', async () => {
const { ctx, parent } = await setup([])
const runError = new Error('published run failed')
const disposalError = new Error('published handle disposal failed')
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithFailedDisposal = {
options: parent.options,
session: parent.session,
ctx: {
get: () => undefined,
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
handle.agent.followup = () => { throw runError }
return {
...handle,
dispose: async () => {
await handle.dispose()
throw disposalError
},
}
},
},
},
} as unknown as Agent
const run = await startInProcessRun(request(parentWithFailedDisposal), {})
expect(ctx.agents.get(run.id)).toBeDefined()
await expect(run.result).rejects.toBe(runError)
await expect(run.dispose()).rejects.toBe(disposalError)
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('reports the turn outcome when later metadata is appended during flush', async () => {
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
let injected = false
ctx.on('session/flush', (session) => {
if (injected || session.header.parentSession === undefined) return
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
injected = true
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}), { surfaceOp: 'append' })
})
const run = await startInProcessRun(request(parent), {})
const result = await run.result
const child = ctx.agents.get(run.id)!
expect(injected).toBe(false)
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
// A tool-only max-tokens step records an empty assistant/message for
// usage. The result retains the preceding assistant output.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
const disposeNoop = ctx.tools.register(defineContentToolFixture({
name: 'noop', description: 'probe', parameters: {},
execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
}))
const run = await startInProcessRun(request(parent), {})
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('partial one')
await run.dispose()
disposeNoop()
})
it('seeds a forked child but reads only the child-owned output', 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 seed = parent.session.events.slice()
const run = await startInProcessRun(request(parent), { seed })
const result = await run.result
expect(text(result.output)).toBe('child answer')
const child = ctx.agents.get(run.id)!
expect(child.session.header.seedLength).toBe(seed.length)
expect(child.session.events.slice(0, seed.length)).toEqual(seed)
await run.dispose()
})
it('persists the child origin and depth in its session header', async () => {
const { ctx, parent } = await setup([textResponse('child answer')])
const run = await startInProcessRun(request(parent), {})
await run.result
// The recursion budget is durable session data, not only runtime options —
// a depth that lived only in AgentOptions would reset to 0 on resume.
expect(ctx.agents.get(run.id)!.session.header).toMatchObject({
origin: 'subagent',
delegationDepth: 1,
})
await run.dispose()
})
it('inherits the parent output-token cap and accepts an explicit child override', async () => {
const { ctx, parent, adapter } = await setup(
[textResponse('inherited'), textResponse('overridden')],
{ maxTokens: 111 },
)
const inherited = await startInProcessRun(request(parent), {})
await inherited.result
expect(adapter.requests[0]?.maxTokens).toBe(111)
expect(ctx.agents.get(inherited.id)?.options.maxTokens).toBe(111)
await inherited.dispose()
const overridden = await startInProcessRun({
...request(parent),
agentOptions: { maxTokens: 222 },
}, {})
await overridden.result
expect(adapter.requests[1]?.maxTokens).toBe(222)
expect(ctx.agents.get(overridden.id)?.options.maxTokens).toBe(222)
await overridden.dispose()
})
it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => {
// Resume rebuilds runtime options, so the durable header must keep this
// depth-1 child from delegating as though it were top-level.
const { ctx } = await setup([textResponse('unused')])
const resumed = (await ctx.agents.create({
sessionId: SessionId('resumed-child'),
meta: { parentSession: SessionId('root'), delegationDepth: 1 },
agentOptions: { provider: 'mock', model: 'mock' },
signal: new AbortController().signal,
})).agent
await expect(startInProcessRun({ ...request(resumed), maxDepth: 1 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 2, maxDepth: 1 })
})
it('lets runtime options deepen but never lower the persisted depth', async () => {
const { ctx } = await setup([textResponse('unused')])
const parent = (await ctx.agents.create({
sessionId: SessionId('deep-parent'),
meta: { delegationDepth: 2 },
agentOptions: { provider: 'mock', model: 'mock', subagentDepth: 1 },
signal: new AbortController().signal,
})).agent
// Persisted 2 vs runtime 1: the child is depth 3, so maxDepth 2 rejects.
await expect(startInProcessRun({ ...request(parent), maxDepth: 2 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 3, maxDepth: 2 })
})
it('rejects invalid and exceeded depth before publication', async () => {
const { parent } = await setup([])
await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {}))
.rejects.toThrow('non-negative safe integer')
await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError' })
for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) {
const malformed = { options: { subagentDepth: value }, session: { header: {} } } as unknown as Agent
await expect(startInProcessRun(request(malformed), {}))
.rejects.toThrow('agent subagentDepth must be a non-negative safe integer')
}
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER }, session: { header: {} } } as unknown as Agent
await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError)
})
it('rejects an already-aborted request without publishing a child', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const controller = new AbortController()
controller.abort('too late')
await expect(startInProcessRun(request(parent, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('stamps only the resolved depth when neither parent nor request declares a model route', async () => {
// The one-shot analogue of the deleted resume coverage ("resumes without
// inventing undeclared agent model options"): a bare parent with no request
// agentOptions yields a child whose options carry ONLY the stamped depth —
// no provider/model is fabricated, so the child's turn errors for want of a
// route rather than silently adopting one.
const { ctx } = await setup([])
const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {})
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
expect(child.options).toEqual({ subagentDepth: 1 })
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
await run.dispose()
})
it('uses the request signal after publication and dispose as cancellation paths', async () => {
const { parent, adapter } = await setup(['hang', 'hang'])
const controller = new AbortController()
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
// No step completed a message, so the text streamed before the abort is
// the cancelled run's output.
await expect(signalled.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial' }],
stopReason: 'aborted',
})
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } })
await signalled.dispose()
const disposed = await startInProcessRun(request(parent), {})
await new Promise(resolve => setTimeout(resolve, 30))
await disposed.dispose()
await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' })
})
it('cleans a failed unpublished setup before rejecting', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown global tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('treats abort after factory publication as a cancelled run with an id', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithAbortAtHandoff = {
options: parent.options,
session: parent.session,
ctx: {
// The driver's synchronous inheritance capture probes both policy
// services opportunistically; this stub composes neither.
get: () => undefined,
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
// `create()` has detached its creation-only listener, but the
// published run has not installed its live listener yet.
controller.abort('handoff race')
return handle
},
},
},
} as unknown as Agent
const run = await startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {})
expect(ctx.agents.get(run.id)).toBeDefined()
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
await run.dispose()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}