/** * session-config.ts — Pure configuration assembler for agent sessions. * * `assembleSessionConfig()` is the pure assembly core called by * `createSubagentSession()`. It accepts resolved inputs (agent type, narrow * context, run options, env info) and returns everything the factory needs to * create the SDK session — without importing or constructing any Pi SDK types. * * The only async IO in the assembly phase (`detectEnv`) is handled by the caller * before invoking this function, keeping the assembler synchronous. */ import { type AgentConfigLookup, BUILTIN_TOOL_NAMES } from "#src/config/agent-types"; import type { EnvInfo } from "#src/session/env"; import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "#src/types"; // ── Public interfaces ──────────────────────────────────────────────────────── /** * IO collaborators injected into `assembleSessionConfig`. * * Bundling the IO-touching (or promptly testable) function into a single * interface keeps the assembler free of direct module imports and makes it * trivially testable without `vi.mock()` — callers inject real implementations * at the edge (`create-subagent-session.ts`) or stubs in tests. */ export interface AssemblerIO { buildAgentPrompt: ( config: AgentPromptConfig, cwd: string, env: EnvInfo, parentPrompt?: string, parentCwd?: string, ) => string; } /** * Narrow context the assembler reads from the parent session. * Tests construct plain objects satisfying this interface — no SDK mocking needed. * * Models are treated as opaque handles: the assembler never inspects their * internals, only passes them through. `getAvailable` returns just enough * structural information ({ provider, id }) for the availability check in * `resolveDefaultModel`. */ export interface AssemblerContext { /** Parent working directory (overridable via options.cwd). */ cwd: string; /** Parent's effective system prompt (for append-mode agents). */ parentSystemPrompt: string; /** Parent's current model instance (fallback when agent config has no model). */ parentModel?: unknown; /** Parent's current effective level for inherited child thinking. */ parentThinkingLevel?: ThinkingLevel; /** Model registry for resolving config.model strings. */ modelRegistry: { find(provider: string, modelId: string): unknown; getAvailable?(): Array<{ provider: string; id: string }>; }; } /** * Narrow slice of per-spawn execution fields consumed by the assembler. * All fields are optional — callers pass only what they have. */ export interface AssemblerOptions { /** Override working directory (e.g. for worktree isolation). */ cwd?: string; /** Explicit model override — wins over agentConfig.model and parent model. */ model?: unknown; /** Explicit thinking level — wins over agentConfig.thinking. */ thinkingLevel?: ThinkingLevel; } /** * Assembled configuration returned to `createSubagentSession()`. * Contains everything needed to create the SDK session and filter tools — * with no SDK object references. */ export interface SessionConfig { /** Resolved working directory (`options.cwd ?? ctx.cwd`). */ effectiveCwd: string; /** Fully-assembled system prompt string (ready for `systemPromptOverride`). */ systemPrompt: string; /** Built-in tools denied to this agent; extension tools remain inheritable. */ excludedBuiltinToolNames: string[]; /** * Resolved model instance (undefined → use parent model as passed to SDK). * Opaque handle — the assembler passes it through without inspection. * Caller casts to the SDK’s Model at the session-creation boundary. */ model: unknown; /** Resolved thinking level; undefined only when no parent level is available. */ thinkingLevel: ThinkingLevel | undefined; /** Per-agent configured max turns (from agentConfig.maxTurns). */ agentMaxTurns: number | undefined; } // ── Internal helpers ───────────────────────────────────────────────────────── /** * Resolve the default model from the agent config's model string. * * Priority: parentModel is the fallback; if `configModel` is a "provider/modelId" * string that resolves against the registry AND is in the available set, return * that model instead. */ export function resolveDefaultModel( parentModel: unknown, registry: AssemblerContext["modelRegistry"], configModel?: string, ): unknown { if (configModel) { const slashIdx = configModel.indexOf("/"); if (slashIdx !== -1) { const provider = configModel.slice(0, slashIdx); const modelId = configModel.slice(slashIdx + 1); const available = registry.getAvailable?.(); const availableKeys = available ? new Set(available.map((m) => `${m.provider}/${m.id}`)) : undefined; const isAvailable = (p: string, id: string) => !availableKeys || availableKeys.has(`${p}/${id}`); const found = registry.find(provider, modelId); if (found && isAvailable(provider, modelId)) return found; } } return parentModel; } // ── Public function ────────────────────────────────────────────────────────── /** * Assemble all configuration needed to create an agent session. * * Synchronous and side-effect-free — all IO is delegated through the `io` * parameter. The caller is responsible for resolving `EnvInfo` beforehand * via `detectEnv()`. * * @param type The subagent type name (case-insensitive registry lookup). * @param ctx Narrow context from the parent session. * @param options Per-call overrides (cwd, model, thinkingLevel). * @param env Pre-resolved environment info from `detectEnv()`. * @param registry Agent config lookup — provides resolveAgentConfig and getToolNamesForType. * @param io IO collaborators (skill loader, memory builder, prompt builder). */ export function assembleSessionConfig( type: SubagentType, ctx: AssemblerContext, options: AssemblerOptions, env: EnvInfo, registry: AgentConfigLookup, io: AssemblerIO, ): SessionConfig { const agentConfig = registry.resolveAgentConfig(type); const effectiveCwd = options.cwd ?? ctx.cwd; const enabledBuiltinTools = new Set(registry.getToolNamesForType(type)); const excludedBuiltinToolNames = BUILTIN_TOOL_NAMES.filter( (name) => !enabledBuiltinTools.has(name), ); // Build system prompt from the resolved agent config const systemPrompt = io.buildAgentPrompt( agentConfig, effectiveCwd, env, ctx.parentSystemPrompt, ctx.cwd, ); // Model resolution: explicit option > config model string > parent model const model = options.model ?? resolveDefaultModel(ctx.parentModel, ctx.modelRegistry, agentConfig.model); // Thinking level: explicit option > agent config > parent level (inherit). // Passing the inherited value explicitly lets queued status use the same // value that the child session will receive once it is admitted. const thinkingLevel = options.thinkingLevel ?? agentConfig.thinking ?? ctx.parentThinkingLevel; // Per-agent max turns (combined with per-call maxTurns and defaultMaxTurns by SubagentSession.runTurnLoop) const agentMaxTurns = agentConfig.maxTurns; return { effectiveCwd, systemPrompt, excludedBuiltinToolNames, model, thinkingLevel, agentMaxTurns, }; }