UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

188 lines (173 loc) • 6.81 kB
import { boolean, choice } from '../../evaluate/index' import type { InferSchemaType, SchemaInput } from '../../../types' import type { DefinedAgent } from './define-agent' import type { SubagentOrder, SubagentRouterPick } from './spawn' const ORDER_KEY = 'order' export interface SubagentRouteOptions< TAgents extends ReadonlyArray<DefinedAgent>, > { /** * Question text for every agent. The key is the agent name. * Omit this and each question uses that agent's description. */ when?: { [K in TAgents[number]['name']]: string } /** * Agents that run after the other selected agents. The lead group starts * together. The `then` agents then run one after another in list order, and * each reads the text so far. Used only when the router picks at least one * agent from each group. Otherwise `pick` returns `{ names, order }`. */ then?: ReadonlyArray<TAgents[number]['name']> } type RouteQuestions<TAgents extends ReadonlyArray<DefinedAgent>> = { [K in TAgents[number]['name']]: ReturnType<typeof boolean> } & { order: ReturnType< typeof choice<{ parallel: string sequence: string }> > } type RouteAnswers<TAgents extends ReadonlyArray<DefinedAgent>> = { [K in TAgents[number]['name']]: { value: boolean } } & { order: { value: SubagentOrder } } /** The name and the `inputSchema` of an agent that has one. Else `never`. */ type InputNeed<TAgent> = TAgent extends { name: infer TName inputSchema?: infer TSchema } ? TSchema extends SchemaInput ? { name: TName; inputSchema: TSchema } : never : never /** One input for each agent with an `inputSchema`, typed from that schema. */ type RouteInputs<TAgents extends ReadonlyArray<DefinedAgent>> = { [TNeed in InputNeed<TAgents[number]> as TNeed['name']]?: InferSchemaType< TNeed['inputSchema'] > } interface RoutePickOptions<TAgents extends ReadonlyArray<DefinedAgent>> { /** * The input of each picked agent that has an `inputSchema`. The key is the * agent name. `needsInput` lists the agents that need one. An input for an * agent that is not picked is not used. */ inputs?: RouteInputs<TAgents> } /** * Build `decide()` questions for a subagent router. * * One yes/no question per agent, plus an `order` choice. * `pick` returns `main`, one name, `{ names, order }`, or `{ steps }`. * Names follow the `agents` array order. * `{ names, order }` overrides `subagents.order` for that turn. * `then`: agents that run after the other selected agents. The lead group * starts together. The `then` agents then run one after another in list * order, and each reads the text so far. Used only when the router picks at * least one agent from each group. Otherwise `pick` returns `{ names, order }`. * * An agent with `inputSchema` needs input. `needsInput(result)` lists the * picked agents that need it, with their schemas. Make each input, then pass * them as `pick(result, { inputs })`. Each name with an input becomes * `{ name, input }`. `pick` throws when a picked agent with a schema has no * input. * * @example * ```ts * const route = subagentRoute(agents) * const result = await decide({ adapter, state, questions: route.questions }) * const inputs = { pricer: { sku: 'A-1' } } * return route.pick(result, { inputs }) * ``` */ export function subagentRoute< const TAgents extends ReadonlyArray<DefinedAgent>, >(agents: TAgents, options?: SubagentRouteOptions<TAgents>) { const namesInList = new Set(agents.map((agent) => agent.name)) for (const agent of agents) { if (agent.name === ORDER_KEY) { throw new Error( 'subagentRoute cannot use an agent named "order". Rename that agent.', ) } } for (const name of options?.then ?? []) { if (!namesInList.has(name)) { throw new Error(`subagentRoute then includes unknown agent "${name}".`) } } const questions: Record<string, unknown> = { order: choice({ instructions: 'When more than one agent runs, how must they run?', options: { parallel: 'Start them together. Use this when no agent must read text from another agent. Working on the same topic is not a reason to wait.', sequence: 'Run them in agent-list order. Use this only when a later agent must read the earlier agent text, such as research notes and then a draft article.', }, }), } for (const agent of agents) { const when = options?.when?.[agent.name as TAgents[number]['name']] questions[agent.name] = boolean({ instructions: when ?? agent.description, }) } /** The agents with a yes answer, in agent-list order. */ function picked(result: RouteAnswers<TAgents>) { return agents.filter( (agent) => result[agent.name as TAgents[number]['name']].value, ) } /** * The picked agents that have an `inputSchema`, in agent-list order. Make * an input for each one and pass it to `pick` in `inputs`. */ function needsInput(result: RouteAnswers<TAgents>) { const needs = picked(result).flatMap(({ name, inputSchema }) => inputSchema === undefined ? [] : [{ name, inputSchema }], ) // In this function each agent has only the wide `DefinedAgent` type, // which loses the link between a name and its schema. The cast names // the entry type of each agent again. return needs as Array<InputNeed<TAgents[number]>> } function pick( result: RouteAnswers<TAgents>, pickOptions?: RoutePickOptions<TAgents>, ): SubagentRouterPick { const inputs: Readonly<Partial<Record<string, unknown>>> = pickOptions?.inputs ?? {} const toPickName = ({ name, inputSchema }: DefinedAgent) => { const input = Object.hasOwn(inputs, name) ? inputs[name] : undefined if (input !== undefined) return { name, input } if (inputSchema !== undefined) { throw new Error(`Agent "${name}" needs input. Pass it in inputs.`) } return name } const chosen = picked(result) const names = chosen.map(toPickName) if (names.length === 0) return 'main' const only = names.length === 1 ? names[0] : undefined if (only !== undefined) return only const later = new Set<string>(options?.then ?? []) const lead = chosen .filter((agent) => !later.has(agent.name)) .map(toPickName) const tail = chosen.filter((agent) => later.has(agent.name)).map(toPickName) if (lead.length === 0 || tail.length === 0) { return { names, order: result.order.value } } return { steps: [ lead.length > 1 ? { names: lead, order: 'parallel' } : { names: lead }, tail.length > 1 ? { names: tail, order: 'sequence' } : { names: tail }, ], } } return { questions: questions as RouteQuestions<TAgents>, pick, needsInput } }