-
Notifications
You must be signed in to change notification settings - Fork 0
feat(agents): support top-level prepare resolver for cross-field async config #74
Description
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:
modelsystemtoolsagentsmaxStepslogger
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
prepareruns inagent.tsafter input validation (~line 341), beforeprepareGeneration()(~line 353)- The return type is
Partial<Pick<AgentConfig, 'model' | 'system' | 'tools' | 'agents' | 'maxSteps' | 'logger'>> - Resolved
preparevalues are merged into the config beforeprepareGeneration()resolves individual Resolvers prepareis 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
preparereceive the static config values (pre-Resolver) so it can extend them, or just{ input }? - If both
prepareand a per-field Resolver are set for the same field (e.g.,system), which wins? Suggestion:prepareruns first, per-field Resolver runs on top (but this may be confusing — might be simpler to make them mutually exclusive per-field)