docs(subagent): update package READMEs for the activation lifecycle
Rewrites the service API table, authority-versus-provenance contract, residency routing, and deferred-work list; scopes the in-process driver README to one-shot runs; and restates both model-facing tools' outputs, which no longer carry a task id.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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-inprocess/README.md
|
||||
README.md: 8d266e93021285e27e7819386a4de9c33492a796
|
||||
README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0
|
||||
README.md: 0495b7cae003a8c280689c4bfdd991e0f6950569
|
||||
README.zh.md: 2e512ffd281c6334db925c97b110934bbcc19eef
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here.
|
||||
This package is the shared run driver for the two in-process providers' one-shot delegations. 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. Continuable children never come through this driver: the continuation manager in `@deepseek-ai/dsh-subagent` composes and drives them directly, so this driver owns exactly one turn with one result.
|
||||
|
||||
## Start contract
|
||||
|
||||
@@ -11,28 +11,20 @@ This package is the shared run driver for the two in-process providers. Spawn pa
|
||||
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. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens.
|
||||
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result and require its participation result to be `true`. This final confirmation retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior.
|
||||
6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records.
|
||||
2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
|
||||
3. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output.
|
||||
|
||||
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.
|
||||
|
||||
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
|
||||
|
||||
## Cold resume
|
||||
|
||||
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start.
|
||||
|
||||
## 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.
|
||||
|
||||
Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。
|
||||
本包是两个进程内提供方一次性委派共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。可继续子 agent 绝不通过本驱动器:`@deepseek-ai/dsh-subagent` 中的继续执行管理器会直接组合并驱动它们,因此本驱动器只拥有一个轮次和一个结果。
|
||||
|
||||
## 启动契约
|
||||
|
||||
@@ -11,28 +11,19 @@
|
||||
驱动器按以下顺序运行:
|
||||
|
||||
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
|
||||
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
|
||||
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。
|
||||
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。
|
||||
6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。
|
||||
2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
|
||||
3. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
|
||||
## 冷恢复
|
||||
|
||||
`resumeInProcessRun(request): Promise<SubagentRun>` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。
|
||||
|
||||
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
|
||||
|
||||
运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。
|
||||
|
||||
## Spawn 与 fork 输入
|
||||
|
||||
`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
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'
|
||||
@@ -120,28 +120,6 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('confirmed steering rejects delivery once the structured result is captured', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
// oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable.
|
||||
let run: Awaited<ReturnType<typeof ctx.subagents.start>> | undefined
|
||||
let delivery: Promise<void> | undefined
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.header.parentSession === undefined || run === undefined
|
||||
|| event.type !== 'tool/result' || delivery !== undefined) return
|
||||
delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
|
||||
void delivery?.catch(() => undefined)
|
||||
})
|
||||
run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
if (delivery === undefined) throw new Error('structured result did not submit steering')
|
||||
await expect(delivery)
|
||||
.rejects.toThrow(/already reported its structured result; the message was not delivered/)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
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
|
||||
|
||||
@@ -2,17 +2,16 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
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 InvariantService 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 SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } 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 { resumeInProcessRun, startInProcessRun } from '../src/index.ts'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
@@ -39,22 +38,6 @@ function request(parent: Agent, signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
|
||||
}
|
||||
|
||||
function continuableRequest(parent: Agent) {
|
||||
const sessionId = SessionId('continuable-child')
|
||||
return {
|
||||
...request(parent),
|
||||
continuation: {
|
||||
sessionId,
|
||||
descriptor: {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
provider: 'spawn',
|
||||
agentProvider: 'mock',
|
||||
agentModel: 'mock',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function text(blocks: readonly { type: string; text?: string }[]): string {
|
||||
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
@@ -88,110 +71,7 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects a continuable child when no durability listener is registered', async () => {
|
||||
const { parent } = await setup([textResponse('driver answer')])
|
||||
|
||||
const run = await startInProcessRun(continuableRequest(parent), {})
|
||||
const caught: unknown = await run.result.catch((error: unknown) => error)
|
||||
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
const durabilityError = caught as SubagentError
|
||||
expect(durabilityError.code).toBe('DURABILITY_FAILED')
|
||||
expect(durabilityError.message).toContain('required durability checkpoint has no registered listener')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects when the durability listener disappears before final confirmation', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
let flushes = 0
|
||||
let detach = (): void => {}
|
||||
detach = ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined) return
|
||||
flushes++
|
||||
if (flushes === 1) detach()
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(continuableRequest(parent), {})
|
||||
const caught: unknown = await run.result.catch((error: unknown) => error)
|
||||
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
const durabilityError = caught as SubagentError
|
||||
expect(durabilityError.code).toBe('DURABILITY_FAILED')
|
||||
expect(durabilityError.message).toContain('required durability checkpoint has no registered listener')
|
||||
expect(flushes).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('requires a final durability checkpoint for a continuable child', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
const failure = new Error('disk full')
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined) return
|
||||
flushes++
|
||||
throw failure
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(continuableRequest(parent), {})
|
||||
const caught: unknown = await run.result.catch((error: unknown) => error)
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
const durabilityError = caught as SubagentError
|
||||
expect(durabilityError.code).toBe('DURABILITY_FAILED')
|
||||
expect(durabilityError.cause).toBe(failure)
|
||||
expect(durabilityError.message).toContain(
|
||||
'the latest child state was not confirmed persisted and may be unavailable or stale on resume: disk full',
|
||||
)
|
||||
expect(flushes).toBe(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('completes a continuable child when the final checkpoint retries a transient flush failure', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined) return
|
||||
flushes++
|
||||
if (flushes === 1) throw new Error('temporary append failure')
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(continuableRequest(parent), {})
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
expect(flushes).toBe(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ checkpoint: 'succeeds', failure: undefined },
|
||||
{ checkpoint: 'fails', failure: new Error('disk full') },
|
||||
])('lets cancellation own the result when the final durability checkpoint $checkpoint', async ({ failure }) => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
const checkpointStarted = Promise.withResolvers<undefined>()
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session.header.parentSession === undefined) return
|
||||
flushes++
|
||||
if (flushes !== 2) return
|
||||
checkpointStarted.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
if (failure !== undefined) throw failure
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
const run = await startInProcessRun({
|
||||
...continuableRequest(parent),
|
||||
signal: controller.signal,
|
||||
}, {})
|
||||
await checkpointStarted.promise
|
||||
controller.abort()
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
expect(flushes).toBe(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps foreground runs best-effort when their turn checkpoint fails', async () => {
|
||||
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) => {
|
||||
@@ -336,69 +216,18 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('rejects an already-aborted resume before publication', async () => {
|
||||
const { parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
controller.abort('too late')
|
||||
await expect(resumeInProcessRun({
|
||||
sessionId: SessionId('resumed-child'),
|
||||
prompt: [{ type: 'text', text: 'continue' }],
|
||||
source: { kind: 'user' },
|
||||
parent,
|
||||
signal: controller.signal,
|
||||
descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' },
|
||||
})).rejects.toThrow('aborted before child publication')
|
||||
})
|
||||
|
||||
it('resumes without inventing undeclared agent model options', async () => {
|
||||
const childId = SessionId('resumed-child')
|
||||
let flushes = 0
|
||||
const child = {
|
||||
id: childId,
|
||||
options: {},
|
||||
session: new Session(childId),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: {
|
||||
sessions: {
|
||||
flush: () => {
|
||||
flushes++
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
},
|
||||
} as unknown as Context,
|
||||
send(): void {},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
updateInbox: () => 'not-found',
|
||||
followup(): void {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } },
|
||||
inject(): void {},
|
||||
cancel(): void {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
let resumedOptions: unknown
|
||||
const parent = {
|
||||
ctx: {
|
||||
agents: {
|
||||
resume: (options: { agentOptions: unknown }) => {
|
||||
resumedOptions = options.agentOptions
|
||||
return Promise.resolve({ agent: child, dispose: () => Promise.resolve() })
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = await resumeInProcessRun({
|
||||
sessionId: childId,
|
||||
prompt: [{ type: 'text', text: 'continue' }],
|
||||
source: { kind: 'plugin', plugin: 'test-coordinator' },
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' },
|
||||
})
|
||||
expect(resumedOptions).toEqual({})
|
||||
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' })
|
||||
expect(flushes).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -461,104 +290,4 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await run.result
|
||||
await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }))
|
||||
.rejects.toThrow(/not running; the message was not delivered/)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('confirmed steering rejects when a concluding tool prevents request admission', async () => {
|
||||
const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})])
|
||||
const enteredTool = Promise.withResolvers<undefined>()
|
||||
const releaseTool = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'finalize',
|
||||
description: 'Finish the child run.',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
enteredTool.resolve(undefined)
|
||||
await releaseTool.promise
|
||||
exec.concludeTurn()
|
||||
return [{ type: 'text', text: 'final' }]
|
||||
},
|
||||
}))
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredTool.promise
|
||||
|
||||
const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' })
|
||||
releaseTool.resolve(undefined)
|
||||
await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/)
|
||||
await run.result
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('confirmed steering fulfills only after the next request snapshot admits it', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const enteredStopping = Promise.withResolvers<undefined>()
|
||||
const releaseStopping = Promise.withResolvers<undefined>()
|
||||
let held = false
|
||||
ctx.on('agent/turn-stopping', (agent) => {
|
||||
if (agent.session.header.parentSession === undefined || held) return
|
||||
held = true
|
||||
enteredStopping.resolve(undefined)
|
||||
return releaseStopping.promise
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredStopping.promise
|
||||
|
||||
let settled = false
|
||||
const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' })
|
||||
.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
releaseStopping.resolve(undefined)
|
||||
await delivery
|
||||
|
||||
const result = await run.result
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step')
|
||||
expect((result.output[0] as { text?: string }).text).toBe('second')
|
||||
const steering = child.session.events.find(event => event.type === 'steering/message')
|
||||
expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('carries steering from a non-terminal flush window into a tracked next turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const enteredFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFlush = Promise.withResolvers<undefined>()
|
||||
let held = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined || held) return
|
||||
if (!session.events.some(event => event.type === 'turn/end')) return
|
||||
held = true
|
||||
enteredFlush.resolve(undefined)
|
||||
return releaseFlush.promise
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredFlush.promise
|
||||
expect(child.status).toBe('running')
|
||||
|
||||
const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' })
|
||||
releaseFlush.resolve(undefined)
|
||||
await delivery
|
||||
const result = await run.result
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect((result.output[0] as { text?: string }).text).toBe('second')
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user