From cc6b9166f83f4a1f3620c0c7f14dce373dbbdcba Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 29 Aug 2026 15:28:09 -0700 Subject: [PATCH 1/2] fix: use completed history for forked subagents HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04f66-abf2-77eb-9315-490bb2bddb1e --- .../fold-core/src/Projection/Projection.ts | 6 ++- .../fold-core/src/Subagents/SubagentTool.ts | 4 ++ .../test/Projection/Projection.vi.test.ts | 8 +-- .../test/Subagents/SubagentFork.vi.test.ts | 54 ++++++------------- .../Subagents/SubagentToolWire.vi.test.ts | 36 +++++++++++++ 5 files changed, 63 insertions(+), 45 deletions(-) diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 515add4..b073a3f 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -170,8 +170,10 @@ const entriesForAgentInternal = ( (entry) => entry.seq <= fork.atSeq, ) - const inheritedEntries = - fork.history === undefined ? parentEntries : eligibleForkHistory(parentEntries, fork.history) + // Legacy fork entries did not persist a history selector. Treat them as completed-history forks too: + // the parent tool result that launched a fork cannot exist until that child finishes, so inheriting + // the active assistant tool-call row would make the child's first provider request invalid. + const inheritedEntries = eligibleForkHistory(parentEntries, fork.history ?? 'all') return [...inheritedEntries, ...ownEntries].sort(compareSeq) } diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 69d373d..914d3d9 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -178,6 +178,10 @@ export const subagentTool = ( prompt: forkCommand.prompt, skill: forkCommand.skill, forkAgentDefinitionId: options?.forkAgent?.id ?? null, + // The current parent assistant message contains this tool call, but its + // result cannot exist until this child finishes. Fork only completed + // conversation turns so the child never receives that dangling call. + history: 'all', }) .pipe( Effect.catchTag('SkillNotFoundError', (error) => diff --git a/packages/fold-core/test/Projection/Projection.vi.test.ts b/packages/fold-core/test/Projection/Projection.vi.test.ts index 7cc4343..980595e 100644 --- a/packages/fold-core/test/Projection/Projection.vi.test.ts +++ b/packages/fold-core/test/Projection/Projection.vi.test.ts @@ -276,7 +276,7 @@ it.effect('projects messages with the latest leading system message and assistan }), ) -it.effect('projects forked agents through the parent fork sequence plus child entries', () => +it.effect('projects legacy forks through completed parent history plus child entries', () => Effect.gen(function* () { const result = yield* Effect.gen(function* () { const log = yield* EventLog @@ -338,9 +338,9 @@ it.effect('projects forked agents through the parent fork sequence plus child en const projected = messagesForAgent(result.entries, result.childAgentId) - expect(projected.map((message) => message._tag)).toEqual(['system-message', 'user-message', 'user-message']) - expect(projected[1]).toMatchObject({ _tag: 'user-message', message: { content: 'parent before fork' } }) - expect(projected[2]).toMatchObject({ _tag: 'user-message', message: { content: 'child prompt' } }) + expect(projected.map((message) => message._tag)).toEqual(['system-message', 'user-message']) + expect(projected[1]).toMatchObject({ _tag: 'user-message', message: { content: 'child prompt' } }) + expect(JSON.stringify(projected)).not.toContain('parent before fork') }), ) diff --git a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts index d7a8e5d..83962db 100644 --- a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts @@ -1,8 +1,7 @@ /** - * Engine tests for fork mode (D21): the fork clones the caller - model binding, toolset, and, through - * fork-by-reference projection, its full history up to the observed head - with NO new leading system - * message, so the fork's prompt prefix is byte-identical to the caller's (the provider-cache claim, - * asserted for real against the scripted model's recorded prompts). + * Engine tests for fork mode (D21): the fork clones the caller's model binding and toolset, and inherits + * only completed conversation history. It omits the active parent tool-call turn because its result is + * the child run itself, so including it would create an invalid provider request. */ import { expect, it } from '@effect/vitest' import { Predicate, Effect } from 'effect' @@ -11,30 +10,7 @@ import { shortAgentId, type AgentStartedLogEntry, type AssistantMessageLogEntry import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { makeDriveSession, renderedDriveResult, subagentStartedEntries } from './DriveHarness' -const withoutCacheControl = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(withoutCacheControl) - if (typeof value !== 'object' || value === null) return value - - const out: Record = {} - for (const [key, nested] of Object.entries(value)) { - if (key === 'cacheControl') continue - const normalized = withoutCacheControl(nested) - if (key === 'anthropic' && typeof normalized === 'object' && normalized !== null) { - if (Object.keys(normalized).length === 0) continue - } - out[key] = normalized - } - return out -} - -const stablePromptJson = (value: unknown): string => - JSON.stringify(withoutCacheControl(value), (key, nested) => { - if (key.length === 0 || Array.isArray(nested) || typeof nested !== 'object' || nested === null) return nested - - return Object.fromEntries(Object.entries(nested).sort(([left], [right]) => left.localeCompare(right))) - }) - -it.effect('a fork clones the caller: shared history prefix, no new leading prompt, own rows after', () => +it.effect('a fork inherits completed context without its invoking tool call', () => Effect.gen(function* () { // The fork clones the ROOT, so it runs on the root's scripted model: turn 1 is the root's drive // call, turn 2 is consumed by the fork, turn 3 finishes the root. @@ -56,7 +32,9 @@ it.effect('a fork clones the caller: shared history prefix, no new leading promp if (forkStarted === undefined) throw new Error('expected the fork to have started') // Fork provenance: mode, no agentType, fromAgentId = the caller, atSeq = the observed head - // (the caller's assistant tool-call row, appended just before settlement began). + // (the caller's assistant tool-call row, appended just before settlement began). This direct + // engine call leaves `history` absent to cover legacy persisted forks, which also use the + // completed-history default. expect(forkStarted.mode).toBe('fork') expect(forkStarted.agentType).toBeNull() const rootStarted = entries.find( @@ -70,6 +48,7 @@ it.effect('a fork clones the caller: shared history prefix, no new leading promp Predicate.isTagged(entry, 'assistant-message') && entry.agentId === rootStarted.agentId, ) expect(forkStarted.fork?.atSeq).toBe(dispatchingAssistantRow?.seq) + expect(forkStarted.fork?.history).toBeUndefined() // No new leading system message for the fork: the fold carries the caller's blocks. const forkSystemMessages = entries.filter( @@ -77,18 +56,15 @@ it.effect('a fork clones the caller: shared history prefix, no new leading promp ) expect(forkSystemMessages).toHaveLength(0) - // The cache claim, for real: excluding request-local cache breakpoint metadata, the fork's first - // request begins with the caller's first request, then continues with the caller's tool-call turn - // and the fork prompt. + // The fork carries no new leading system message, but its first request omits the parent user turn + // and assistant tool-call turn that invoked it. This prevents a provider request from containing a + // function call whose output cannot exist until the child completes. const prompts = yield* rootScripted.scripted.prompts - const callerRequest = prompts[0] const forkRequest = prompts[1] - if (callerRequest === undefined || forkRequest === undefined) throw new Error('expected two requests') - const prefix = forkRequest.content.slice(0, callerRequest.content.length) - expect(stablePromptJson(prefix)).toBe(stablePromptJson(callerRequest.content)) - expect(JSON.stringify(forkRequest.content.slice(callerRequest.content.length))).toContain( - 'continue with everything you know', - ) + if (forkRequest === undefined) throw new Error('expected fork request') + expect(JSON.stringify(forkRequest.content)).toContain('continue with everything you know') + expect(JSON.stringify(forkRequest.content)).not.toContain('provider-call-0') + expect(JSON.stringify(forkRequest.content)).not.toContain('go') // The result renders like any dispatch: resumable id + turns header + body. const rendered = renderedDriveResult(entries, 0) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index 86e3e42..526a531 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -112,6 +112,42 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) +it.effect('the public fork wire persists completed-history selection', () => + Effect.gen(function* () { + const rootScripted = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([ + { + id: 'provider-call-1', + name: 'subagent', + params: { description: 'inspect context', prompt: 'inspect the completed context', fork: true }, + }, + ]), + textTurn('fork findings'), + textTurn('root complete'), + ]) + + const session = yield* startSession({ + agent: defineAgent({ model: rootScripted.model, systemPrompt: 'root', tools: [subagentTool([])] }), + }) + + const finished = yield* session.send('parent request') + expect(finished.outcome).toBe('completed') + + const started = subagentStartedEntries(yield* session.entries)[0] + if (started === undefined) throw new Error('expected the fork to have started') + expect(started.fork?.history).toBe('all') + + const prompts = yield* rootScripted.scripted.prompts + const forkRequest = prompts[1] + if (forkRequest === undefined) throw new Error('expected the fork request') + const forkRequestJson = JSON.stringify(forkRequest.content) + expect(forkRequestJson).toContain('inspect the completed context') + expect(forkRequestJson).not.toContain('provider-call-1') + expect(forkRequestJson).not.toContain('parent request') + expect(yield* rootScripted.scripted.remainingTurns).toBe(0) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) + it.effect('malformed wire commands come back as instructive tool failures the model can correct from', () => Effect.gen(function* () { const researcherScripted = yield* scriptedModel(claudeActiveModel, []) From 9f3022405b64da2393f31260edf45a4adaa9d0d9 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 29 Aug 2026 17:24:57 -0700 Subject: [PATCH 2/2] chore: remove fork history comments HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04f66-abf2-77eb-9315-490bb2bddb1e --- packages/fold-core/src/Projection/Projection.ts | 3 --- packages/fold-core/src/Subagents/SubagentTool.ts | 3 --- .../fold-core/test/Subagents/SubagentFork.vi.test.ts | 12 +----------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index b073a3f..38e0e11 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -170,9 +170,6 @@ const entriesForAgentInternal = ( (entry) => entry.seq <= fork.atSeq, ) - // Legacy fork entries did not persist a history selector. Treat them as completed-history forks too: - // the parent tool result that launched a fork cannot exist until that child finishes, so inheriting - // the active assistant tool-call row would make the child's first provider request invalid. const inheritedEntries = eligibleForkHistory(parentEntries, fork.history ?? 'all') return [...inheritedEntries, ...ownEntries].sort(compareSeq) } diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 914d3d9..70e9b10 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -178,9 +178,6 @@ export const subagentTool = ( prompt: forkCommand.prompt, skill: forkCommand.skill, forkAgentDefinitionId: options?.forkAgent?.id ?? null, - // The current parent assistant message contains this tool call, but its - // result cannot exist until this child finishes. Fork only completed - // conversation turns so the child never receives that dangling call. history: 'all', }) .pipe( diff --git a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts index 83962db..ddc2997 100644 --- a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts @@ -1,8 +1,3 @@ -/** - * Engine tests for fork mode (D21): the fork clones the caller's model binding and toolset, and inherits - * only completed conversation history. It omits the active parent tool-call turn because its result is - * the child run itself, so including it would create an invalid provider request. - */ import { expect, it } from '@effect/vitest' import { Predicate, Effect } from 'effect' @@ -32,9 +27,7 @@ it.effect('a fork inherits completed context without its invoking tool call', () if (forkStarted === undefined) throw new Error('expected the fork to have started') // Fork provenance: mode, no agentType, fromAgentId = the caller, atSeq = the observed head - // (the caller's assistant tool-call row, appended just before settlement began). This direct - // engine call leaves `history` absent to cover legacy persisted forks, which also use the - // completed-history default. + // (the caller's assistant tool-call row, appended just before settlement began). expect(forkStarted.mode).toBe('fork') expect(forkStarted.agentType).toBeNull() const rootStarted = entries.find( @@ -56,9 +49,6 @@ it.effect('a fork inherits completed context without its invoking tool call', () ) expect(forkSystemMessages).toHaveLength(0) - // The fork carries no new leading system message, but its first request omits the parent user turn - // and assistant tool-call turn that invoked it. This prevents a provider request from containing a - // function call whose output cannot exist until the child completes. const prompts = yield* rootScripted.scripted.prompts const forkRequest = prompts[1] if (forkRequest === undefined) throw new Error('expected fork request')