Skip to content

Commit 6f3d40a

Browse files
seefeldbclaude
andauthored
feat(llm tools): Add patternTool helper to support pattern LLM tools (#1962)
* feat: Add extraParams support to LLM tool definitions Adds the ability to pass extra parameters to pattern-based tools in LLM dialog tool invocations. This allows tool definitions to specify additional static parameters that get merged with the LLM-provided input when invoking the underlying pattern. Changes: - Add extraParams field to LLMToolSchema for pattern-based tools - Merge extraParams with tool call input when invoking patterns - Update calculator pattern to accept optional base parameter - Add example usage in default-app with base: 10 for calculator tool This enables use cases like configuring tool behavior (e.g., number base for calculator) without requiring the LLM to provide those values. * feat: Allow patterns as LLM tool actions via OpaqueRef Extends LLM tool system to support passing pattern recipes as actions, not just handler streams. This enables using recipes with extraParams directly as tool definitions. Changes: - Modified invokeToolCall to accept pattern and extraParams via charmMeta - Updated tool resolution in startRequest to detect and extract patterns from OpaqueRef cells - Added grep pattern example in note.tsx demonstrating the new capability This allows more flexible tool definitions where patterns can be composed with additional parameters and used as LLM actions. * feat: Add patternTool helper for LLM tool definitions with pre-filled params Add a new `patternTool` helper function that simplifies creating LLM tool definitions from recipes with optional pre-filled parameters. This eliminates the need for manual type casting and provides automatic type inference. The helper: - Automatically creates a recipe with description "tool" - Constructs the { pattern, extraParams } object structure - Properly types the result as OpaqueRef<Omit<T, keyof E>> (excluding pre-filled params) - No manual type casting needed Example usage: ```typescript grep: patternTool( ({ query, content }: { query: string; content: string }) => { return derive({ query, content }, ({ query, content }) => { return content.split("\n").filter((c) => c.includes(query)); }); }, { content }, ), // Type is automatically inferred as OpaqueRef<{ query: string }> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * compile api types * feat: Allow patternTool to accept existing recipes Extend patternTool to accept either a function or an already-created RecipeFactory. This provides flexibility when you want to reuse an existing recipe with pre-filled parameters. The function now: - Checks if the input is a recipe using isRecipe() - Only creates a new recipe if a function is passed - Reuses the existing recipe if a RecipeFactory is passed Example usage with existing recipe: ```typescript const myRecipe = recipe<{ query: string; content: string }>( "Grep", ({ query, content }) => { ... } ); // Pass existing recipe with extra params const tool = patternTool(myRecipe, { content }); // Type: OpaqueRef<{ query: string }> ``` Updated default-app.tsx to demonstrate this pattern with the calculator tool. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * sanitize base input * patternTool's 2nd parameter is optional, adjust type accordingly --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8056036 commit 6f3d40a

File tree

9 files changed

+130
-17
lines changed

9 files changed

+130
-17
lines changed

packages/api/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,14 @@ export type RecipeFunction = {
388388
): RecipeFactory<T, R>;
389389
};
390390

391+
export type PatternToolFunction = <
392+
T,
393+
E extends Partial<T> = Record<PropertyKey, never>,
394+
>(
395+
fnOrRecipe: ((input: OpaqueRef<Required<T>>) => any) | RecipeFactory<T, any>,
396+
extraParams?: Opaque<E>,
397+
) => OpaqueRef<Omit<T, keyof E>>;
398+
391399
export type LiftFunction = {
392400
<T extends JSONSchema = JSONSchema, R extends JSONSchema = JSONSchema>(
393401
argumentSchema: T,
@@ -573,6 +581,7 @@ export type GetRecipeEnvironmentFunction = () => RecipeEnvironment;
573581
// Re-export all function types as values for destructuring imports
574582
// These will be implemented by the factory
575583
export declare const recipe: RecipeFunction;
584+
export declare const patternTool: PatternToolFunction;
576585
export declare const lift: LiftFunction;
577586
export declare const handler: HandlerFunction;
578587
export declare const derive: DeriveFunction;

packages/patterns/common-tools.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,27 @@ import {
1818
type CalculatorRequest = {
1919
/** The mathematical expression to evaluate. */
2020
expression: string;
21+
/** The base to use for the calculation. */
22+
base?: number;
2123
};
2224

2325
export const calculator = recipe<
2426
CalculatorRequest,
2527
string | { error: string }
26-
>("Calculator", ({ expression }) => {
27-
return derive(expression, (expr) => {
28-
const sanitized = expr.replace(/[^0-9+\-*/().\s]/g, "");
28+
>("Calculator", ({ expression, base }) => {
29+
return derive({ expression, base }, ({ expression, base }) => {
30+
const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, "");
31+
let sanitizedBase = Number(base);
32+
if (
33+
Number.isNaN(sanitizedBase) || sanitizedBase < 2 || sanitizedBase > 36
34+
) {
35+
sanitizedBase = 10;
36+
}
2937
let result;
3038
try {
31-
result = Function(`"use strict"; return (${sanitized})`)();
39+
result = Function(
40+
`"use strict"; return Number(${sanitized}).toString(${sanitizedBase})`,
41+
)();
3242
} catch (error) {
3343
result = { error: (error as any)?.message || "<error>" };
3444
}

packages/patterns/default-app.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
lift,
1010
NAME,
1111
navigateTo,
12+
patternTool,
1213
recipe,
1314
str,
1415
UI,
@@ -170,9 +171,8 @@ export default recipe<CharmsListInput, CharmsListOutput>(
170171
readWebpage: {
171172
pattern: readWebpage,
172173
},
173-
calculator: {
174-
pattern: calculator,
175-
},
174+
// Example of using patternTool with an existing recipe and extra params
175+
calculator: patternTool(calculator, { base: 10 }),
176176
},
177177
});
178178

packages/patterns/note.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
navigateTo,
1010
Opaque,
1111
OpaqueRef,
12+
patternTool,
1213
recipe,
1314
UI,
1415
wish,
@@ -25,6 +26,7 @@ type Output = {
2526

2627
/** The content of the note */
2728
content: Default<string, "">;
29+
grep: OpaqueRef<{ query: string }>;
2830
editContent: OpaqueRef<{ detail: { value: string } }>;
2931
};
3032

@@ -154,6 +156,14 @@ const Note = recipe<Input, Output>(
154156
content,
155157
mentioned,
156158
backlinks,
159+
grep: patternTool(
160+
({ query, content }: { query: string; content: string }) => {
161+
return derive({ query, content }, ({ query, content }) => {
162+
return content.split("\n").filter((c) => c.includes(query));
163+
});
164+
},
165+
{ content },
166+
),
157167
editContent: handleEditContent({ content }),
158168
};
159169
},

packages/runner/src/builder/built-in.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { BuiltInLLMDialogState } from "@commontools/api";
22
import { createNodeFactory, lift } from "./module.ts";
3+
import { recipe } from "./recipe.ts";
4+
import { isRecipe } from "./types.ts";
35
import type {
46
Cell,
57
JSONSchema,
68
NodeFactory,
79
Opaque,
810
OpaqueRef,
11+
RecipeFactory,
912
Schema,
1013
} from "./types.ts";
1114
import type {
@@ -152,3 +155,52 @@ declare function createCell<S extends JSONSchema = JSONSchema>(
152155
): Cell<Schema<S>>;
153156

154157
export type { createCell };
158+
159+
/**
160+
* Helper function for creating LLM tool definitions from recipes with optional pre-filled parameters.
161+
* Creates a recipe with the given function and returns an object suitable for use as an LLM tool,
162+
* with proper TypeScript typing that reflects only the non-pre-filled parameters.
163+
*
164+
* @param fnOrRecipe - Either a recipe function or an already-created RecipeFactory
165+
* @param extraParams - Optional object containing parameter values to pre-fill
166+
* @returns An object with `pattern` and `extraParams` properties, typed to show only remaining params
167+
*
168+
* @example
169+
* ```ts
170+
* import { patternTool } from "commontools";
171+
*
172+
* const content = cell("Hello world");
173+
*
174+
* // With a function - recipe will be created automatically
175+
* const grepTool = patternTool(
176+
* ({ query, content }: { query: string; content: string }) => {
177+
* return derive({ query, content }, ({ query, content }) => {
178+
* return content.split("\n").filter((c) => c.includes(query));
179+
* });
180+
* },
181+
* { content }
182+
* );
183+
*
184+
* // With an existing recipe
185+
* const myRecipe = recipe<{ query: string; content: string }>(
186+
* "Grep",
187+
* ({ query, content }) => { ... }
188+
* );
189+
* const grepTool2 = patternTool(myRecipe, { content });
190+
*
191+
* // Both result in type: OpaqueRef<{ query: string }>
192+
* ```
193+
*/
194+
export function patternTool<T, E extends Partial<T>>(
195+
fnOrRecipe: ((input: OpaqueRef<Required<T>>) => any) | RecipeFactory<T, any>,
196+
extraParams?: Opaque<E>,
197+
): OpaqueRef<Omit<T, keyof E>> {
198+
const pattern = isRecipe(fnOrRecipe)
199+
? fnOrRecipe
200+
: recipe<T>("tool", fnOrRecipe);
201+
202+
return {
203+
pattern,
204+
extraParams: extraParams ?? {},
205+
} as any as OpaqueRef<Omit<T, keyof E>>;
206+
}

packages/runner/src/builder/factory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
llm,
3030
llmDialog,
3131
navigateTo,
32+
patternTool,
3233
str,
3334
streamData,
3435
wish,
@@ -100,6 +101,7 @@ export const createBuilder = (
100101
commontools: {
101102
// Recipe creation
102103
recipe,
104+
patternTool,
103105

104106
// Module creation
105107
lift,

packages/runner/src/builder/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
Opaque,
2626
OpaqueRef,
2727
OpaqueRefMethods,
28+
PatternToolFunction,
2829
Recipe,
2930
RecipeFunction,
3031
RenderFunction,
@@ -275,6 +276,7 @@ export type Frame = {
275276
export interface BuilderFunctionsAndConstants {
276277
// Recipe creation
277278
recipe: RecipeFunction;
279+
patternTool: PatternToolFunction;
278280

279281
// Module creation
280282
lift: LiftFunction;

packages/runner/src/builtins/llm-dialog.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const LLMToolSchema = {
209209
required: ["argumentSchema", "resultSchema", "nodes"],
210210
asCell: true,
211211
},
212+
extraParams: { type: "object" },
212213
charm: {
213214
// Accept whole charm - its own schema defines its handlers
214215
asCell: true,
@@ -425,11 +426,21 @@ async function invokeToolCall(
425426
space: MemorySpace,
426427
toolDef: Cell<Schema<typeof LLMToolSchema>> | undefined,
427428
toolCall: LLMToolCall,
428-
charmMeta?: { handler?: any; cell?: Cell<any>; charm: Cell<any> },
429+
charmMeta?: {
430+
handler?: any;
431+
cell?: Cell<any>;
432+
charm: Cell<any>;
433+
extraParams?: Record<string, unknown>;
434+
pattern?: Readonly<Recipe>;
435+
},
429436
) {
430-
const pattern = toolDef?.key("pattern").getRaw() as unknown as
431-
| Readonly<Recipe>
432-
| undefined;
437+
const pattern = charmMeta?.pattern ??
438+
toolDef?.key("pattern").getRaw() as unknown as
439+
| Readonly<Recipe>
440+
| undefined;
441+
const extraParams = charmMeta?.extraParams ??
442+
toolDef?.key("extraParams").get() ??
443+
{};
433444
const handler = charmMeta?.handler ?? toolDef?.key("handler");
434445
// FIXME(bf): in practice, toolCall has toolCall.toolCallId not .id
435446
const result = runtime.getCell<any>(space, toolCall.id);
@@ -455,7 +466,7 @@ async function invokeToolCall(
455466

456467
runtime.editWithRetry((tx) => {
457468
if (pattern) {
458-
runtime.run(tx, pattern, toolCall.input, result);
469+
runtime.run(tx, pattern, { ...toolCall.input, ...extraParams }, result);
459470
} else if (handler) {
460471
handler.withTx(tx).send({
461472
...toolCall.input,
@@ -893,15 +904,30 @@ async function startRequest(
893904
],
894905
};
895906
const ref: Cell<any> = runtime.getCellFromLink(link);
896-
if (!isStream(ref)) {
907+
if (isStream(ref)) {
908+
charmMeta = {
909+
handler: ref as any,
910+
charm: agg.charm,
911+
} as any;
912+
} else {
913+
const pattern = (ref as Cell<any>).key("pattern")
914+
.getRaw() as unknown as
915+
| Readonly<Recipe>
916+
| undefined;
917+
if (pattern) {
918+
charmMeta = {
919+
pattern: pattern,
920+
extraParams:
921+
(ref as Cell<any>).key("extraParams").get() ?? {},
922+
charm: agg.charm,
923+
} as any;
924+
}
925+
}
926+
if (!charmMeta) {
897927
throw new Error(
898928
"path does not resolve to a handler stream",
899929
);
900930
}
901-
charmMeta = {
902-
handler: ref as any,
903-
charm: agg.charm,
904-
} as any;
905931
// For run tools, pass only handler args. Accept both shapes:
906932
// either nested under `args`, or top-level fields alongside path.
907933
toolDef = undefined;

packages/static/assets/types/commontools.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export type RecipeFunction = {
244244
<T, R>(argumentSchema: string | JSONSchema, fn: (input: OpaqueRef<Required<T>>) => Opaque<R>): RecipeFactory<T, R>;
245245
<T, R>(argumentSchema: string | JSONSchema, resultSchema: JSONSchema, fn: (input: OpaqueRef<Required<T>>) => Opaque<R>): RecipeFactory<T, R>;
246246
};
247+
export type PatternToolFunction = <T, E extends Partial<T> = Record<PropertyKey, never>>(fnOrRecipe: ((input: OpaqueRef<Required<T>>) => any) | RecipeFactory<T, any>, extraParams?: Opaque<E>) => OpaqueRef<Omit<T, keyof E>>;
247248
export type LiftFunction = {
248249
<T extends JSONSchema = JSONSchema, R extends JSONSchema = JSONSchema>(argumentSchema: T, resultSchema: R, implementation: (input: Schema<T>) => Schema<R>): ModuleFactory<SchemaWithoutCell<T>, SchemaWithoutCell<R>>;
249250
<T, R>(implementation: (input: T) => R): ModuleFactory<T, R>;
@@ -319,6 +320,7 @@ export interface RecipeEnvironment {
319320
}
320321
export type GetRecipeEnvironmentFunction = () => RecipeEnvironment;
321322
export declare const recipe: RecipeFunction;
323+
export declare const patternTool: PatternToolFunction;
322324
export declare const lift: LiftFunction;
323325
export declare const handler: HandlerFunction;
324326
export declare const derive: DeriveFunction;

0 commit comments

Comments
 (0)