Skip to content
5 changes: 5 additions & 0 deletions .changeset/input-param-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@funkai/agents": minor
---

Add input parameter parity with the Vercel AI SDK. Surface CallSettings (temperature, maxOutputTokens, topP, topK, presencePenalty, frequencyPenalty, stopSequences, seed, maxRetries), telemetry, granular timeout objects, custom stop conditions (stopWhen), and stream-only callbacks (onChunk, onStreamError, onAbort) on both AgentConfig and per-call overrides.
108 changes: 97 additions & 11 deletions packages/agents/src/core/agents/base/agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { generateText, streamText, stepCountIs } from "ai";
import type { AsyncIterableStream, GenerateTextResult, ModelMessage, ToolSet } from "ai";
import type {
AsyncIterableStream,
GenerateTextResult,
ModelMessage,
StopCondition,
ToolSet,
} from "ai";

// See types.ts for why `any` is needed here — AI SDK's `Output` is a merged namespace + interface.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -170,28 +176,47 @@ export function agent<
readonly output: OutputSpec | undefined;
readonly maxSteps: number;
readonly signal: AbortSignal | undefined;
readonly timeout: Record<string, number> | undefined;
readonly onStepFinish: (step: AIStepResult) => Promise<void>;
readonly onStepStart: ((event: unknown) => Promise<void>) | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI SDK passthrough params
readonly aiSdkParams: Record<string, any>;
readonly stopConditions: StopCondition<ToolSet> | StopCondition<ToolSet>[] | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI SDK stream-only params
readonly streamOnlyParams: Record<string, any>;
}

/**
* Resolve the abort signal from params, combining `signal` and `timeout`.
* Resolve the abort signal and timeout from params.
*
* When `timeout` is a number, creates an `AbortSignal.timeout()`.
* When `timeout` is an object, it's forwarded as the AI SDK `timeout`
* param and signal is used as-is.
*
* @private
*/
function resolveSignal(
params: GenerateParams<TInput, TTools, TSubAgents, TOutput>,
): AbortSignal | undefined {
function resolveSignalAndTimeout(params: GenerateParams<TInput, TTools, TSubAgents, TOutput>): {
signal: AbortSignal | undefined;
timeout: Record<string, number> | undefined;
} {
const { timeout, signal } = params;

// Object timeout — forward to AI SDK directly
if (isNotNil(timeout) && typeof timeout === "object") {
return { signal, timeout: timeout as Record<string, number> };
}

// Number timeout — convert to AbortSignal
if (signal && isNotNil(timeout)) {
return AbortSignal.any([signal, AbortSignal.timeout(timeout)]);
return {
signal: AbortSignal.any([signal, AbortSignal.timeout(timeout)]),
timeout: undefined,
};
}
if (isNotNil(timeout)) {
return AbortSignal.timeout(timeout);
return { signal: AbortSignal.timeout(timeout), timeout: undefined };
}
return signal;
return { signal, timeout: undefined };
}

/**
Expand Down Expand Up @@ -260,7 +285,7 @@ export function agent<

const resolvedMaxSteps = await resolveOptionalValue(config.maxSteps, input);
const maxSteps = params.maxSteps ?? resolvedMaxSteps ?? 20;
const signal = resolveSignal(params);
const { signal, timeout } = resolveSignalAndTimeout(params);

await fireHooks(log, wrapHook(config.onStart, { input }), wrapHook(params.onStart, { input }));

Expand Down Expand Up @@ -310,6 +335,28 @@ export function agent<
experimental_download: params.experimental_download ?? config.experimental_download,
experimental_onToolCallStart: params.onToolCallStart ?? config.onToolCallStart,
experimental_onToolCallFinish: params.onToolCallFinish ?? config.onToolCallFinish,
// CallSettings
maxOutputTokens: params.maxOutputTokens ?? config.maxOutputTokens,
temperature: params.temperature ?? config.temperature,
topP: params.topP ?? config.topP,
topK: params.topK ?? config.topK,
presencePenalty: params.presencePenalty ?? config.presencePenalty,
frequencyPenalty: params.frequencyPenalty ?? config.frequencyPenalty,
stopSequences: params.stopSequences ?? config.stopSequences,
seed: params.seed ?? config.seed,
maxRetries: params.maxRetries ?? config.maxRetries,
// Telemetry
experimental_telemetry: params.experimental_telemetry ?? config.experimental_telemetry,
},
isNotNil,
);

// Stream-only params (onChunk, onError, onAbort) — forwarded only in stream()
const streamOnlyParams = pickBy(
{
onChunk: params.onChunk,
onError: params.onStreamError,
onAbort: params.onAbort,
},
isNotNil,
);
Expand All @@ -323,9 +370,12 @@ export function agent<
output,
maxSteps,
signal,
timeout,
onStepFinish,
onStepStart,
aiSdkParams,
stopConditions: params.stopWhen,
streamOnlyParams,
};
}

Expand Down Expand Up @@ -360,9 +410,11 @@ export function agent<
output,
maxSteps,
signal,
timeout: resolvedTimeout,
onStepFinish,
onStepStart,
aiSdkParams,
stopConditions,
} = prepared;

log.debug("agent.generate start", { name: config.name });
Expand All @@ -372,7 +424,7 @@ export function agent<
model,
...promptParams,
...aiSdkParams,
stopWhen: stepCountIs(maxSteps),
stopWhen: buildStopConditions(maxSteps, stopConditions),
onStepFinish,
};
if (system !== undefined) {
Expand All @@ -387,6 +439,9 @@ export function agent<
if (signal !== undefined) {
generateParams.abortSignal = signal;
}
if (resolvedTimeout !== undefined) {
generateParams.timeout = resolvedTimeout;
}
if (onStepStart !== undefined) {
generateParams.experimental_onStepStart = onStepStart;
}
Expand Down Expand Up @@ -470,9 +525,12 @@ export function agent<
output,
maxSteps,
signal,
timeout: resolvedTimeout,
onStepFinish,
onStepStart,
aiSdkParams,
stopConditions,
streamOnlyParams,
} = prepared;

log.debug("agent.stream start", { name: config.name });
Expand All @@ -482,7 +540,8 @@ export function agent<
model,
...promptParams,
...aiSdkParams,
stopWhen: stepCountIs(maxSteps),
...streamOnlyParams,
stopWhen: buildStopConditions(maxSteps, stopConditions),
onStepFinish,
};
if (system !== undefined) {
Expand All @@ -497,6 +556,9 @@ export function agent<
if (signal !== undefined) {
streamParams.abortSignal = signal;
}
if (resolvedTimeout !== undefined) {
streamParams.timeout = resolvedTimeout;
}
if (onStepStart !== undefined) {
streamParams.experimental_onStepStart = onStepStart;
}
Expand Down Expand Up @@ -741,6 +803,30 @@ function pickByOutput<T>(output: unknown, ifOutput: T, ifText: T): T {
return ifText;
}

/**
* Build the combined stop conditions array.
*
* Always includes `stepCountIs(maxSteps)` as a safety ceiling.
* User-provided conditions are prepended so they can trigger first.
*
* @private
*/
function buildStopConditions(
maxSteps: number,
userConditions: StopCondition<ToolSet> | StopCondition<ToolSet>[] | undefined,
): StopCondition<ToolSet> | StopCondition<ToolSet>[] {
if (isNil(userConditions)) {
return stepCountIs(maxSteps);
}
if (Array.isArray(userConditions)) {
if (userConditions.length === 0) {
return stepCountIs(maxSteps);
}
return [...userConditions, stepCountIs(maxSteps)];
}
return [userConditions, stepCountIs(maxSteps)];
}

/**
* Build a merged hook that fires config-level and per-call hooks sequentially.
*
Expand Down
17 changes: 13 additions & 4 deletions packages/agents/src/core/agents/flow/flow-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,20 @@ export function flowAgent<TInput, TOutput = any>(
*/
function resolveSignal(params: FlowGenerateParams): AbortSignal {
const { timeout, signal } = params;
if (signal && isNotNil(timeout)) {
return AbortSignal.any([signal, AbortSignal.timeout(timeout)]);

// Extract the numeric timeout value for the flow-level abort signal
let timeoutMs: number | undefined;
if (typeof timeout === "number") {
timeoutMs = timeout;
} else if (typeof timeout === "object" && isNotNil(timeout)) {
timeoutMs = timeout.totalMs;
}

if (signal && isNotNil(timeoutMs)) {
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
}
if (isNotNil(timeout)) {
return AbortSignal.timeout(timeout);
if (isNotNil(timeoutMs)) {
return AbortSignal.timeout(timeoutMs);
}
if (signal) {
return signal;
Expand Down
Loading