ai-functions
Version:
Core AI primitives for building intelligent applications
685 lines (672 loc) • 27.7 kB
JavaScript
/**
* Function Registry - Storage and management of defined AI functions
*
* This module provides the registry for storing and retrieving defined functions,
* including the global registry and factory for creating isolated registries.
*/
import { generateObject } from './generate.js';
import { PENDING_HUMAN_RESULT_SYMBOL } from './types.js';
import { schema as convertSchema } from './schema.js';
import { getLogger } from './logger.js';
import { runInSandbox } from './sandbox.js';
// ============================================================================
// JSON Schema Conversion
// ============================================================================
/**
* Convert args schema to JSON Schema
*/
export function convertArgsToJSONSchema(args) {
// If it's already a JSON schema-like object
if (typeof args === 'object' && args !== null && 'type' in args) {
return args;
}
// Convert SimpleSchema to JSON Schema
const properties = {};
const required = [];
if (typeof args === 'object' && args !== null) {
for (const [key, value] of Object.entries(args)) {
required.push(key); // All properties required for cross-provider compatibility
properties[key] = convertValueToJSONSchema(value);
}
}
return {
type: 'object',
properties,
required,
additionalProperties: false, // Required for OpenAI compatibility
};
}
/**
* Convert a single value to JSON Schema
*/
function convertValueToJSONSchema(value) {
if (typeof value === 'string') {
// Check for type hints: 'description (number)', 'description (boolean)', etc.
const typeMatch = value.match(/^(.+?)\s*\((number|boolean|integer|date)\)$/i);
if (typeMatch) {
const description = typeMatch[1];
const type = typeMatch[2];
switch (type.toLowerCase()) {
case 'number':
return { type: 'number', description: description.trim() };
case 'integer':
return { type: 'integer', description: description.trim() };
case 'boolean':
return { type: 'boolean', description: description.trim() };
case 'date':
return { type: 'string', format: 'date-time', description: description.trim() };
}
}
// Check for enum: 'option1 | option2 | option3'
if (value.includes(' | ')) {
const options = value.split(' | ').map((s) => s.trim());
return { type: 'string', enum: options };
}
return { type: 'string', description: value };
}
if (Array.isArray(value) && value.length === 1) {
const [desc] = value;
if (typeof desc === 'string') {
return { type: 'array', items: { type: 'string' }, description: desc };
}
if (typeof desc === 'number') {
return { type: 'array', items: { type: 'number' } };
}
return { type: 'array', items: convertValueToJSONSchema(desc) };
}
if (typeof value === 'object' && value !== null) {
return convertArgsToJSONSchema(value);
}
return { type: 'string' };
}
// ============================================================================
// Template Utilities
// ============================================================================
/**
* Fill template with values
*/
export function fillTemplate(template, args) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const v = args[key] ?? '';
if (typeof v === 'object' && v !== null) {
return JSON.stringify(v);
}
return String(v);
});
}
// ============================================================================
// Function Executors
// ============================================================================
/**
* Execute a **deterministic** code function.
*
* `Code` is the deterministic kind: no model is consulted at call time. This
* invokes the definition's `handler` (canonical) or, if only an inline `code`
* body was supplied, deterministically compiles and runs it. The result is
* returned directly.
*
* This is a deliberate change from the previous behavior, where `type: 'code'`
* LLM-generated source at call time. That code-*authoring* behavior now lives
* in {@link generateAndRunCode} (and the `generate('code', …)` primitive), so
* that `Code` can carry the "deterministic handler" contract consumers depend
* on (ADR-0033). See the package README migration note.
*
* Inline `code` bodies are executed in ai-evaluate's V8-isolate sandbox — never
* via `new Function`/`eval`. Execution stays deterministic: no model is ever
* consulted on this path.
*
* @param env - Optional host Workers env (carrying `LOADER`) for the sandbox;
* when omitted the inline-code path falls back to the Miniflare-backed Node
* runtime. Ignored when a `handler` is supplied (direct call, no sandbox).
*
* @throws if neither `handler` nor `code` is provided, or if an inline `code`
* body is in a non-evaluable language.
*/
async function executeCodeFunction(definition, args, env) {
const { handler, code, language = 'typescript', name } = definition;
if (typeof handler === 'function') {
return await handler(args);
}
if (typeof code === 'string' && code.length > 0) {
return await runInlineCode(code, args, language, name, env);
}
throw new Error(`Code function '${name}' has no handler or inline code. ` +
`'code' functions are deterministic and require a handler: (input) => output ` +
`(or an inline 'code' body). To have a model *author* code instead, use ` +
`generateAndRunCode() / generateCode() or define a 'generative' function.`);
}
/**
* Deterministically run an inline `code` body for a Code function in the
* ai-evaluate V8-isolate sandbox.
*
* The body is treated as a function whose `return` value is the result; the
* parsed `args` are exposed as a top-level `args` binding inside the sandbox.
* Only the JS/TS-compatible languages can be evaluated; other languages are
* carried as metadata for an external runtime and are rejected here.
*
* No model is involved — the same body always produces the same behavior. This
* replaces the former `new Function(...)` path: `new Function`/`eval` are
* banned in this package (broken under Workers, unsandboxed under Node).
*
* Limitation: `args` are injected by JSON-serializing them into the sandbox
* script (`JSON.parse(<json>)`), so only JSON-serializable inputs are
* supported on the inline-`code` path. Pass a `handler` for non-serializable
* inputs (functions, class instances, etc.).
*
* @param env - Optional host Workers env (carrying `LOADER`) for the sandbox;
* when omitted, runs against the Miniflare-backed Node runtime.
*/
async function runInlineCode(code, args, language, name, env) {
if (language !== 'typescript' && language !== 'javascript') {
throw new Error(`Code function '${name}' has an inline 'code' body in language '${language}', ` +
`which cannot be evaluated in the sandbox. Pass a 'handler' instead, or run it ` +
`in an external deterministic runtime.`);
}
const body = /\breturn\b/.test(code) ? code : `return (${code})`;
// Inject args deterministically by serializing them into the sandbox script.
// (JSON-serializable inputs only — see the doc comment.)
let argsJson;
try {
argsJson = JSON.stringify(args ?? null);
}
catch (e) {
throw new Error(`Code function '${name}' received non-JSON-serializable args for its inline 'code' ` +
`body: ${e.message}. Pass a 'handler' for non-serializable inputs.`);
}
const script = `const args = JSON.parse(${JSON.stringify(argsJson)});\n${body}`;
const result = await runInSandbox({ script }, env);
if (result.success === false) {
throw new Error(`Code function '${name}' failed in the sandbox: ${result.error ?? 'unknown error'}`);
}
return result.value;
}
/**
* Author code with a model — the explicit, opt-in code-*generation* path.
*
* This is the behavior `type: 'code'` used to have implicitly at call time.
* It has been split out so that `Code` functions can be deterministic. Calling
* this **does** consult a model and returns the generated source as a string;
* it does not produce a deterministic, repeatable handler.
*
* @param definition - The code-authoring spec ({@link CodeGenerationDefinition})
* @param args - Concrete inputs / refinements for the requested code
* @returns The generated source code as a string
*
* @example
* ```ts
* import { generateCode } from 'ai-functions'
*
* const src = await generateCode(
* { name: 'calculateTax', args: { amount: '(number)', rate: '(number)' }, language: 'typescript' },
* { amount: 100, rate: 0.2 }
* )
* ```
*/
export async function generateCode(definition, args) {
const { name, description, language = 'typescript', instructions, returnType, model = 'sonnet', } = definition;
const argsDescription = JSON.stringify(args ?? definition.args, null, 2);
const result = await generateObject({
model,
schema: {
code: `The complete ${language} implementation code. Output ONLY the raw code without markdown formatting or code blocks.`,
},
system: `You are an expert ${language} developer. Generate clean, well-documented, production-ready code. Output ONLY the code itself, without any markdown code fences or language tags.`,
prompt: `Generate a ${language} function/query with the following specification:
Name: ${name}
Description: ${description || 'No description provided'}
Arguments: ${argsDescription}
Return Type: ${JSON.stringify(returnType)}
${instructions ? `Additional Instructions: ${instructions}` : ''}
Requirements:
- Include appropriate comments/documentation
- Follow best practices for ${language}
- Handle edge cases appropriately
- Return ONLY the code without markdown formatting`,
});
return result.object.code;
}
/**
* The **non-deterministic** generate → run → test → return capability.
*
* This is the headline of the `generate('code', …)` primitive: a model
* **authors** code, that code is **run** in ai-evaluate's V8-isolate sandbox,
* optionally **tested** there, and the executed **result** is returned (not
* just the source). This is deliberately separate from `type: 'code'`, which is
* deterministic and never consults a model — so determinism is never blurred.
*
* Unlike {@link generateCode} (which only returns source text), this runs the
* authored code. The authored module is expected to `export function ${name}`
* (a NAMED export — the sandbox's module loader does not support `export
* default`); the sandbox script invokes `${name}(args)` and returns its result.
*
* @param definition - The code-authoring spec ({@link CodeGenerationDefinition}).
* Set `includeTests: false` to skip test authoring (default: tests included).
* @param args - Concrete inputs the authored code is invoked with.
* @param env - Optional host Workers env. When it carries `LOADER` **and**
* `TEST`, tests run on the real Dynamic Workers loader; otherwise execution
* falls back to the Miniflare-backed Node runtime (whose dev worker has its
* own embedded test runner and needs no live `TEST` binding).
* @returns The executed result plus authored artifacts.
*
* @example
* ```ts
* import { generateAndRunCode } from 'ai-functions'
*
* const { value } = await generateAndRunCode(
* { name: 'calculateTax', args: { amount: '(number)', rate: '(number)' } },
* { amount: 100, rate: 0.2 }
* )
* // value === 20 (the model authored the code, the sandbox ran it)
* ```
*/
export async function generateAndRunCode(definition, args, env) {
const { name, description, language = 'typescript', instructions, returnType, includeTests = true, model = 'sonnet', } = definition;
const argsDescription = JSON.stringify(args ?? definition.args, null, 2);
// Step 1 — model AUTHORS the module (+ optional tests). Non-deterministic.
const codeSpec = `The complete ${language} module. It MUST contain a NAMED export 'export function ${name}(args) { ... }' (NOT a default export) that takes a single arguments object and returns the result. Output ONLY raw code, no markdown fences.`;
const schema = includeTests
? {
code: codeSpec,
tests: `vitest-style tests using global describe/it/expect. The function '${name}' is already in scope (do not import it). Output ONLY raw code, no markdown fences.`,
}
: {
code: codeSpec,
};
const authored = await generateObject({
model,
schema,
system: `You are an expert ${language} developer. Generate clean, production-ready code. The module MUST expose a NAMED export 'export function ${name}(args)' taking one arguments object — do NOT use 'export default'. Output ONLY raw code, no markdown code fences or language tags.`,
prompt: `Author a ${language} module with the following specification:
Name: ${name}
Description: ${description || 'No description provided'}
Arguments: ${argsDescription}
Return Type: ${JSON.stringify(returnType)}
${instructions ? `Additional Instructions: ${instructions}` : ''}
Requirements:
- Expose 'export function ${name}(args) { ... }' (a NAMED export, not default), taking one arguments object.
- Handle edge cases appropriately.
- Return ONLY raw code without markdown formatting.`,
});
const authoredObj = authored.object;
const code = authoredObj.code;
const tests = includeTests ? authoredObj.tests : undefined;
// Step 2 — RUN the authored code in the sandbox and capture its return value.
// The module's default export is invoked with the JSON-injected args; the
// result is returned by the sandbox script. Tests (if any) run in the same
// sandbox via the worker template's test runner.
let argsJson;
try {
argsJson = JSON.stringify(args ?? null);
}
catch (e) {
throw new Error(`generateAndRunCode('${name}'): args are not JSON-serializable: ${e.message}`);
}
// The named export `${name}` is exposed as a top-level binding by the worker
// template (`const { ${name} } = exports`). The script calls it with the
// JSON-injected args and returns the result.
const result = await runInSandbox({
module: code,
script: `const __args__ = JSON.parse(${JSON.stringify(argsJson)}); if (typeof ${name} !== 'function') { throw new Error("authored module did not export a callable '${name}'"); } return await ${name}(__args__);`,
...(tests !== undefined && { tests }),
}, env);
if (result.success === false) {
throw new Error(`generateAndRunCode('${name}') failed in the sandbox: ${result.error ?? 'unknown error'}`);
}
return {
value: result.value,
code,
...(tests !== undefined && { tests }),
...(result.testResults && {
testResults: {
total: result.testResults.total,
passed: result.testResults.passed,
failed: result.testResults.failed,
skipped: result.testResults.skipped,
},
}),
logs: result.logs.map((l) => ({ level: l.level, message: l.message })),
};
}
/**
* Execute a generative function - uses AI to generate content
*/
async function executeGenerativeFunction(definition, args) {
const { output, system, promptTemplate, model = 'sonnet', temperature, returnType } = definition;
const prompt = promptTemplate
? fillTemplate(promptTemplate, args)
: JSON.stringify(args);
switch (output) {
case 'string': {
const result = await generateObject({
model,
schema: { text: 'The generated text response' },
prompt,
...(system !== undefined && { system }),
...(temperature !== undefined && { temperature }),
});
return result.object.text;
}
case 'object': {
const objectSchema = returnType || { result: 'The generated result' };
const result = await generateObject({
model,
schema: objectSchema,
prompt,
...(system !== undefined && { system }),
...(temperature !== undefined && { temperature }),
});
return result.object;
}
case 'image':
throw new Error('Image generation via generative functions is not yet implemented. ' +
'Use the image() primitive directly instead.');
case 'video':
throw new Error('Video generation via generative functions is not yet implemented. ' +
'Use the video() primitive directly instead.');
default:
throw new Error(`Unknown output type: ${output}`);
}
}
/**
* Execute an agentic function - runs in a loop with tools
*/
async function executeAgenticFunction(definition, args) {
const { instructions, promptTemplate, tools = [], maxIterations = 10, model = 'sonnet', returnType, } = definition;
const prompt = promptTemplate
? fillTemplate(promptTemplate, args)
: JSON.stringify(args);
// Build system prompt with tool descriptions
const toolDescriptions = tools.map((t) => `- ${t.name}: ${t.description}`).join('\n');
const systemPrompt = `${instructions}
Available tools:
${toolDescriptions || 'No tools available'}
Work step by step to accomplish the task. When you have completed the task, provide your final result.`;
let iteration = 0;
const toolResults = [];
// Simple agent loop
while (iteration < maxIterations) {
iteration++;
const result = await generateObject({
model,
schema: {
thinking: 'Your step-by-step reasoning',
toolCall: {
name: 'Tool to call (or "done" if finished)',
arguments: 'Arguments for the tool as JSON string',
},
finalResult: returnType || 'The final result if done',
},
system: systemPrompt,
prompt: `Task: ${prompt}
Previous tool results:
${toolResults.map((r, i) => `Step ${i + 1}: ${JSON.stringify(r)}`).join('\n') || 'None yet'}
What is your next step?`,
});
const response = result.object;
if (response.toolCall.name === 'done' || response.finalResult) {
return response.finalResult;
}
// Execute tool call
const tool = tools.find((t) => t.name === response.toolCall.name);
if (tool) {
let toolArgs;
try {
toolArgs = JSON.parse(response.toolCall.arguments || '{}');
}
catch (e) {
toolResults.push({
error: `Invalid tool arguments: ${e.message}`,
});
continue;
}
const toolResult = await tool.handler(toolArgs);
toolResults.push({ tool: response.toolCall.name, result: toolResult });
}
else {
toolResults.push({ error: `Tool not found: ${response.toolCall.name}` });
}
}
throw new Error(`Agent exceeded maximum iterations (${maxIterations})`);
}
/**
* Execute a human function - generates UI and waits for human input
*
* **Note: This function currently returns a pending placeholder.**
*
* In a complete implementation, this function would:
* 1. Generate channel-specific UI (Slack blocks, email templates, web forms, etc.)
* 2. Send the generated UI to the appropriate channel
* 3. Wait for human response with optional timeout
* 4. Validate and return the human's response
*
* The current implementation generates the UI artifacts but returns a pending
* placeholder instead of actually sending to the channel and waiting for response.
* This allows testing the UI generation without requiring actual channel integrations.
*
* **Important:** Use `isPendingHumanResult()` to check if the result is pending
* before attempting to use it as the expected output type.
*
* @param definition - The human function definition with channel and instructions
* @param args - Arguments to pass to the function
* @returns Either the actual TOutput from human input, or a HumanFunctionPending placeholder
*
* @example
* ```ts
* import { isPendingHumanResult } from 'ai-functions'
*
* const result = await approveRefund({ amount: 500 })
*
* if (isPendingHumanResult(result)) {
* // Handle pending state
* console.log('Awaiting human approval via:', result.channel)
* return { status: 'pending' }
* }
*
* // result is the actual approval response
* console.log('Approved:', result.approved)
* ```
*/
async function executeHumanFunction(definition, args) {
const { channel, instructions, promptTemplate, returnType } = definition;
const prompt = promptTemplate
? fillTemplate(promptTemplate, args)
: JSON.stringify(args);
// Generate channel-specific UI
const uiSchema = {
// New HumanChannel types
chat: {
message: 'Chat message to send',
options: ['Response options if applicable'],
},
email: {
subject: 'Email subject',
html: 'Email HTML body',
text: 'Plain text fallback',
},
phone: {
script: 'Phone call script',
keyPoints: ['Key points to cover'],
},
sms: {
text: 'SMS message text (max 160 chars)',
},
workspace: {
blocks: ['Workspace/Slack BlockKit blocks as JSON array'],
text: 'Plain text fallback',
},
web: {
component: 'React component code for the form',
schema: 'JSON schema for the form fields',
},
// Legacy fallback
custom: {
data: 'Structured data for custom implementation',
instructions: 'Instructions for the human',
},
};
const result = await generateObject({
model: 'sonnet',
schema: uiSchema[channel] ?? uiSchema['custom'],
system: `Generate ${channel} UI/content for a human-in-the-loop task.`,
prompt: `Task: ${instructions}
Input data:
${prompt}
Expected response format:
${JSON.stringify(returnType)}
Generate the appropriate ${channel} UI/content to collect this response from a human.`,
});
// Runtime warning for developers
getLogger().warn(`[HumanFunction] Returning pending placeholder for channel '${channel}'. ` +
`Use isPendingHumanResult() to check before using the result. ` +
`Full channel integration is not yet implemented.`);
// Return a properly typed pending result
// The symbol marker allows isPendingHumanResult() to reliably identify this
const pendingResult = {
[PENDING_HUMAN_RESULT_SYMBOL]: true,
_pending: true,
channel,
artifacts: result.object,
expectedResponseType: returnType,
};
return pendingResult;
}
// ============================================================================
// Defined Function Creation
// ============================================================================
/**
* Create a defined function from a function definition
*/
export function createDefinedFunction(definition) {
const call = async (args, env) => {
switch (definition.type) {
case 'code':
// Optional host Workers env threads through to the sandbox for inline
// `code` bodies; ignored for `handler` (direct call) and other types.
return executeCodeFunction(definition, args, env);
case 'generative':
return executeGenerativeFunction(definition, args);
case 'agentic':
return executeAgenticFunction(definition, args);
case 'human':
return executeHumanFunction(definition, args);
default:
throw new Error(`Unknown function type: ${definition.type}`);
}
};
const asTool = () => {
return {
name: definition.name,
description: definition.description || `Execute ${definition.name}`,
parameters: convertArgsToJSONSchema(definition.args),
handler: call,
};
};
return { definition, call, asTool };
}
/**
* Standalone function for defining AI functions
*
* @example
* ```ts
* import { defineFunction } from 'ai-functions'
*
* const summarize = defineFunction({
* type: 'generative',
* name: 'summarize',
* args: { text: 'Text to summarize' },
* output: 'string',
* promptTemplate: 'Summarize: {{text}}',
* })
*
* const result = await summarize.call({ text: 'Long article...' })
* ```
*/
export function defineFunction(definition) {
return createDefinedFunction(definition);
}
// ============================================================================
// Function Registry Implementation
// ============================================================================
/**
* In-memory function registry
*/
class InMemoryFunctionRegistry {
functions = new Map();
get(name) {
return this.functions.get(name);
}
set(name, fn) {
this.functions.set(name, fn);
}
has(name) {
return this.functions.has(name);
}
list() {
return Array.from(this.functions.keys());
}
delete(name) {
return this.functions.delete(name);
}
clear() {
this.functions.clear();
}
}
/**
* Factory function to create a new isolated function registry instance.
*
* Use this when you need:
* - Test isolation: Each test can have its own registry
* - Scoped registries: Different parts of an app can have separate registries
* - Custom lifecycle management: Control when registries are created/destroyed
*
* @example
* ```ts
* // Create isolated registry for tests
* const registry = createFunctionRegistry()
* const fn = defineFunction({ ... })
* registry.set('myFunc', fn)
*
* // Later, registry can be discarded without affecting global state
* ```
*
* @returns A new FunctionRegistry instance
*/
export function createFunctionRegistry() {
return new InMemoryFunctionRegistry();
}
/**
* Global function registry
*
* Note: This is in-memory only. For persistence, use mdxai or mdxdb packages.
*
* **Lifecycle:**
* - Created once at module load time
* - Shared across the entire application
* - Use `resetGlobalRegistry()` in tests to clear state between test runs
* - For isolated registries, use `createFunctionRegistry()` instead
*/
export const functions = new InMemoryFunctionRegistry();
/**
* Reset the global function registry to a clean state.
*
* **Important:** This is primarily intended for test cleanup to ensure
* test isolation. In production code, prefer using `createFunctionRegistry()`
* for isolated registries.
*
* @example
* ```ts
* // In test setup/teardown
* beforeEach(() => {
* resetGlobalRegistry()
* })
*
* // Or after each test
* afterEach(() => {
* resetGlobalRegistry()
* })
* ```
*/
export function resetGlobalRegistry() {
functions.clear();
}
//# sourceMappingURL=function-registry.js.map