Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions packages/fold-core/src/Api/StartSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -275,14 +278,12 @@ type SessionGraph = {
readonly profiles: ProfilesService
readonly configRef: Ref.Ref<SessionAgentConfig>
readonly validateSubagentRegistry: (
definitions: ReadonlyArray<SubagentDefinition>,
definitions: CollectedAgentDefinitions,
profiles: SessionProfiles,
) => Effect.Effect<void>
readonly extendSubagentRegistry: (definitions: ReadonlyArray<SubagentDefinition>) => void
readonly extendSubagentRegistry: (definitions: CollectedAgentDefinitions) => void
readonly ensureToolContributions: (tools: ReadonlyArray<FoldTool>) => Effect.Effect<void>
readonly collectNewSubagentDefinitions: (
tools: ReadonlyArray<FoldTool>,
) => Effect.Effect<ReadonlyArray<SubagentDefinition>>
readonly collectNewSubagentDefinitions: (tools: ReadonlyArray<FoldTool>) => Effect.Effect<CollectedAgentDefinitions>
readonly provisionRootRuntime: (
model: FoldModel,
tools: ReadonlyArray<FoldTool>,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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<FoldTool>): RealizedAgentTools => {
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/fold-core/src/EventLog/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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

Expand Down
95 changes: 69 additions & 26 deletions packages/fold-core/src/Subagents/AgentRegistry.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<SubagentDefinition>
readonly forkAgents: ReadonlyArray<ForkAgentDefinition>
}

/** One registered subagent type, resolved for runtime use. */
export type RegisteredAgentType = {
Expand All @@ -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<RegisteredAgentType>
/** 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<SubagentDefinition>) => ReadonlyArray<RegisteredAgentType>
readonly extend: (definitions: CollectedAgentDefinitions) => ReadonlyArray<RegisteredAgentType>
}

/**
* 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<FoldTool>,
): Effect.Effect<ReadonlyArray<SubagentDefinition>> =>
export const collectAgentDefinitions = (rootTools: ReadonlyArray<FoldTool>): Effect.Effect<CollectedAgentDefinitions> =>
Effect.suspend(() => {
const seen = new Set<SubagentDefinition>()
const byName = new Map<string, SubagentDefinition>()
const ordered: Array<SubagentDefinition> = []
const seenSubagents = new Set<SubagentDefinition>()
const subagentsByName = new Map<string, SubagentDefinition>()
const subagents: Array<SubagentDefinition> = []
const seenForkAgents = new Set<ForkAgentDefinition>()
const forkAgentsById = new Map<ForkAgentDefinitionId, ForkAgentDefinition>()
const forkAgents: Array<ForkAgentDefinition> = []

const visitDefinition = (definition: SubagentDefinition): Effect.Effect<void> => {
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<void> => {
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<FoldTool>): Effect.Effect<void> =>
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<FoldTool>,
): Effect.Effect<ReadonlyArray<SubagentDefinition>> =>
collectAgentDefinitions(rootTools).pipe(Effect.map((definitions) => definitions.subagents))

/** Build the flat registry over pre-collected (validated, deduped) definitions. */
export const agentRegistryFromDefinitions = (definitions: ReadonlyArray<SubagentDefinition>): AgentRegistry => {
export const agentRegistryFromDefinitions = (definitions: CollectedAgentDefinitions): AgentRegistry => {
const registeredFrom = (definition: SubagentDefinition): RegisteredAgentType => ({
name: definition.name,
description: definition.description,
Expand All @@ -103,19 +141,21 @@ export const agentRegistryFromDefinitions = (definitions: ReadonlyArray<Subagent
model: definition.model,
hooks: definition.hooks ?? {},
})
const entries: Array<RegisteredAgentType> = definitions.map(registeredFrom)
const entries: Array<RegisteredAgentType> = 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<RegisteredAgentType> = []
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)
Expand All @@ -124,6 +164,9 @@ export const agentRegistryFromDefinitions = (definitions: ReadonlyArray<Subagent
entries.push(entry)
added.push(entry)
}
for (const definition of incoming.forkAgents) {
if (!forkAgentsById.has(definition.id)) forkAgentsById.set(definition.id, definition)
}
return added
},
}
Expand Down
16 changes: 16 additions & 0 deletions packages/fold-core/src/Subagents/ForkAgentDefinition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Schema } from 'effect'

import type { FoldTool } from '../Api/ToolDefinition'

/** Stable identifier persisted on forks so their host-configured tools survive replay. */
export const ForkAgentDefinitionId = Schema.NonEmptyString.annotate({ identifier: 'ForkAgentDefinitionId' })
export type ForkAgentDefinitionId = typeof ForkAgentDefinitionId.Type

/** Host-owned tool configuration for one fork generation. Other agent configuration remains inherited. */
export type ForkAgentDefinition = {
readonly id: ForkAgentDefinitionId
readonly tools: ReadonlyArray<FoldTool>
}

/** Define the tools inherited by a configured fork. */
export const defineForkAgent = (definition: ForkAgentDefinition): ForkAgentDefinition => definition
9 changes: 9 additions & 0 deletions packages/fold-core/src/Subagents/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading