Skip to content

feat(agents): support top-level prepare resolver for cross-field async config #74

@zrosenbauer

Description

@zrosenbauer

Summary

Add a prepare option to AgentConfig — a single async function that resolves multiple config fields at once, avoiding redundant async lookups when multiple Resolvers need the same data.

Current state

funkai agents support per-field Resolver<TInput, T> functions (packages/agents/src/core/agents/types.ts:57) that can be async and receive { input }:

const myAgent = agent({
  name: 'support',
  input: z.object({ userId: z.string() }),
  model: async ({ input }) => pickModel(input),
  system: async ({ input }) => buildSystem(input),
  tools: async ({ input }) => pickTools(input),
});

This works well for independent fields, but when multiple fields depend on the same async data, each Resolver fetches independently:

const myAgent = agent({
  name: 'support',
  input: z.object({ userId: z.string() }),
  // fetchUser called 3 times for the same userId
  model: async ({ input }) => {
    const user = await fetchUser(input.userId);
    return user.tier === 'pro' ? openai('gpt-4.1') : openai('gpt-4.1-mini');
  },
  system: async ({ input }) => {
    const user = await fetchUser(input.userId);
    return `You are a support agent.\nUser: ${user.name} (${user.tier})`;
  },
  tools: async ({ input }) => {
    const user = await fetchUser(input.userId);
    return user.role === 'admin' ? allTools : readOnlyTools;
  },
});

Users can work around this with external caching, but it's boilerplate the framework should handle.

Background

The Vercel AI SDK's ToolLoopAgent solves this with prepareCall — a single function that receives all settings and returns modified settings. However, prepareCall exists primarily because class constructors can't be async, so they need a separate hook at .generate() time. funkai doesn't have this limitation since we use factory functions + Resolvers.

Rather than porting prepareCall as-is, a prepare resolver fits funkai's existing patterns more naturally.

Proposed API

A prepare function on AgentConfig that receives { input } and returns a partial config. Runs once per .generate() / .stream() call, after input validation. Fields returned by prepare override the static config but are overridden by per-call params.

const myAgent = agent({
  name: 'support',
  input: z.object({ userId: z.string() }),
  prepare: async ({ input }) => {
    const user = await fetchUser(input.userId);
    return {
      model: user.tier === 'pro' ? openai('gpt-4.1') : openai('gpt-4.1-mini'),
      system: `You are a support agent.\nUser: ${user.name} (${user.tier})`,
      tools: user.role === 'admin' ? allTools : readOnlyTools,
    };
  },
});

Resolution order

static config → prepare({ input }) → per-call params

Each layer overrides the previous. Per-field Resolvers and prepare are mutually compatible — prepare runs first, then any per-field Resolvers that are also defined run on top.

Fields prepare can return

All config fields that currently accept Resolver should be returnable from prepare:

  • model
  • system
  • tools
  • agents
  • maxSteps
  • logger

Static/structural fields (name, input, output, middleware) are not overridable via prepare.

Subagent benefit

When used as a subagent, prepare fires automatically each time the parent invokes it:

const researcher = agent({
  name: 'researcher',
  input: z.object({ query: z.string() }),
  prepare: async ({ input }) => {
    const docs = await vectorSearch(input.query);
    return {
      system: `You are a research agent.\n\nRelevant docs:\n${docs}`,
    };
  },
  tools: { search: searchTool },
});

const orchestrator = agent({
  name: 'orchestrator',
  model: openai('gpt-4.1'),
  agents: { researcher }, // researcher.prepare fires on each delegation
});

Implementation notes

  • prepare runs in agent.ts after input validation (~line 341), before prepareGeneration() (~line 353)
  • The return type is Partial<Pick<AgentConfig, 'model' | 'system' | 'tools' | 'agents' | 'maxSteps' | 'logger'>>
  • Resolved prepare values are merged into the config before prepareGeneration() resolves individual Resolvers
  • prepare is not overridable via per-call params (it's a definition-time concern, not a call-site concern)
  • Needs a decision on interaction with per-field Resolvers when both are defined on the same field

Open questions

  • Should prepare receive the static config values (pre-Resolver) so it can extend them, or just { input }?
  • If both prepare and a per-field Resolver are set for the same field (e.g., system), which wins? Suggestion: prepare runs first, per-field Resolver runs on top (but this may be confusing — might be simpler to make them mutually exclusive per-field)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or improvement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions