diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index f843e83..a1706af 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -8,8 +8,8 @@ * descriptors become layers; no public signature accepts or returns one. * * System tools are ordinary members of `tools` (round-five ruling): the composition root walks tools - * arrays from the root - following every `subagentTool([...])` value into the definitions it carries - - * to build the flat agent-type registry, and runs each distinct tool value's `init` exactly once (the + * arrays from the root through every delegation tool's specialist and fork definitions, builds the + * flat agent-definition registry, and runs each distinct tool value's `init` exactly once (the * skill tool's roster scan), collecting the realized tool and its leading-prompt block for every agent * listing that value. * @@ -74,9 +74,12 @@ import { Session, type SessionService, type StartedSession } from '../Session/Se import type { SkillSourceService } from '../Skills/SkillSource' import { StopConditions } from '../StopConditions/StopConditions' import { agentIdsFromEntries, resolveAgentIdRef } from '../Subagents/AgentIdRef' -import { agentRegistryFromDefinitions, collectSubagentDefinitions } from '../Subagents/AgentRegistry' +import { + agentRegistryFromDefinitions, + collectAgentDefinitions, + type CollectedAgentDefinitions, +} from '../Subagents/AgentRegistry' import { SubagentNotFoundError } from '../Subagents/Errors' -import type { SubagentDefinition } from '../Subagents/SubagentDefinition' import { makeSubagents, type RealizedAgentTools, type RootAgentSnapshot } from '../Subagents/SubagentsLayer' import { Subagents, type SubagentsService } from '../Subagents/SubagentsService' import { makeSystemPrompt } from '../SystemPrompt/SystemPromptLayer' @@ -275,14 +278,12 @@ type SessionGraph = { readonly profiles: ProfilesService readonly configRef: Ref.Ref readonly validateSubagentRegistry: ( - definitions: ReadonlyArray, + definitions: CollectedAgentDefinitions, profiles: SessionProfiles, ) => Effect.Effect - readonly extendSubagentRegistry: (definitions: ReadonlyArray) => void + readonly extendSubagentRegistry: (definitions: CollectedAgentDefinitions) => void readonly ensureToolContributions: (tools: ReadonlyArray) => Effect.Effect - readonly collectNewSubagentDefinitions: ( - tools: ReadonlyArray, - ) => Effect.Effect> + readonly collectNewSubagentDefinitions: (tools: ReadonlyArray) => Effect.Effect readonly provisionRootRuntime: ( model: FoldModel, tools: ReadonlyArray, @@ -313,10 +314,11 @@ const assembleSessionGraph = (options: { const rootTools = agent.tools ?? [] const rootHooks = agent.hooks ?? {} - // Walk the tools arrays from the root: every subagentTool value contributes its definitions - // (recursively, through THEIR tools), flattening into the session's one flat registry (§1a). - const subagentDefinitions = yield* collectSubagentDefinitions(rootTools) - const registry = agentRegistryFromDefinitions(subagentDefinitions) + // Walk the tools arrays from the root: every delegation tool contributes its specialist and fork + // definitions recursively, flattening them into the session's registries (§1a). + const agentDefinitions = yield* collectAgentDefinitions(rootTools) + const subagentDefinitions = agentDefinitions.subagents + const registry = agentRegistryFromDefinitions(agentDefinitions) // Profiles slice: every role-bound registry entry must resolve against the INITIAL bindings // (`orchestrator` -> `smart` fallback counts, D25), so an uncovered role is a configuration @@ -342,6 +344,9 @@ const assembleSessionGraph = (options: { yield* Effect.forEach(subagentDefinitions, (definition) => validateToolNames(definition.tools ?? []), { discard: true, }) + yield* Effect.forEach(agentDefinitions.forkAgents, (definition) => validateToolNames(definition.tools), { + discard: true, + }) // Run each distinct tool value's init exactly once per session (for ordinary tools that is a // constant; for the skill tool it is the roster scan): the contribution - realized tool, @@ -359,6 +364,9 @@ const assembleSessionGraph = (options: { yield* Effect.forEach(subagentDefinitions, (definition) => ensureToolContributions(definition.tools ?? []), { discard: true, }) + yield* Effect.forEach(agentDefinitions.forkAgents, (definition) => ensureToolContributions(definition.tools), { + discard: true, + }) /** Realize one agent's configured tools against the session-start contributions (§2.5). */ const realizeAgentTools = (tools: ReadonlyArray): RealizedAgentTools => { @@ -520,7 +528,9 @@ const assembleSessionGraph = (options: { Effect.sync(() => { const bindings = [ ...registry.entries, - ...definitions.filter((definition) => registry.resolveAgentType(definition.name) === null), + ...definitions.subagents.filter( + (definition) => registry.resolveAgentType(definition.name) === null, + ), ] for (const entry of bindings) { if (typeof entry.model !== 'string') continue @@ -534,7 +544,7 @@ const assembleSessionGraph = (options: { registry.extend(definitions) }, ensureToolContributions, - collectNewSubagentDefinitions: (tools) => collectSubagentDefinitions(tools), + collectNewSubagentDefinitions: (tools) => collectAgentDefinitions(tools), provisionRootRuntime, setProvisionedRuntime: (runtime) => Ref.set(runtimeRef, runtime), currentProvisionedRuntime: Ref.get(runtimeRef), @@ -813,16 +823,24 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS // is the sole extension boundary, so dispatch can never observe a partially installed graph. yield* graph.ensureToolContributions(next.tools) const introduced = yield* graph.collectNewSubagentDefinitions(next.tools) - yield* Effect.forEach(introduced, (definition) => validateToolNames(definition.tools ?? []), { + yield* Effect.forEach(introduced.subagents, (definition) => validateToolNames(definition.tools ?? []), { discard: true, }) yield* Effect.forEach( - introduced, + introduced.subagents, (definition) => graph.ensureToolContributions(definition.tools ?? []), { discard: true, }, ) + yield* Effect.forEach(introduced.forkAgents, (definition) => validateToolNames(definition.tools), { + discard: true, + }) + yield* Effect.forEach( + introduced.forkAgents, + (definition) => graph.ensureToolContributions(definition.tools), + { discard: true }, + ) yield* graph.validateSubagentRegistry(introduced, candidateProfiles) // Provision against the new toolset before writing the transition, so the durable diff --git a/packages/fold-core/src/EventLog/Schemas.ts b/packages/fold-core/src/EventLog/Schemas.ts index 24ab396..8558915 100644 --- a/packages/fold-core/src/EventLog/Schemas.ts +++ b/packages/fold-core/src/EventLog/Schemas.ts @@ -2,6 +2,7 @@ import { Schema } from 'effect' import { Prompt, Response } from 'effect/unstable/ai' import { AgentId, CompactionId, EventId, MessageId, SessionId, StateId, ToolCallId } from '../Ids' +import { ForkAgentDefinitionId } from '../Subagents/ForkAgentDefinition' import { UsageEncoded } from './Usage' /** The sequence number of a log entry. The first entry in a session is seq 0. */ @@ -206,6 +207,8 @@ export type AgentLaunchMode = typeof AgentLaunchMode.Type export const AgentFork = Schema.Struct({ fromAgentId: AgentId, atSeq: LogSeq, + /** Host fork configuration. Absent on legacy and default toolset-inheriting forks. */ + definitionId: Schema.optionalKey(ForkAgentDefinitionId), }).annotate({ identifier: 'AgentFork' }) export type AgentFork = typeof AgentFork.Type diff --git a/packages/fold-core/src/Subagents/AgentRegistry.ts b/packages/fold-core/src/Subagents/AgentRegistry.ts index 116a8c4..acf12eb 100644 --- a/packages/fold-core/src/Subagents/AgentRegistry.ts +++ b/packages/fold-core/src/Subagents/AgentRegistry.ts @@ -1,7 +1,7 @@ /** * This file owns the session's flat agent-type registry (D21, round-five shape): the composition root - * walks tools arrays from the root agent, following every `subagentTool([...])` value into the - * definitions it carries (and recursing into THEIR tools), and flattens everything reachable into one + * walks tools arrays from the root agent through every delegation tool's specialist and fork + * definitions, and flattens everything reachable into one * name-keyed registry. Two concerns, two structures - each subagentTool value's closure gates * *dispatchability* for the agent holding it; the flat registry owns *state, ids, and resume* - so a * subagent is always resumable by id from one place while only the types in a dispatcher's roster are @@ -11,8 +11,15 @@ import { Effect } from 'effect' import type { FoldTool } from '../Api/ToolDefinition' import type { HookConfig } from '../HookRunner/Types' +import type { ForkAgentDefinition, ForkAgentDefinitionId } from './ForkAgentDefinition' import type { SubagentDefinition, SubagentModelBinding } from './SubagentDefinition' -import { subagentRosterOf } from './SubagentTool' +import { subagentCapabilitiesOf } from './SubagentTool' + +/** Agent definitions reachable from a configured root toolset. */ +export type CollectedAgentDefinitions = { + readonly subagents: ReadonlyArray + readonly forkAgents: ReadonlyArray +} /** One registered subagent type, resolved for runtime use. */ export type RegisteredAgentType = { @@ -36,65 +43,96 @@ export type AgentRegistry = { readonly resolveAgentType: (name: string) => RegisteredAgentType | null /** Every registered type, in first-reached order from the root's tools. */ readonly entries: ReadonlyArray + /** Resolve one host-configured fork toolset by its durable identifier. */ + readonly resolveForkAgentDefinition: (id: ForkAgentDefinitionId) => ForkAgentDefinition | null /** * Add definitions discovered at an explicit session switch boundary. Existing names remain bound * to their original session definition; duplicate names within the incoming graph have already - * been rejected by {@link collectSubagentDefinitions}. Returns the definitions actually added. + * been rejected by {@link collectAgentDefinitions}. Returns the specialist definitions actually added. */ - readonly extend: (definitions: ReadonlyArray) => ReadonlyArray + readonly extend: (definitions: CollectedAgentDefinitions) => ReadonlyArray } /** - * Walk tools arrays from the root, collecting every subagent definition reachable through - * `subagentTool` values (recursing into each definition's own tools), in first-reached order. + * Walk tools arrays from the root, collecting every specialist and fork definition reachable through + * delegation tools, in first-reached order. * Dedup is by object identity (sharing = passing the same definition by reference); the same name on * two distinct definitions is a configuration bug and dies. The seen-set makes traversal total even * if a definition graph is ever made circular through post-construction mutation. */ -export const collectSubagentDefinitions = ( - rootTools: ReadonlyArray, -): Effect.Effect> => +export const collectAgentDefinitions = (rootTools: ReadonlyArray): Effect.Effect => Effect.suspend(() => { - const seen = new Set() - const byName = new Map() - const ordered: Array = [] + const seenSubagents = new Set() + const subagentsByName = new Map() + const subagents: Array = [] + const seenForkAgents = new Set() + const forkAgentsById = new Map() + const forkAgents: Array = [] const visitDefinition = (definition: SubagentDefinition): Effect.Effect => { - if (seen.has(definition)) return Effect.void - seen.add(definition) + if (seenSubagents.has(definition)) return Effect.void + seenSubagents.add(definition) if (definition.name.trim().length === 0) { return Effect.die(new Error('subagent definitions must have a non-empty name')) } - const existing = byName.get(definition.name) + const existing = subagentsByName.get(definition.name) if (existing !== undefined && existing !== definition) { return Effect.die( new Error(`duplicate subagent type name "${definition.name}" across distinct definitions`), ) } - byName.set(definition.name, definition) - ordered.push(definition) + subagentsByName.set(definition.name, definition) + subagents.push(definition) return visitTools(definition.tools ?? []) } + const visitForkAgent = (definition: ForkAgentDefinition): Effect.Effect => { + if (seenForkAgents.has(definition)) return Effect.void + seenForkAgents.add(definition) + + if (definition.id.trim().length === 0) { + return Effect.die(new Error('fork agent definitions must have a non-empty id')) + } + + const existing = forkAgentsById.get(definition.id) + if (existing !== undefined && existing !== definition) { + return Effect.die(new Error(`duplicate fork agent definition id "${definition.id}"`)) + } + + forkAgentsById.set(definition.id, definition) + forkAgents.push(definition) + return visitTools(definition.tools) + } + const visitTools = (tools: ReadonlyArray): Effect.Effect => Effect.forEach( tools, (tool) => { - const roster = subagentRosterOf(tool) - return roster === null ? Effect.void : Effect.forEach(roster, visitDefinition, { discard: true }) + const capabilities = subagentCapabilitiesOf(tool) + if (capabilities === null) return Effect.void + return Effect.all([ + Effect.forEach(capabilities.agents, visitDefinition, { discard: true }), + capabilities.forkAgent === undefined ? Effect.void : visitForkAgent(capabilities.forkAgent), + ]).pipe(Effect.asVoid) }, { discard: true }, ) - return visitTools(rootTools).pipe(Effect.as(ordered)) + return visitTools(rootTools).pipe(Effect.as({ subagents, forkAgents })) }) +/** Compatibility helper returning only registered specialist definitions. */ +export const collectSubagentDefinitions = ( + rootTools: ReadonlyArray, +): Effect.Effect> => + collectAgentDefinitions(rootTools).pipe(Effect.map((definitions) => definitions.subagents)) + /** Build the flat registry over pre-collected (validated, deduped) definitions. */ -export const agentRegistryFromDefinitions = (definitions: ReadonlyArray): AgentRegistry => { +export const agentRegistryFromDefinitions = (definitions: CollectedAgentDefinitions): AgentRegistry => { const registeredFrom = (definition: SubagentDefinition): RegisteredAgentType => ({ name: definition.name, description: definition.description, @@ -103,19 +141,21 @@ export const agentRegistryFromDefinitions = (definitions: ReadonlyArray = definitions.map(registeredFrom) + const entries: Array = definitions.subagents.map(registeredFrom) const byName = new Map(entries.map((entry) => [entry.name, entry])) - const definitionsByName = new Map(definitions.map((definition) => [definition.name, definition])) + const definitionsByName = new Map(definitions.subagents.map((definition) => [definition.name, definition])) + const forkAgentsById = new Map(definitions.forkAgents.map((definition) => [definition.id, definition])) return { resolveAgentType: (name) => byName.get(name) ?? null, + resolveForkAgentDefinition: (id) => forkAgentsById.get(id) ?? null, entries, extend: (incoming) => { const added: Array = [] - for (const definition of incoming) { + for (const definition of incoming.subagents) { const existing = definitionsByName.get(definition.name) // Mode rebuilds intentionally create fresh definition values for types already installed. - // Keep the session's original binding; collectSubagentDefinitions has still rejected two + // Keep the session's original binding; collectAgentDefinitions has still rejected two // distinct definitions with this name inside the incoming graph itself. if (existing !== undefined) continue const entry = registeredFrom(definition) @@ -124,6 +164,9 @@ export const agentRegistryFromDefinitions = (definitions: ReadonlyArray +} + +/** Define the tools inherited by a configured fork. */ +export const defineForkAgent = (definition: ForkAgentDefinition): ForkAgentDefinition => definition diff --git a/packages/fold-core/src/Subagents/Schemas.ts b/packages/fold-core/src/Subagents/Schemas.ts index 5ca8bde..3b47a09 100644 --- a/packages/fold-core/src/Subagents/Schemas.ts +++ b/packages/fold-core/src/Subagents/Schemas.ts @@ -12,6 +12,7 @@ import { AgentFinishedOutcome } from '../EventLog/Schemas' import { AgentId } from '../Ids' import { AgentIdRef } from './AgentIdRef' import { InvalidSubagentCommandError } from './Errors' +import { ForkAgentDefinitionId } from './ForkAgentDefinition' /** Count of assistant turns (one LLM call each, D7 vocabulary) - a pure fold over assistant-message rows. */ export const TurnCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ identifier: 'TurnCount' }) @@ -77,6 +78,14 @@ export const SubagentCommand = Schema.Union([ ]).annotate({ identifier: 'SubagentCommand' }) export type SubagentCommand = typeof SubagentCommand.Type +/** Input to the fork engine after a model-facing tool has selected its next fork configuration. */ +export const ForkSubagentInput = Schema.Struct({ + prompt: Schema.String, + skill: Schema.NullOr(Schema.String), + forkAgentDefinitionId: Schema.NullOr(ForkAgentDefinitionId), +}).annotate({ identifier: 'ForkSubagentInput' }) +export type ForkSubagentInput = typeof ForkSubagentInput.Type + const decodeAgentIdRef = Schema.decodeUnknownEffect(AgentIdRef) /** The subagent tool's flat wire parameters, as decoded by Effect AI against the tool contract. */ diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 7e845c9..69d373d 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -8,23 +8,39 @@ * the tool's instructive failure payload with `catchTag`/`catchTags` - all choreography lives in the * deep module, and no error is ever inspected as a value. */ -import { Effect } from 'effect' +import { Effect, Match } from 'effect' import { defineTool, type FoldTool } from '../Api/ToolDefinition' import type { SkillNotFoundError } from '../Skills/SkillSource' import { subagentToolContract } from '../Tools/Contracts' import { shortAgentId } from './AgentIdRef' import type { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' +import type { ForkAgentDefinition } from './ForkAgentDefinition' import { parseSubagentCommand, type SubagentResult } from './Schemas' import type { SubagentDefinition } from './SubagentDefinition' import { Subagents } from './SubagentsService' -/** Module-internal registry carrying each subagentTool value's roster for the tools-array walk. */ -const rosterBySubagentTool = new WeakMap>() +/** Runtime capabilities attached to a model-visible delegation tool. */ +export type SubagentToolCapabilities = { + readonly agents: ReadonlyArray + readonly forkAgent?: ForkAgentDefinition +} + +const capabilitiesBySubagentTool = new WeakMap() + +/** Attach Fold's roster and fork behavior to any host-defined model-visible tool. */ +export const withSubagentCapabilities = (tool: FoldTool, capabilities: SubagentToolCapabilities): FoldTool => { + capabilitiesBySubagentTool.set(tool, capabilities) + return tool +} + +/** Read the capabilities attached to a delegation tool; null for every other tool. */ +export const subagentCapabilitiesOf = (tool: FoldTool): SubagentToolCapabilities | null => + capabilitiesBySubagentTool.get(tool) ?? null /** Read the roster off a subagentTool value; null for every other tool. */ export const subagentRosterOf = (tool: FoldTool): ReadonlyArray | null => - rosterBySubagentTool.get(tool) ?? null + subagentCapabilitiesOf(tool)?.agents ?? null /** Model-facing failure payload of the subagent tool (schema: message + availableAgents). */ type SubagentToolFailure = { @@ -121,7 +137,10 @@ const rosterDescriptionSuffix = (agents: ReadonlyArray): str * the value (`subagentRosterOf`) to build the session registry. Each call creates an independent value; * agents sharing one roster should share one value. */ -export const subagentTool = (agents: ReadonlyArray): FoldTool => { +export const subagentTool = ( + agents: ReadonlyArray, + options?: { readonly forkAgent?: ForkAgentDefinition }, +): FoldTool => { const allowedAgents = agents.map((agent) => agent.name) const tool = defineTool({ @@ -136,58 +155,56 @@ export const subagentTool = (agents: ReadonlyArray): FoldToo ), ) - switch (command._tag) { - case 'dispatch': { - const result = yield* subagents - .dispatch({ - agent: command.agent, - prompt: command.prompt, - skill: command.skill, - allowedAgents, - }) - .pipe( - Effect.catchTags({ - SubagentTypeNotInRosterError: (error) => Effect.fail(rosterFailure(error)), - SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), - }), - ) - - return { content: renderSubagentResult(result) } - } - - case 'fork': { - const result = yield* subagents - .fork({ prompt: command.prompt, skill: command.skill }) - .pipe( - Effect.catchTag('SkillNotFoundError', (error) => - Effect.fail(skillFailure(error, allowedAgents)), + return yield* Match.value(command).pipe( + Match.tagsExhaustive({ + dispatch: (dispatchCommand) => + subagents + .dispatch({ + agent: dispatchCommand.agent, + prompt: dispatchCommand.prompt, + skill: dispatchCommand.skill, + allowedAgents, + }) + .pipe( + Effect.catchTags({ + SubagentTypeNotInRosterError: (error) => Effect.fail(rosterFailure(error)), + SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), + }), + Effect.map((result) => ({ content: renderSubagentResult(result) })), ), - ) - - return { content: renderSubagentResult(result) } - } - - case 'resume': { - const result = yield* subagents - .resume({ agentId: command.agentId, prompt: command.prompt, skill: command.skill }) - .pipe( - Effect.catchTags({ - SubagentNotFoundError: (error) => - Effect.fail(notFoundFailure(error, allowedAgents)), - SubagentBusyError: (error) => Effect.fail(busyFailure(error, allowedAgents)), - SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), - }), - ) - - return { content: renderSubagentResult(result) } - } - } + fork: (forkCommand) => + subagents + .fork({ + prompt: forkCommand.prompt, + skill: forkCommand.skill, + forkAgentDefinitionId: options?.forkAgent?.id ?? null, + }) + .pipe( + Effect.catchTag('SkillNotFoundError', (error) => + Effect.fail(skillFailure(error, allowedAgents)), + ), + Effect.map((result) => ({ content: renderSubagentResult(result) })), + ), + resume: (resumeCommand) => + subagents + .resume({ + agentId: resumeCommand.agentId, + prompt: resumeCommand.prompt, + skill: resumeCommand.skill, + }) + .pipe( + Effect.catchTags({ + SubagentNotFoundError: (error) => + Effect.fail(notFoundFailure(error, allowedAgents)), + SubagentBusyError: (error) => Effect.fail(busyFailure(error, allowedAgents)), + SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), + }), + Effect.map((result) => ({ content: renderSubagentResult(result) })), + ), + }), + ) }), }) - // Register the roster against the value's identity so the composition root's tools-array walk can - // recover it; the value itself stays an ordinary FoldTool. - rosterBySubagentTool.set(tool, agents) - - return tool + return withSubagentCapabilities(tool, { agents, ...(options?.forkAgent === undefined ? {} : options) }) } diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index b68b7e1..248dda5 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -49,12 +49,11 @@ import { import { agentIdsFromEntries, resolveAgentIdRef, shortAgentId } from './AgentIdRef' import type { AgentRegistry, RegisteredAgentType } from './AgentRegistry' import { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' -import type { SubagentResult, TurnCount } from './Schemas' +import type { ForkSubagentInput, SubagentResult, TurnCount } from './Schemas' import type { SubagentModelBinding } from './SubagentDefinition' import type { ContinueSubagentInput, DispatchSubagentInput, - ForkSubagentInput, ResumeSubagentInput, SubagentsService, } from './SubagentsService' @@ -91,6 +90,13 @@ export type SubagentsConfig = { /** Where an agent's configuration comes from: a registered type, or the root agent. */ type OriginatingConfig = { readonly _tag: 'entry'; readonly entry: RegisteredAgentType } | { readonly _tag: 'root' } +type AgentConfigurationSnapshot = { + readonly model: FoldModel + readonly tools: ReadonlyArray + readonly hooks: HookConfig + readonly systemPrompt: string | ReadonlyArray | null +} + /** Everything one subagent launch/resume needs, resolved before the run fiber forks. */ type LaunchSubagentParams = { readonly subagentId: AgentId @@ -258,14 +264,7 @@ export const makeSubagents = ( * resolve their model binding against the CURRENT profiles map, so fork/resume/continue of a * role-bound type all see the live binding (a fork clones the caller's binding by definition). */ - const agentSnapshotForOrigin = ( - origin: OriginatingConfig, - ): Effect.Effect<{ - readonly model: FoldModel - readonly tools: ReadonlyArray - readonly hooks: HookConfig - readonly systemPrompt: string | ReadonlyArray | null - }> => + const agentSnapshotForOrigin = (origin: OriginatingConfig): Effect.Effect => origin._tag === 'root' ? config.currentRootAgent : resolveModelBinding(origin.entry.model).pipe( @@ -277,6 +276,30 @@ export const makeSubagents = ( })), ) + /** Reconstruct one agent's effective configuration, including a persisted fork tool override. */ + const agentSnapshotForAgent = ( + entries: ReadonlyArray, + agentId: AgentId, + ): Effect.Effect => + Effect.gen(function* () { + const origin = originatingConfigForAgent(entries, agentId) + if (origin === null) return null + + const snapshot = yield* agentSnapshotForOrigin(origin) + const started = findAgentStarted(entries, agentId) + const definitionId = started?.fork?.definitionId + if (definitionId === undefined) return snapshot + + const definition = config.registry.resolveForkAgentDefinition(definitionId) + if (definition === null) { + return yield* Effect.die( + new Error(`fork agent definition "${definitionId}" required by ${agentId} is not registered`), + ) + } + + return { ...snapshot, tools: definition.tools } + }) + /** Every ancestor of an agent (parent chain from agent_started rows), for the resume self-guard. */ const ancestorAgentIds = (entries: ReadonlyArray, agentId: AgentId): ReadonlySet => { const ancestors = new Set() @@ -294,12 +317,11 @@ export const makeSubagents = ( /** Load and render a preloaded skill through the dispatcher's own skillTool source (§2.3). */ const preloadedSkillMessage = ( - dispatcherOrigin: OriginatingConfig, + dispatcherSnapshot: AgentConfigurationSnapshot, skillName: string, ): Effect.Effect => Effect.gen(function* () { - const snapshot = yield* agentSnapshotForOrigin(dispatcherOrigin) - const realized = config.realizeAgentTools(snapshot.tools) + const realized = config.realizeAgentTools(dispatcherSnapshot.tools) if (realized.skillSource === null) { return yield* new SkillNotFoundError({ name: skillName, availableSkills: [] }) @@ -564,14 +586,12 @@ export const makeSubagents = ( return yield* new SubagentNotFoundError({ requested: input.agentId }) } - const targetOrigin = originatingConfigForAgent(entries, input.agentId) - if (targetOrigin === null) { + const snapshot = yield* agentSnapshotForAgent(entries, input.agentId) + if (snapshot === null) { return yield* Effect.die( new Error(`continuation target ${input.agentId} has no resolvable configuration`), ) } - - const snapshot = yield* agentSnapshotForOrigin(targetOrigin) const realized = config.realizeAgentTools(snapshot.tools) const projected = runtimeForAgent(entries, input.agentId) @@ -667,13 +687,13 @@ export const makeSubagents = ( // Preload resolves through the DISPATCHER's skill source, and fails before any durable // subagent row exists (§2.3 step 6). const entries = yield* collectEntries - const dispatcherOrigin = originatingConfigForAgent(entries, dispatcher.agentId) + const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) const preloaded = input.skill === null ? null - : dispatcherOrigin === null + : dispatcherSnapshot === null ? yield* new SkillNotFoundError({ name: input.skill, availableSkills: [] }) - : yield* preloadedSkillMessage(dispatcherOrigin, input.skill) + : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) const subagentId = yield* ids.makeAgentId yield* interruptNote.set(interruptedSubagentNote(entry.name, subagentId, 0)) @@ -708,18 +728,27 @@ export const makeSubagents = ( const interruptNote = yield* InterruptNote const entries = yield* collectEntries - const dispatcherOrigin = originatingConfigForAgent(entries, dispatcher.agentId) - if (dispatcherOrigin === null) { + const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) + if (dispatcherSnapshot === null) { return yield* Effect.die( new Error(`fork dispatcher ${dispatcher.agentId} has no resolvable configuration`), ) } const preloaded = - input.skill === null ? null : yield* preloadedSkillMessage(dispatcherOrigin, input.skill) + input.skill === null ? null : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) - const snapshot = yield* agentSnapshotForOrigin(dispatcherOrigin) - const realized = config.realizeAgentTools(snapshot.tools) + const forkDefinition = + input.forkAgentDefinitionId === null + ? null + : config.registry.resolveForkAgentDefinition(input.forkAgentDefinitionId) + if (input.forkAgentDefinitionId !== null && forkDefinition === null) { + return yield* Effect.die( + new Error(`fork agent definition "${input.forkAgentDefinitionId}" is not registered`), + ) + } + const forkTools = forkDefinition?.tools ?? dispatcherSnapshot.tools + const realized = config.realizeAgentTools(forkTools) // The fork sees the caller's history up to the head observed here; rows appended by parallel // work after this observation are deliberately outside the fork's view. @@ -739,10 +768,14 @@ export const makeSubagents = ( parentAgentId: dispatcher.agentId, toolCallId: currentCall.toolCallId, mode: 'fork', - fork: { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq }, - model: snapshot.model, + fork: { + fromAgentId: dispatcher.agentId, + atSeq: lastEntry.seq, + ...(input.forkAgentDefinitionId === null ? {} : { definitionId: input.forkAgentDefinitionId }), + }, + model: dispatcherSnapshot.model, tools: realized.tools, - hooks: snapshot.hooks, + hooks: dispatcherSnapshot.hooks, // Forks append no leading system message: the fold carries the caller's blocks (D21). systemPrompt: null, skillParam: input.skill, @@ -784,22 +817,20 @@ export const makeSubagents = ( return yield* new SubagentBusyError({ agentId }) } - const dispatcherOrigin = originatingConfigForAgent(entries, dispatcher.agentId) + const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) const preloaded = input.skill === null ? null - : dispatcherOrigin === null + : dispatcherSnapshot === null ? yield* new SkillNotFoundError({ name: input.skill, availableSkills: [] }) - : yield* preloadedSkillMessage(dispatcherOrigin, input.skill) + : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) // The resumed agent's binding: its registry entry when its type is (still) registered, // otherwise its fork-source chain, otherwise the dispatcher's own configuration. - const targetOrigin = originatingConfigForAgent(entries, agentId) ?? dispatcherOrigin - if (targetOrigin === null) { + const snapshot = (yield* agentSnapshotForAgent(entries, agentId)) ?? dispatcherSnapshot + if (snapshot === null) { return yield* Effect.die(new Error(`resume target ${agentId} has no resolvable configuration`)) } - - const snapshot = yield* agentSnapshotForOrigin(targetOrigin) const realized = config.realizeAgentTools(snapshot.tools) const projected = runtimeForAgent(entries, agentId) diff --git a/packages/fold-core/src/Subagents/SubagentsService.ts b/packages/fold-core/src/Subagents/SubagentsService.ts index a121c1c..f2330cc 100644 --- a/packages/fold-core/src/Subagents/SubagentsService.ts +++ b/packages/fold-core/src/Subagents/SubagentsService.ts @@ -15,7 +15,7 @@ import type { AgentId } from '../Ids' import type { SkillNotFoundError } from '../Skills/SkillSource' import type { CurrentAgent, CurrentToolCall, InterruptNote } from '../ToolRuntime/ToolContextServices' import type { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' -import type { SubagentResult } from './Schemas' +import type { ForkSubagentInput, SubagentResult } from './Schemas' /** Ambient per-tool-call services every Subagents method consumes. */ export type SubagentAmbientServices = CurrentAgent | CurrentToolCall | InterruptNote @@ -31,12 +31,6 @@ export type DispatchSubagentInput = { readonly allowedAgents: ReadonlyArray } -/** Input for forking the dispatching agent: the fork clones its context, config, and toolset. */ -export type ForkSubagentInput = { - readonly prompt: string - readonly skill: string | null -} - /** * Input for resuming a previously dispatched subagent by reference: the full branded id, or a unique * short prefix like `agent_ab12cd34` (the form rendered in subagent results). The engine resolves the diff --git a/packages/fold-core/src/index.ts b/packages/fold-core/src/index.ts index 6a73192..daf235c 100644 --- a/packages/fold-core/src/index.ts +++ b/packages/fold-core/src/index.ts @@ -10,6 +10,7 @@ export * from './Api/ToolDefinition' export * from './Subagents/AgentIdRef' export * from './Subagents/AgentRegistry' export * from './Subagents/Errors' +export * from './Subagents/ForkAgentDefinition' export * from './Subagents/Schemas' export * from './Subagents/SubagentDefinition' export * from './Subagents/SubagentsLayer' diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index ab34856..5887f4f 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -76,7 +76,7 @@ export const makeDriveTool = (instructions: Ref.Ref + Effect.gen(function* () { + const logContext = yield* Layer.build(layerInMemoryEventLog) + const sharedLog = Context.get(logContext, EventLog) + const sharedLogSource = eventLogSource(Effect.succeed(sharedLog)) + + const dispatched = yield* Effect.scoped( + Effect.gen(function* () { + const leafFork = defineForkAgent({ id: 'leaf-fork', tools: [] }) + const delegatingFork = defineForkAgent({ + id: 'delegating-fork', + tools: [subagentTool([], { forkAgent: leafFork })], + }) + const scripted = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([ + { id: 'root-fork', name: 'subagent', params: { prompt: 'first child', fork: true } }, + ]), + textTurn('child paused'), + textTurn('root A done'), + ]) + const session = yield* startSession({ + agent: defineAgent({ + model: scripted.model, + tools: [subagentTool([], { forkAgent: delegatingFork })], + }), + log: sharedLogSource, + }) + yield* session.send('go') + const started = subagentStartedEntries(yield* session.entries)[0] + if (started === undefined) throw new Error('expected a configured fork') + return started.agentId + }), + ) + + const leafFork = defineForkAgent({ id: 'leaf-fork', tools: [] }) + const delegatingFork = defineForkAgent({ + id: 'delegating-fork', + tools: [subagentTool([], { forkAgent: leafFork })], + }) + const scripted = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([ + { id: 'root-resume', name: 'subagent', params: { prompt: 'continue', agent_id: dispatched } }, + ]), + toolCallTurn([{ id: 'child-fork', name: 'subagent', params: { prompt: 'leaf work', fork: true } }]), + textTurn('leaf done'), + textTurn('child resumed'), + textTurn('root B done'), + ]) + const session = yield* startSession({ + agent: defineAgent({ model: scripted.model, tools: [subagentTool([], { forkAgent: delegatingFork })] }), + log: sharedLogSource, + }) + yield* session.send('resume') + + const started = subagentStartedEntries(yield* session.entries) + expect(started).toHaveLength(2) + expect(started[0]?.fork?.definitionId).toBe('delegating-fork') + expect(started[1]?.fork?.definitionId).toBe('leaf-fork') + expect(started[1]?.parentAgentId).toBe(dispatched) + expect(started[1]?.tools).not.toContain('subagent') + }).pipe(Effect.scoped), +) diff --git a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts index 047a40c..25b6355 100644 --- a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts @@ -6,9 +6,21 @@ * distinct definitions are a session-start defect. */ import { expect, it } from '@effect/vitest' -import { Cause, Effect, Exit } from 'effect' - -import { defineAgent, defineSubagent, startSession, subagentTool, type ToolResultLogEntry } from '../../src/index' +import { Cause, Effect, Exit, Schema } from 'effect' + +import { + defineAgent, + defineForkAgent, + defineSubagent, + defineTool, + renderSubagentResult, + startSession, + Subagents, + subagentTool, + withSubagentCapabilities, + type ForkAgentDefinition, + type ToolResultLogEntry, +} from '../../src/index' import { claudeActiveModel, gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { subagentStartedEntries } from './DriveHarness' @@ -149,3 +161,71 @@ it.effect('the same definition shared by two rosters is one registry entry', () expect(finished.outcome).toBe('completed') }).pipe(Effect.scoped), ) + +const hostAgentTool = (forkAgent: ForkAgentDefinition) => + withSubagentCapabilities( + defineTool({ + name: 'agent', + description: 'Fork the current agent for an isolated task.', + parameters: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ content: Schema.String }), + handler: ({ prompt }) => + Effect.gen(function* () { + const subagents = yield* Subagents + const result = yield* subagents + .fork({ prompt, skill: null, forkAgentDefinitionId: forkAgent.id }) + .pipe(Effect.orDie) + return { content: renderSubagentResult(result) } + }), + }), + { agents: [], forkAgent }, + ) + +it.effect('host agent tools configure two fork generations structurally', () => + Effect.gen(function* () { + const leafFork = defineForkAgent({ id: 'leaf-fork', tools: [] }) + const delegatingFork = defineForkAgent({ + id: 'delegating-fork', + tools: [hostAgentTool(leafFork)], + }) + const scripted = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([{ id: 'root-fork', name: 'agent', params: { prompt: 'first child' } }]), + toolCallTurn([{ id: 'child-fork', name: 'agent', params: { prompt: 'leaf child' } }]), + textTurn('leaf done'), + textTurn('child done'), + textTurn('root done'), + ]) + + const session = yield* startSession({ + agent: defineAgent({ model: scripted.model, tools: [hostAgentTool(delegatingFork)] }), + }) + const finished = yield* session.send('go') + expect(finished.outcome).toBe('completed') + + const started = subagentStartedEntries(yield* session.entries) + expect(started).toHaveLength(2) + expect(started[0]?.fork?.definitionId).toBe('delegating-fork') + expect(started[0]?.tools).toContain('agent') + expect(started[1]?.fork?.definitionId).toBe('leaf-fork') + expect(started[1]?.tools).not.toContain('agent') + expect(started[1]?.parentAgentId).toBe(started[0]?.agentId) + }).pipe(Effect.scoped), +) + +it.effect('duplicate fork agent definition ids defect at session start', () => + Effect.gen(function* () { + const scripted = yield* scriptedModel(claudeActiveModel, []) + const first = defineForkAgent({ id: 'duplicate', tools: [] }) + const second = defineForkAgent({ id: 'duplicate', tools: [] }) + + const exit = yield* startSession({ + agent: defineAgent({ + model: scripted.model, + tools: [subagentTool([], { forkAgent: first }), subagentTool([], { forkAgent: second })], + }), + }).pipe(Effect.exit) + + if (!Exit.isFailure(exit)) throw new Error('expected session start to defect') + expect(String(Cause.squash(exit.cause))).toContain('duplicate fork agent definition id') + }).pipe(Effect.scoped), +)