瀏覽代碼

refactor: hide remaining subagent helpers

Tianyi Cui 2 月之前
父節點
當前提交
65521b589f

+ 1 - 1
docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md

@@ -38,7 +38,7 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship
 
 
 ### The subagent conversation-history descriptor
 ### The subagent conversation-history descriptor
 
 
-`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md).
+`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag: the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md).
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 

+ 1 - 1
docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md

@@ -4,7 +4,7 @@ Status: implemented
 
 
 ## Problem
 ## Problem
 
 
-[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule.
+[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule.
 
 
 The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)).
 The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)).
 
 

+ 1 - 1
docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md

@@ -17,7 +17,7 @@ Record two scenarios against the real API, both replayed keyless in the default
 
 
 ### Why a completed turn-1 is required
 ### Why a completed turn-1 is required
 
 
-The fork backend seeds the child with the parent's **balanced completed-turn prefix** ([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork)). A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes.
+The fork backend seeds the child with the parent's **balanced completed-turn prefix**. A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes.
 
 
 ## Consequences
 ## Consequences
 
 

+ 1 - 1
packages/subagent/subagent-fork/README.md

@@ -6,7 +6,7 @@ The fork provider creates an in-process child seeded with the parent's completed
 
 
 The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session.
 The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session.
 
 
-Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn.
+Fork therefore computes the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn.
 
 
 The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.
 The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.
 
 

+ 1 - 1
packages/subagent/subagent-fork/src/index.ts

@@ -54,7 +54,7 @@ export const Config: z<Config> = z.object({
  * @param parent - the agent whose session log to slice.
  * @param parent - the agent whose session log to slice.
  * @returns the seed events, contiguous from seq 0; empty when no turn has completed.
  * @returns the seed events, contiguous from seq 0; empty when no turn has completed.
  */
  */
-export function completedTurnPrefix(parent: Agent): SessionEvent[] {
+function completedTurnPrefix(parent: Agent): SessionEvent[] {
   const events = parent.session.events
   const events = parent.session.events
   const lastEnd = events.findLast(e => e.type === 'turn/end')
   const lastEnd = events.findLast(e => e.type === 'turn/end')
   if (lastEnd === undefined) return []
   if (lastEnd === undefined) return []

+ 18 - 24
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -14,7 +14,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import * as fork from '../src/index.ts'
 import * as fork from '../src/index.ts'
 import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
 import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
-import { completedTurnPrefix } from '../src/index.ts'
 
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
 
@@ -52,28 +51,6 @@ function text(blocks: { type: string; text?: string }[]): string {
   return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
   return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
 }
 }
 
 
-describe('completedTurnPrefix', () => {
-  it('returns an empty prefix for a parent that has never completed a turn', async () => {
-    const { parent } = await setup([])
-    expect(completedTurnPrefix(parent)).toEqual([])
-  })
-
-  it('returns the balanced prefix up to and including the last turn/end', async () => {
-    const { parent } = await setup([textResponse('first'), textResponse('second')])
-    parent.send([{ type: 'text', text: 'q1' }])
-    await parent.whenIdle()
-    parent.send([{ type: 'text', text: 'q2' }])
-    await parent.whenIdle()
-
-    const prefix = completedTurnPrefix(parent)
-    // Ends exactly at the last turn/end; seq is contiguous from 0.
-    expect(prefix.at(-1)?.type).toBe('turn/end')
-    expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i))
-    // Both completed turns are present.
-    expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2)
-  })
-})
-
 describe('dsh-subagent-fork', () => {
 describe('dsh-subagent-fork', () => {
   it('emits subagent/start only after the seeded child is published', async () => {
   it('emits subagent/start only after the seeded child is published', async () => {
     const { ctx, parent } = await setup([textResponse('child answer')])
     const { ctx, parent } = await setup([textResponse('child answer')])
@@ -96,7 +73,6 @@ describe('dsh-subagent-fork', () => {
     // The parent has never completed a turn → empty prefix → the provider omits
     // The parent has never completed a turn → empty prefix → the provider omits
     // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
     // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
     const { ctx, parent } = await setup([textResponse('fresh child')])
     const { ctx, parent } = await setup([textResponse('fresh child')])
-    expect(completedTurnPrefix(parent)).toEqual([])
     const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
     const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
     const result = await run.result
     const result = await run.result
     expect(result.stopReason).toBe('completed')
     expect(result.stopReason).toBe('completed')
@@ -104,6 +80,24 @@ describe('dsh-subagent-fork', () => {
     const child = ctx.agents.get(run.id)!
     const child = ctx.agents.get(run.id)!
     // Only the child's own turn — no seeded parent turns.
     // Only the child's own turn — no seeded parent turns.
     expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1)
     expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1)
+    expect(child.session.header.seedLength).toBeUndefined()
+    await run.dispose()
+  })
+
+  it('seeds every completed parent turn through the last turn/end', async () => {
+    const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
+    parent.send([{ type: 'text', text: 'q1' }])
+    await parent.whenIdle()
+    parent.send([{ type: 'text', text: 'q2' }])
+    await parent.whenIdle()
+    const parentPrefixLen = parent.session.events.length
+
+    const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
+    await run.result
+    const child = ctx.agents.get(run.id)!
+    expect(child.session.header.seedLength).toBe(parentPrefixLen)
+    expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end')
+    expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2)
     await run.dispose()
     await run.dispose()
   })
   })
 
 

+ 2 - 2
packages/subagent/tool-subagent/src/index.ts

@@ -161,13 +161,13 @@ function stopReasonError(result: SubagentResult): string | undefined {
  * A fresh child needs a standalone prompt; a forked child already sees the
  * A fresh child needs a standalone prompt; a forked child already sees the
  * conversation's completed turns — telling the model to restate everything
  * conversation's completed turns — telling the model to restate everything
  * (or, worse, that the child "does not see this conversation") would be false
  * (or, worse, that the child "does not see this conversation") would be false
- * for a fork. Exported for tests.
+ * for a fork.
  * @param inheritsConversation - whether the child's conversation is seeded
  * @param inheritsConversation - whether the child's conversation is seeded
  *   with the parent's completed turns; this says nothing about tool, service,
  *   with the parent's completed turns; this says nothing about tool, service,
  *   scope, or authority inheritance.
  *   scope, or authority inheritance.
  * @returns the tool `description` and the `prompt` parameter description.
  * @returns the tool `description` and the `prompt` parameter description.
  */
  */
-export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
+function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
   if (inheritsConversation) {
   if (inheritsConversation) {
     return {
     return {
       description:
       description: