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
9 changes: 9 additions & 0 deletions .changeset/evolve-model-generic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@funkai/agents": minor
---

Add `TModel` generic to `AgentConfig` and `Agent` for discriminated model types in `evolve()`.

Previously, `evolve(base, (config) => ...)` always typed `config.model` as the full `Resolver<TInput, Model>` union, even when the base agent was created with a static `LanguageModel`. This required unnecessary narrowing with `isFunction()` before accessing `.modelId`.

Now the 5th generic `TModel` is inferred from `agent()` and threaded through `evolve()`, so `config.model` is correctly typed as `Model` (with `.modelId`) when the base agent uses a static model.
10 changes: 8 additions & 2 deletions examples/realworld-cli/api/agents/analyzer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { openai } from "@ai-sdk/openai";
import { agent, evolve } from "@funkai/agents";
import type { Tool } from "@funkai/agents";
import type { Agent, Tool } from "@funkai/agents";
import { prompts } from "~prompts";

/**
Expand All @@ -24,6 +24,12 @@ const baseAnalyzer = agent({
* @param tools - The filesystem tools scoped to the target directory.
* @param testFilePath - The path to the test file being analyzed.
* @returns An agent configured to analyze test quality.
*
* @example
* ```ts
* const analyzer = createAnalyzerAgent(fsTools, "src/foo.test.ts")
* const result = await analyzer.generate({ prompt: "Review this test file" })
* ```
*/
export const createAnalyzerAgent = (
tools: {
Expand All @@ -32,7 +38,7 @@ export const createAnalyzerAgent = (
readonly ls: Tool;
},
testFilePath: string,
) =>
): Agent =>
evolve(baseAnalyzer, {
system: prompts.agents.analyzer.render({
testFilePath,
Expand Down
10 changes: 8 additions & 2 deletions examples/realworld-cli/api/agents/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { openai } from "@ai-sdk/openai";
import { agent } from "@funkai/agents";
import type { Tool } from "@funkai/agents";
import type { Agent, Tool } from "@funkai/agents";
import { prompts } from "~prompts";

/**
* Create the scanner agent that finds test files in a codebase.
*
* @param tools - The filesystem tools scoped to the target directory.
* @returns An agent configured to scan for test files.
*
* @example
* ```ts
* const scanner = createScannerAgent(fsTools)
* const result = await scanner.generate({ prompt: "Find test files" })
* ```
*/
export const createScannerAgent = (tools: { readonly ls: Tool; readonly grep: Tool }) =>
export const createScannerAgent = (tools: { readonly ls: Tool; readonly grep: Tool }): Agent =>
agent({
name: "scanner",
model: openai("gpt-4.1"),
Expand Down
10 changes: 6 additions & 4 deletions packages/agents/src/core/agents/base/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { createDefaultLogger } from "@/core/logger.js";
import type { Logger } from "@/core/logger.js";
import type { LanguageModel } from "@/core/provider/types.js";
import type { Tool } from "@/core/tool.js";
import type { StepFinishEvent, StreamPart } from "@/core/types.js";
import type { Model, StepFinishEvent, StreamPart } from "@/core/types.js";
import { fireHooks, wrapHook } from "@/lib/hooks.js";
import { withModelMiddleware } from "@/lib/middleware.js";
import { AGENT_CONFIG, RUNNABLE_META } from "@/lib/runnable.js";
Expand Down Expand Up @@ -86,9 +86,10 @@ export function agent<
TTools extends Record<string, Tool> = {},
// oxlint-disable-next-line typescript-eslint/ban-types
TSubAgents extends SubAgents = {},
TModel extends Resolver<TInput, Model> = Resolver<TInput, Model>,
>(
config: AgentConfig<TInput, TOutput, TTools, TSubAgents>,
): Agent<TInput, TOutput, TTools, TSubAgents> {
config: AgentConfig<TInput, TOutput, TTools, TSubAgents, TModel>,
): Agent<TInput, TOutput, TTools, TSubAgents, TModel> {
/**
* Extract the raw input from unified params.
*
Expand Down Expand Up @@ -538,7 +539,8 @@ export function agent<
}

// eslint-disable-next-line no-shadow -- Local variable is the return value constructed inside its own factory function
const agent: Agent<TInput, TOutput, TTools, TSubAgents> = {
const agent: Agent<TInput, TOutput, TTools, TSubAgents, TModel> = {
model: config.model,
generate,
stream,
fn: () => generate,
Expand Down
6 changes: 3 additions & 3 deletions packages/agents/src/core/agents/base/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type { RunnableMeta } from "@/lib/runnable.js";
export function buildAITools(
tools?: Record<string, Tool>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent generic params are contravariant; `unknown` breaks assignability
agents?: Record<string, Agent<any, any, any, any>>,
agents?: Record<string, Agent<any, any, any, any, any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ToolSet requires `any` values; `unknown` breaks assignability with AI SDK
): Record<string, any> | undefined {
const hasTools = isNotNil(tools) && Object.keys(tools).length > 0;
Expand Down Expand Up @@ -251,7 +251,7 @@ function resolveToolName(meta: RunnableMeta | undefined, fallback: string): stri
*/
function buildAgentTools(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent generic params are contravariant; `unknown` breaks assignability
agents: Record<string, Agent<any, any, any, any>> | undefined,
agents: Record<string, Agent<any, any, any, any, any>> | undefined,
tools: Record<string, Tool> | undefined,
): Record<string, unknown> {
if (!agents) {
Expand Down Expand Up @@ -294,7 +294,7 @@ function buildAgentTools(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ToolSet requires `any` values; `unknown` breaks assignability with AI SDK
function buildAgentTool(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent generic params are contravariant; `unknown` breaks assignability
runnable: Agent<any, any, any, any>,
runnable: Agent<any, any, any, any, any>,
meta: RunnableMeta | undefined,
toolName: string,
tools: Record<string, Tool> | undefined,
Expand Down
20 changes: 11 additions & 9 deletions packages/agents/src/core/agents/evolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import type {
FlowAgentHandler,
FlowSubAgents,
} from "@/core/agents/flow/types.js";
import type { Agent, AgentConfig, SubAgents } from "@/core/agents/types.js";
import type { Agent, AgentConfig, Resolver, SubAgents } from "@/core/agents/types.js";
import type { Tool } from "@/core/tool.js";
import type { Model } from "@/core/types.js";
import { getAgentConfig, getFlowAgentConfig, isAgent, isFlowAgent } from "@/lib/runnable.js";

/**
Expand Down Expand Up @@ -79,14 +80,15 @@ export function evolve<
TOutput,
TTools extends Record<string, Tool>,
TSubAgents extends SubAgents,
TModel extends Resolver<TInput, Model>,
>(
base: Agent<TInput, TOutput, TTools, TSubAgents>,
base: Agent<TInput, TOutput, TTools, TSubAgents, TModel>,
overrides:
| Partial<AgentConfig<TInput, TOutput, TTools, TSubAgents>>
| Partial<AgentConfig<TInput, TOutput, TTools, TSubAgents, TModel>>
| ((
config: AgentConfig<TInput, TOutput, TTools, TSubAgents>,
) => Partial<AgentConfig<TInput, TOutput, TTools, TSubAgents>>),
): Agent<TInput, TOutput, TTools, TSubAgents>;
config: AgentConfig<TInput, TOutput, TTools, TSubAgents, TModel>,
) => Partial<AgentConfig<TInput, TOutput, TTools, TSubAgents, TModel>>),
): Agent<TInput, TOutput, TTools, TSubAgents, TModel>;

/**
* Create a new flow agent from an existing one with config overrides.
Expand Down Expand Up @@ -154,7 +156,7 @@ function evolveAgent(
overridesOrMapper: Record<string, any> | ((config: any) => Record<string, any>),
): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- internal: stored config type is erased
const baseConfig = getAgentConfig<AgentConfig<any, any, any, any>>(base);
const baseConfig = getAgentConfig<AgentConfig<any, any, any, any, any>>(base);
if (isNil(baseConfig)) {
throw new Error("Cannot evolve: agent does not have stored configuration.");
}
Expand Down Expand Up @@ -203,9 +205,9 @@ function evolveFlowAgent(
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- internal merge operates on erased types
function mergeAgentConfigs(
base: AgentConfig<any, any, any, any>,
base: AgentConfig<any, any, any, any, any>,
overrides: Record<string, unknown>,
): AgentConfig<any, any, any, any> {
): AgentConfig<any, any, any, any, any> {
const { tools: overrideTools, agents: overrideAgents, ...restOverrides } = overrides;
return {
...base,
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/core/agents/flow/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type { StepInfo } from "@/core/types.js";
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FlowSubAgents = Record<string, Agent<any, any, any, any> | FlowAgent<any, any>>;
export type FlowSubAgents = Record<string, Agent<any, any, any, any, any> | FlowAgent<any, any>>;

/**
* Result of a completed flow agent generation.
Expand Down
14 changes: 12 additions & 2 deletions packages/agents/src/core/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export type ToolName<S extends string> = S extends ""
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SubAgents = Record<string, Agent<any, any, any, any>>;
export type SubAgents = Record<string, Agent<any, any, any, any, any>>;

/**
* Chat message type.
Expand Down Expand Up @@ -500,6 +500,7 @@ export interface AgentConfig<
TOutput,
TTools extends Record<string, Tool>,
TSubAgents extends SubAgents,
TModel extends Resolver<TInput, Model> = Resolver<TInput, Model>,
> {
/**
* Unique agent name.
Expand All @@ -518,7 +519,7 @@ export interface AgentConfig<
* @see {@link Model}
* @see {@link Resolver}
*/
model: Resolver<TInput, Model>;
model: TModel;

/**
* Zod schema for the agent's typed input.
Expand Down Expand Up @@ -676,7 +677,16 @@ export interface Agent<
TOutput = string,
TTools extends Record<string, Tool> = Record<string, Tool>,
TSubAgents extends SubAgents = Record<string, never>,
TModel extends Resolver<TInput, Model> = Resolver<TInput, Model>,
> {
/**
* The model (or resolver) used by this agent.
*
* Exposes the value passed via `AgentConfig.model` so that
* `evolve()` can infer and preserve the concrete model type.
*/
readonly model: TModel;

/**
* Run the agent to completion.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/lib/runnable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface RunnableMeta {
* @internal
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Widest instantiation; concrete generics are unknown at runtime
export function isAgent(value: unknown): value is Agent<any, any, any, any> {
export function isAgent(value: unknown): value is Agent<any, any, any, any, any> {
return isObject(value) && has(value, AGENT_CONFIG);
}

Expand Down