Skip to content

feat(js): added dynamicTool factory function that does not depend on genkit instance #3026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 10, 2025
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
32 changes: 17 additions & 15 deletions js/ai/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import {
GenkitError,
isAction,
isDetachedAction,
runWithContext,
runWithStreamingCallback,
sentinelNoopStreamingCallback,
Expand Down Expand Up @@ -52,8 +54,13 @@ import {
type ToolRequestPart,
type ToolResponsePart,
} from './model.js';
import type { ExecutablePrompt } from './prompt.js';
import { resolveTools, toToolDefinition, type ToolArgument } from './tool.js';
import { isExecutablePrompt } from './prompt.js';
import {
isDynamicTool,
resolveTools,
toToolDefinition,
type ToolArgument,
} from './tool.js';
export { GenerateResponse, GenerateResponseChunk };

/** Specifies how tools should be called by the model. */
Expand Down Expand Up @@ -264,15 +271,11 @@ async function toolsToActionRefs(
for (const t of toolOpt) {
if (typeof t === 'string') {
tools.push(await resolveFullToolName(registry, t));
} else if ((t as Action).__action) {
tools.push(
`/${(t as Action).__action.metadata?.type}/${(t as Action).__action.name}`
);
} else if (typeof (t as ExecutablePrompt).asTool === 'function') {
const promptToolAction = await (t as ExecutablePrompt).asTool();
} else if (isAction(t) || isDynamicTool(t)) {
tools.push(`/${t.__action.metadata?.type}/${t.__action.name}`);
} else if (isExecutablePrompt(t)) {
const promptToolAction = await t.asTool();
tools.push(`/prompt/${promptToolAction.__action.name}`);
} else if (t.name) {
tools.push(await resolveFullToolName(registry, t.name));
} else {
throw new Error(`Unable to determine type of tool: ${JSON.stringify(t)}`);
}
Expand Down Expand Up @@ -370,11 +373,10 @@ function maybeRegisterDynamicTools<
>(registry: Registry, options: GenerateOptions<O, CustomOptions>): Registry {
let hasDynamicTools = false;
options?.tools?.forEach((t) => {
if (
(t as Action).__action &&
(t as Action).__action.metadata?.type === 'tool' &&
(t as Action).__action.metadata?.dynamic
) {
if (isDynamicTool(t)) {
if (isDetachedAction(t)) {
t = t.attach(registry);
}
if (!hasDynamicTools) {
hasDynamicTools = true;
// Create a temporary registry with dynamic tools for the duration of this
Expand Down
2 changes: 1 addition & 1 deletion js/ai/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ async function renderDotpromptToParts<
/**
* Checks whether the provided object is an executable prompt.
*/
export function isExecutablePrompt(obj: any): boolean {
export function isExecutablePrompt(obj: any): obj is ExecutablePrompt {
return (
!!(obj as ExecutablePrompt)?.render &&
!!(obj as ExecutablePrompt)?.asTool &&
Expand Down
132 changes: 93 additions & 39 deletions js/ai/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@
*/

import {
action,
assertUnstable,
defineAction,
detachedAction,
isAction,
isDetachedAction,
stripUndefinedProps,
z,
type Action,
type ActionContext,
type ActionRunOptions,
type DetachedAction,
type JSONSchema7,
} from '@genkit-ai/core';
import type { HasRegistry, Registry } from '@genkit-ai/core/registry';
import type { Registry } from '@genkit-ai/core/registry';
import { parseSchema, toJsonSchema } from '@genkit-ai/core/schema';
import { setCustomMetadataAttributes } from '@genkit-ai/core/tracing';
import type {
Expand All @@ -34,20 +37,12 @@ import type {
ToolRequestPart,
ToolResponsePart,
} from './model.js';
import type { ExecutablePrompt } from './prompt.js';
import { isExecutablePrompt, type ExecutablePrompt } from './prompt.js';

/**
* An action with a `tool` type.
*/
export type ToolAction<
export interface Resumable<
I extends z.ZodTypeAny = z.ZodTypeAny,
O extends z.ZodTypeAny = z.ZodTypeAny,
> = Action<I, O, z.ZodTypeAny, ToolRunOptions> & {
__action: {
metadata: {
type: 'tool';
};
};
> {
/**
* respond constructs a tool response corresponding to the provided interrupt tool request
* using the provided reply data, validating it against the output schema of the tool if
Expand Down Expand Up @@ -90,7 +85,37 @@ export type ToolAction<
replaceInput?: z.infer<I>;
}
): ToolRequestPart;
};
}

/**
* An action with a `tool` type.
*/
export type ToolAction<
I extends z.ZodTypeAny = z.ZodTypeAny,
O extends z.ZodTypeAny = z.ZodTypeAny,
> = Action<I, O, z.ZodTypeAny, ToolRunOptions> &
Resumable<I, O> & {
__action: {
metadata: {
type: 'tool';
};
};
};

/**
* A dynamic action with a `tool` type. Dynamic tools are detached actions -- not associated with any registry.
*/
export type DynamicToolAction<
I extends z.ZodTypeAny = z.ZodTypeAny,
O extends z.ZodTypeAny = z.ZodTypeAny,
> = DetachedAction<I, O, z.ZodTypeAny, ToolRunOptions> &
Resumable<I, O> & {
__action: {
metadata: {
type: 'tool';
};
};
};

export interface ToolRunOptions extends ActionRunOptions<z.ZodTypeAny> {
/**
Expand Down Expand Up @@ -128,7 +153,12 @@ export interface ToolConfig<I extends z.ZodTypeAny, O extends z.ZodTypeAny> {
export type ToolArgument<
I extends z.ZodTypeAny = z.ZodTypeAny,
O extends z.ZodTypeAny = z.ZodTypeAny,
> = string | ToolAction<I, O> | Action<I, O> | ExecutablePrompt<any, any, any>;
> =
| string
| ToolAction<I, O>
| DynamicToolAction<I, O>
| Action<I, O>
| ExecutablePrompt<any, any, any>;

/**
* Converts an action to a tool action by setting the appropriate metadata.
Expand Down Expand Up @@ -170,14 +200,15 @@ export async function resolveTools<
tools.map(async (ref): Promise<ToolAction> => {
if (typeof ref === 'string') {
return await lookupToolByName(registry, ref);
} else if ((ref as Action).__action) {
return asTool(registry, ref as Action);
} else if (typeof (ref as ExecutablePrompt).asTool === 'function') {
return await (ref as ExecutablePrompt).asTool();
} else if (ref.name) {
} else if (isAction(ref)) {
return asTool(registry, ref);
} else if (isExecutablePrompt(ref)) {
return await ref.asTool();
} else if ((ref as ToolDefinition).name) {
return await lookupToolByName(
registry,
(ref as ToolDefinition).metadata?.originalName || ref.name
(ref as ToolDefinition).metadata?.originalName ||
(ref as ToolDefinition).name
);
}
throw new Error('Tools must be strings, tool definitions, or actions.');
Expand Down Expand Up @@ -278,14 +309,16 @@ export function defineTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
function implementTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
a: ToolAction<I, O>,
config: ToolConfig<I, O>,
registry: Registry
registry?: Registry
) {
(a as ToolAction<I, O>).respond = (interrupt, responseData, options) => {
assertUnstable(
registry,
'beta',
"The 'tool.reply' method is part of the 'interrupts' beta feature."
);
if (registry) {
assertUnstable(
registry,
'beta',
"The 'tool.reply' method is part of the 'interrupts' beta feature."
);
}
parseSchema(responseData, {
jsonSchema: config.outputJsonSchema,
schema: config.outputSchema,
Expand All @@ -303,11 +336,13 @@ function implementTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
};

(a as ToolAction<I, O>).restart = (interrupt, resumedMetadata, options) => {
assertUnstable(
registry,
'beta',
"The 'tool.restart' method is part of the 'interrupts' beta feature."
);
if (registry) {
assertUnstable(
registry,
'beta',
"The 'tool.restart' method is part of the 'interrupts' beta feature."
);
}
let replaceInput = options?.replaceInput;
if (replaceInput) {
replaceInput = parseSchema(replaceInput, {
Expand Down Expand Up @@ -352,6 +387,14 @@ export function isToolResponse(part: Part): part is ToolResponsePart {
return !!part.toolResponse;
}

export function isDynamicTool(t: unknown): t is DynamicToolAction {
return (
(isDetachedAction(t) || isAction(t)) &&
t.__action.metadata?.type === 'tool' &&
t.__action.metadata?.dynamic
);
}

export function defineInterrupt<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
registry: Registry,
config: InterruptConfig<I, O>
Expand Down Expand Up @@ -396,19 +439,17 @@ function interruptTool(registry: Registry) {
* Genkit registry and can be defined dynamically at runtime.
*/
export function dynamicTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
ai: HasRegistry,
config: ToolConfig<I, O>,
fn?: ToolFn<I, O>
): ToolAction<I, O> {
const a = action(
ai.registry,
): DynamicToolAction<I, O> {
const a = detachedAction(
{
...config,
actionType: 'tool',
metadata: { ...(config.metadata || {}), type: 'tool', dynamic: true },
},
(i, runOptions) => {
const interrupt = interruptTool(ai.registry);
const interrupt = interruptTool(runOptions.registry);
if (fn) {
return fn(i, {
...runOptions,
Expand All @@ -419,6 +460,19 @@ export function dynamicTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
return interrupt();
}
);
implementTool(a as ToolAction<I, O>, config, ai.registry);
return a as ToolAction<I, O>;
implementTool(a as any, config);
return {
__action: {
...a.__action,
metadata: {
...a.__action.metadata,
type: 'tool',
},
},
attach(registry) {
const bound = a.attach(registry);
implementTool(bound as ToolAction<I, O>, config);
return bound;
},
} as DynamicToolAction<I, O>;
}
Loading