diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 02181ff0c..f07117678 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -17,14 +17,18 @@ model FlowDuplicateRequest { op FlowDuplicate(...FlowDuplicateRequest): {}; +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Run Flow" }) +@doc("Execute a workflow from the start node.") model FlowRunRequest { - flowId: Id; + @doc("The ULID of the workflow to run") flowId: Id; } op FlowRun(...FlowRunRequest): {}; +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Stop Flow" }) +@doc("Stop a running workflow execution.") model FlowStopRequest { - flowId: Id; + @doc("The ULID of the workflow to stop") flowId: Id; } op FlowStop(...FlowStopRequest): {}; @@ -35,6 +39,11 @@ model FlowVersion { @foreignKey flowId: Id; } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create Variable", name: "CreateVariable", description: "Create a new workflow variable that can be referenced in node expressions." }, + #{ operation: AITools.CrudOperation.Update, title: "Update Variable", name: "UpdateVariable", description: "Update an existing workflow variable." } +) @TanStackDB.collection model FlowVariable { @primaryKey flowVariableId: Id; @@ -47,6 +56,12 @@ enum HandleKind { Loop, } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Connect Sequential Nodes", name: "ConnectSequentialNodes", exclude: #["sourceHandle"], description: "Connect two nodes in a sequential flow. Use this for ManualStart, JavaScript, and HTTP nodes which have a single output." }, + #{ operation: AITools.CrudOperation.Insert, title: "Connect Branching Nodes", name: "ConnectBranchingNodes", description: "Connect a branching node (Condition, For, ForEach) to another node. Requires sourceHandle: 'then' or 'else' for Condition nodes, 'then' or 'loop' for For/ForEach nodes." }, + #{ operation: AITools.CrudOperation.Delete, title: "Disconnect Nodes", name: "DisconnectNodes", description: "Remove an edge connection between nodes." } +) @TanStackDB.collection model Edge { @primaryKey edgeId: Id; @@ -78,6 +93,11 @@ model Position { y: float32; } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Update, title: "Update Node Config", name: "UpdateNodeConfig", exclude: #["kind"], description: "Update general node properties like name or position." }, + #{ operation: AITools.CrudOperation.Delete, description: "Delete a node from the workflow. Also removes all connected edges." } +) @TanStackDB.collection model Node { @primaryKey nodeId: Id; @@ -89,10 +109,14 @@ model Node { @visibility(Lifecycle.Read) info?: string; } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create HTTP Node", name: "CreateHttpNode", parent: "Node", exclude: #["kind"], description: "Create a new HTTP request node that makes an API call." } +) @TanStackDB.collection model NodeHttp { @primaryKey nodeId: Id; - @foreignKey httpId: Id; + @doc("The ULID of the HTTP request definition to use") @foreignKey httpId: Id; @foreignKey deltaHttpId?: Id; } @@ -101,28 +125,45 @@ enum ErrorHandling { Break, } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create For Loop Node", name: "CreateForNode", parent: "Node", exclude: #["kind"], description: "Create a for-loop node that iterates a fixed number of times." } +) @TanStackDB.collection model NodeFor { @primaryKey nodeId: Id; - iterations: int32; + @doc("Number of iterations to perform") iterations: int32; condition: string; errorHandling: ErrorHandling; } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create ForEach Loop Node", name: "CreateForEachNode", parent: "Node", exclude: #["kind"], description: "Create a forEach node that iterates over an array or object." } +) @TanStackDB.collection model NodeForEach { @primaryKey nodeId: Id; - path: string; + @doc("Path to the array/object to iterate (e.g., \"input.items\")") path: string; condition: string; errorHandling: ErrorHandling; } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create Condition Node", name: "CreateConditionNode", parent: "Node", exclude: #["kind"], description: "Create a condition node that routes flow based on a boolean expression. Has THEN and ELSE output handles." } +) @TanStackDB.collection model NodeCondition { @primaryKey nodeId: Id; condition: string; } +@AITools.explorationTool +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Create JavaScript Node", name: "CreateJsNode", parent: "Node", exclude: #["kind"], description: "Create a new JavaScript node in the workflow. JS nodes can transform data, make calculations, or perform custom logic." }, + #{ operation: AITools.CrudOperation.Update, title: "Update Node Code", name: "UpdateNodeCode", description: "Update the JavaScript code of a JS node." } +) @TanStackDB.collection model NodeJs { @primaryKey nodeId: Id; diff --git a/packages/spec/api/main.tsp b/packages/spec/api/main.tsp index 6062bcea7..abe3d50da 100644 --- a/packages/spec/api/main.tsp +++ b/packages/spec/api/main.tsp @@ -1,6 +1,7 @@ import "@the-dev-tools/spec-lib/core"; import "@the-dev-tools/spec-lib/protobuf"; import "@the-dev-tools/spec-lib/tanstack-db"; +import "@the-dev-tools/spec-lib/ai-tools"; import "./environment.tsp"; import "./export.tsp"; @@ -20,11 +21,11 @@ alias Id = bytes; model CommonTableFields { ...Keys; - key: string; - enabled: boolean; - value: string; - description: string; - order: float32; + @doc("Variable name (used to reference it in expressions)") key: string; + @doc("Whether the variable is active") enabled: boolean; + @doc("Variable value") value: string; + @doc("Description of what the variable is for") description: string; + @doc("Display order") order: float32; } @DevTools.project diff --git a/packages/spec/package.json b/packages/spec/package.json index c5edb8bd0..eb975af5b 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -4,12 +4,21 @@ "type": "module", "files": [ "dist", + "src", "go.mod", "go.sum" ], "exports": { "./buf/*": "./dist/buf/typescript/*.ts", - "./tanstack-db/*": "./dist/tanstack-db/typescript/*.ts" + "./tanstack-db/*": "./dist/tanstack-db/typescript/*.ts", + "./tools": "./dist/ai-tools/v1/index.ts", + "./tools/common": "./dist/ai-tools/v1/common.ts", + "./tools/execution": "./dist/ai-tools/v1/execution.ts", + "./tools/exploration": "./dist/ai-tools/v1/exploration.ts", + "./tools/mutation": "./dist/ai-tools/v1/mutation.ts" + }, + "dependencies": { + "effect": "catalog:" }, "devDependencies": { "@bufbuild/buf": "catalog:", @@ -18,8 +27,8 @@ "@the-dev-tools/eslint-config": "workspace:^", "@the-dev-tools/spec-lib": "workspace:^", "@types/node": "catalog:", - "effect": "catalog:", "prettier": "catalog:", + "tsx": "^4.19.0", "typescript": "catalog:" } } diff --git a/packages/spec/tsconfig.lib.json b/packages/spec/tsconfig.lib.json index c8a7c6987..0258c20c9 100644 --- a/packages/spec/tsconfig.lib.json +++ b/packages/spec/tsconfig.lib.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.json", - "include": ["dist", "lib"], + "include": ["dist", "lib", "src/tools"], "exclude": ["node_modules", "*.ts"], "references": [ { diff --git a/packages/spec/tspconfig.yaml b/packages/spec/tspconfig.yaml index 8c30b4272..d35646eca 100644 --- a/packages/spec/tspconfig.yaml +++ b/packages/spec/tspconfig.yaml @@ -3,6 +3,7 @@ output-dir: '{project-root}/dist' emit: - '@the-dev-tools/spec-lib/protobuf' - '@the-dev-tools/spec-lib/tanstack-db' + - '@the-dev-tools/spec-lib/ai-tools' options: '@the-dev-tools/spec-lib': diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adac19ad8..97ce59158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -813,6 +813,10 @@ importers: version: link:../spec packages/spec: + dependencies: + effect: + specifier: 'catalog:' + version: 3.19.14 devDependencies: '@bufbuild/buf': specifier: 'catalog:' @@ -832,12 +836,12 @@ importers: '@types/node': specifier: 'catalog:' version: 25.0.3 - effect: - specifier: 'catalog:' - version: 3.19.14 prettier: specifier: 'catalog:' version: 3.7.4 + tsx: + specifier: ^4.19.0 + version: 4.21.0 typescript: specifier: 'catalog:' version: 5.9.3 diff --git a/tools/spec-lib/package.json b/tools/spec-lib/package.json index c81a0de37..e2bfe17e7 100755 --- a/tools/spec-lib/package.json +++ b/tools/spec-lib/package.json @@ -17,6 +17,11 @@ "types": "./dist/src/tanstack-db/index.d.ts", "default": "./dist/src/tanstack-db/index.js", "typespec": "./src/tanstack-db/main.tsp" + }, + "./ai-tools": { + "types": "./dist/src/ai-tools/index.d.ts", + "default": "./dist/src/ai-tools/index.js", + "typespec": "./src/ai-tools/main.tsp" } }, "devDependencies": { diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx new file mode 100644 index 000000000..6a8ff96f8 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -0,0 +1,719 @@ +import { code, For, Indent, refkey, Show, SourceDirectory } from '@alloy-js/core'; +import { SourceFile, VarDeclaration } from '@alloy-js/typescript'; +import { EmitContext, getDoc, Model, ModelProperty, Program } from '@typespec/compiler'; +import { Output, useTsp, writeOutput } from '@typespec/emitter-framework'; +import { Array, String } from 'effect'; +import { join } from 'node:path/posix'; +import { primaryKeys } from '../core/index.jsx'; +import { formatStringLiteral, getFieldSchema } from './field-schema.js'; +import { aiTools, explorationTools, MutationToolOptions, mutationTools, ToolCategory } from './lib.js'; + +export const $onEmit = async (context: EmitContext) => { + const { emitterOutputDir, program } = context; + + if (program.compilerOptions.noEmit) return; + + const tools = aiTools(program); + const mutations = mutationTools(program); + const explorations = explorationTools(program); + if (tools.size === 0 && mutations.size === 0 && explorations.size === 0) { + return; + } + + await writeOutput( + program, + + + + + + + , + join(emitterOutputDir, 'ai-tools'), + ); +}; + +interface ResolvedProperty { + optional: boolean; + property: ModelProperty; +} + +interface ResolvedTool { + description?: string | undefined; + name: string; + properties: ResolvedProperty[]; + title: string; +} + +function isVisibleFor(property: ModelProperty, phase: 'Create' | 'Update'): boolean { + const visibilityDec = property.decorators.find( + (d) => d.decorator.name === '$visibility', + ); + if (!visibilityDec) return true; + + return visibilityDec.args.some((arg) => { + const val = arg.value as { value?: { name?: string } } | undefined; + return val?.value?.name === phase; + }); +} + +function resolveToolProperties(program: Program, collectionModel: Model, toolDef: MutationToolOptions): ResolvedProperty[] { + const { exclude = [], operation, parent: parentName } = toolDef; + const parent = parentName ? collectionModel.namespace?.models.get(parentName) : undefined; + + switch (operation) { + case 'Insert': { + const props: ResolvedProperty[] = []; + if (parent) { + for (const prop of parent.properties.values()) { + if (primaryKeys(program).has(prop)) continue; + if (!isVisibleFor(prop, 'Create')) continue; + if (exclude.includes(prop.name)) continue; + props.push({ optional: prop.optional, property: prop }); + } + } + for (const prop of collectionModel.properties.values()) { + if (primaryKeys(program).has(prop)) continue; + if (!isVisibleFor(prop, 'Create')) continue; + if (exclude.includes(prop.name)) continue; + props.push({ optional: prop.optional, property: prop }); + } + return props; + } + case 'Update': { + const props: ResolvedProperty[] = []; + for (const prop of collectionModel.properties.values()) { + if (!isVisibleFor(prop, 'Update')) continue; + if (primaryKeys(program).has(prop)) { + props.push({ optional: false, property: prop }); + } else { + if (exclude.includes(prop.name)) continue; + props.push({ optional: true, property: prop }); + } + } + return props; + } + case 'Delete': { + const props: ResolvedProperty[] = []; + for (const prop of collectionModel.properties.values()) { + if (primaryKeys(program).has(prop)) { + props.push({ optional: false, property: prop }); + } + } + return props; + } + } +} + +function resolveExplorationTools(program: Program): ResolvedTool[] { + const tools: ResolvedTool[] = []; + + for (const [model, toolDefs] of explorationTools(program).entries()) { + for (const toolDef of toolDefs) { + const properties: ResolvedProperty[] = []; + for (const prop of model.properties.values()) { + if (primaryKeys(program).has(prop)) { + properties.push({ optional: false, property: prop }); + } + } + if (properties.length === 0) continue; + + tools.push({ + description: toolDef.description, + name: toolDef.name!, + properties, + title: toolDef.title!, + }); + } + } + + return tools; +} + +function resolveMutationTools(program: Program): ResolvedTool[] { + const tools: ResolvedTool[] = []; + + for (const [model, toolDefs] of mutationTools(program).entries()) { + for (const toolDef of toolDefs) { + const name = toolDef.name ?? `${toolDef.operation}${model.name}`; + const properties = resolveToolProperties(program, model, toolDef); + tools.push({ + description: toolDef.description, + name, + properties, + title: toolDef.title!, + }); + } + } + + return tools; +} + +function resolveAiTools(program: Program): Partial> { + const result: Partial> = {}; + + for (const [model, options] of aiTools(program).entries()) { + const properties: ResolvedProperty[] = []; + for (const prop of model.properties.values()) { + properties.push({ optional: prop.optional, property: prop }); + } + const category = options.category; + if (!result[category]) result[category] = []; + result[category]!.push({ + description: getDoc(program, model), + name: model.name, + properties, + title: options.title ?? model.name, + }); + } + + return result; +} + +const CategoryFiles = () => { + const { program } = useTsp(); + + const resolvedMutationTools = resolveMutationTools(program); + const resolvedExplorationTools = resolveExplorationTools(program); + const aiToolsByCategory = resolveAiTools(program); + + const categories: { category: ToolCategory; tools: ResolvedTool[] }[] = []; + + if (resolvedMutationTools.length > 0) { + categories.push({ category: 'Mutation', tools: resolvedMutationTools }); + } + + const allExploration = [...resolvedExplorationTools, ...(aiToolsByCategory['Exploration'] ?? [])]; + if (allExploration.length > 0) { + categories.push({ category: 'Exploration', tools: allExploration }); + } + + const executionTools = aiToolsByCategory['Execution'] ?? []; + if (executionTools.length > 0) { + categories.push({ category: 'Execution', tools: executionTools }); + } + + return ( + + {({ category, tools }) => ( + + + + + {(tool) => } + + + + {'{'} + + + + {(tool) => <>{tool.name}} + + , + + + {'}'} as const + + + + {(tool) => ( + <> + export type {tool.name} = typeof {tool.name}.Type; + + )} + + + )} + + ); +}; + +const SchemaImports = ({ tools }: { tools: ResolvedTool[] }) => { + const { program } = useTsp(); + const commonImports = new Set(); + + for (const { properties } of tools) { + for (const { property } of properties) { + const fieldSchema = getFieldSchema(property, program); + if (fieldSchema.importFrom === 'common') { + commonImports.add(fieldSchema.schemaName); + } + } + } + + const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); + + return ( + <> + {code`import { Schema } from 'effect';`} + + 0}> + import {'{'} + + + + {(name) => <>{name}} + + + + {'}'} from './common.ts'; + + + + ); +}; + +const ToolSchema = ({ tool }: { tool: ResolvedTool }) => { + const identifier = String.uncapitalize(tool.name); + + return ( + + Schema.Struct({'{'} + + + + {({ optional, property }) => } + + + + {'}'}).pipe( + + + Schema.annotations({'{'} + + + identifier: '{identifier}', + title: '{tool.title}', + description: {formatStringLiteral(tool.description ?? '')}, + + {'}'}), + + ) + + ); +}; + +interface PropertySchemaProps { + isOptional: boolean; + property: ModelProperty; +} + +const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { + const { program } = useTsp(); + const doc = getDoc(program, property); + const fieldSchema = getFieldSchema(property, program); + + const needsOptionalWrapper = isOptional && !fieldSchema.includesOptional; + + if (doc || fieldSchema.needsDescription) { + const description = doc ?? ''; + // When optional, wrap the annotated inner schema with Schema.optional() + // Schema.optional() returns a PropertySignature that can't be piped + const annotatedInner = ( + <> + {fieldSchema.expression}.pipe( + + + Schema.annotations({'{'} + + description: {formatStringLiteral(description)}, + {'}'}), + + ) + + ); + + if (needsOptionalWrapper) { + return ( + <> + {property.name}: Schema.optional({annotatedInner}) + + ); + } + + return ( + <> + {property.name}: {annotatedInner} + + ); + } + + const schemaExpr = needsOptionalWrapper + ? `Schema.optional(${fieldSchema.expression})` + : fieldSchema.expression; + + return ( + <> + {property.name}: {schemaExpr} + + ); +}; + +// ============================================================================= +// Generated common.ts — inline schema building blocks +// ============================================================================= + +const CommonSchemaFile = () => { + return ( + + {`/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Common schemas and utilities for tool definitions. + * Generated by the TypeSpec emitter. + */ + +import { Schema } from 'effect'; + +import { + ErrorHandling as PbErrorHandling, + HandleKind as PbHandleKind, +} from '../../buf/typescript/api/flow/v1/flow_pb.ts'; + +// ============================================================================= +// Common Field Schemas +// ============================================================================= + +/** + * ULID identifier schema - used for all entity IDs + */ +export const UlidId = Schema.String.pipe( + Schema.pattern(/^[0-9A-HJKMNP-TV-Z]{26}$/), + Schema.annotations({ + title: 'ULID', + description: 'A ULID (Universally Unique Lexicographically Sortable Identifier)', + examples: ['01ARZ3NDEKTSV4RRFFQ69G5FAV'], + }), +); + +/** + * Flow ID - references a workflow + */ +export const FlowId = UlidId.pipe( + Schema.annotations({ + identifier: 'flowId', + description: 'The ULID of the workflow', + }), +); + +/** + * Node ID - references a node within a workflow + */ +export const NodeId = UlidId.pipe( + Schema.annotations({ + identifier: 'nodeId', + description: 'The ULID of the node', + }), +); + +/** + * Edge ID - references an edge connection + */ +export const EdgeId = UlidId.pipe( + Schema.annotations({ + identifier: 'edgeId', + description: 'The ULID of the edge', + }), +); + +// ============================================================================= +// Position Schema +// ============================================================================= + +export const Position = Schema.Struct({ + x: Schema.Number.pipe( + Schema.annotations({ + description: 'X coordinate on the canvas', + }), + ), + y: Schema.Number.pipe( + Schema.annotations({ + description: 'Y coordinate on the canvas', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'Position', + description: 'Position on the canvas', + }), +); + +export const OptionalPosition = Schema.optional( + Position.pipe( + Schema.annotations({ + description: 'Position on the canvas (optional)', + }), + ), +); + +// ============================================================================= +// Enums DERIVED from TypeSpec/Protobuf definitions +// ============================================================================= + +type ValidHandleKind = Exclude; +type ValidErrorHandling = Exclude; + +function literalFromValues>(mapping: T) { + const values = Object.values(mapping) as [string, ...string[]]; + return Schema.Literal(...values); +} + +const errorHandlingValues: Record = { + [PbErrorHandling.IGNORE]: 'ignore', + [PbErrorHandling.BREAK]: 'break', +}; + +export const ErrorHandling = literalFromValues(errorHandlingValues).pipe( + Schema.annotations({ + identifier: 'ErrorHandling', + description: 'How to handle errors: "ignore" continues, "break" stops the loop', + }), +); + +const handleKindValues: Record = { + [PbHandleKind.THEN]: 'then', + [PbHandleKind.ELSE]: 'else', + [PbHandleKind.LOOP]: 'loop', +}; + +export const SourceHandle = literalFromValues(handleKindValues).pipe( + Schema.annotations({ + identifier: 'SourceHandle', + description: + 'Output handle for branching nodes. Use "then"/"else" for Condition nodes, "loop"/"then" for For/ForEach nodes.', + }), +); + +export const ApiCategory = Schema.Literal( + 'messaging', + 'payments', + 'project-management', + 'storage', + 'database', + 'email', + 'calendar', + 'crm', + 'social', + 'analytics', + 'developer', +).pipe( + Schema.annotations({ + identifier: 'ApiCategory', + description: 'Category of the API', + }), +); + +// ============================================================================= +// Display Name & Code Schemas +// ============================================================================= + +export const NodeName = Schema.String.pipe( + Schema.minLength(1), + Schema.maxLength(100), + Schema.annotations({ + description: 'Display name for the node', + examples: ['Transform Data', 'Fetch User', 'Check Status'], + }), +); + +export const JsCode = Schema.String.pipe( + Schema.annotations({ + description: + 'The function body only. Write code directly - do NOT define inner functions. Use ctx for input. MUST have a return statement. The tool auto-wraps with "export default function(ctx) { ... }". Example: "const result = ctx.value * 2; return { result };"', + examples: [ + 'const result = ctx.value * 2; return { result };', + 'const items = ctx.data.filter(x => x.active); return { items, count: items.length };', + ], + }), +); + +export const ConditionExpression = Schema.String.pipe( + Schema.annotations({ + description: + 'Boolean expression using expr-lang syntax. Use == for equality (NOT ===). Use Input to reference previous node output (e.g., "Input.status == 200", "Input.success == true")', + examples: ['Input.status == 200', 'Input.success == true', 'Input.count > 0'], + }), +); + +// ============================================================================= +// Type Exports +// ============================================================================= + +export type Position = typeof Position.Type; +export type ErrorHandling = typeof ErrorHandling.Type; +export type SourceHandle = typeof SourceHandle.Type; +export type ApiCategory = typeof ApiCategory.Type; +`} + + ); +}; + +// ============================================================================= +// Generated index.ts — runtime JSON Schema conversion +// ============================================================================= + +const IndexFile = () => { + return ( + + {`/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Runtime tool schema index — converts Effect Schemas to JSON Schema tool definitions. + * Generated by the TypeSpec emitter. + */ + +import { JSONSchema, Schema } from 'effect'; + +export * from './common.ts'; +export * from './execution.ts'; +export * from './exploration.ts'; +export * from './mutation.ts'; + +import { ExecutionSchemas } from './execution.ts'; +import { ExplorationSchemas } from './exploration.ts'; +import { MutationSchemas } from './mutation.ts'; + +// ============================================================================= +// Tool Definition Type +// ============================================================================= + +export interface ToolDefinition { + name: string; + description: string; + parameters: object; +} + +// ============================================================================= +// JSON Schema Generation +// ============================================================================= + +/** Recursively resolve $ref references in a JSON Schema */ +function resolveRefs(obj: unknown, defs: Record): unknown { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map((item) => resolveRefs(item, defs)); + + const record = obj as Record; + + if ('$ref' in record && typeof record['$ref'] === 'string') { + const defName = record['$ref'].replace('#/$defs/', ''); + const resolved = defs[defName]; + if (resolved) { + const { $ref: _, ...rest } = record; + return { ...(resolveRefs(resolved, defs) as Record), ...rest }; + } + } + + if ('allOf' in record && Array.isArray(record['allOf']) && record['allOf'].length === 1) { + const first = record['allOf'][0] as Record; + if ('$ref' in first) { + const { allOf: _, ...rest } = record; + return { ...(resolveRefs(first, defs) as Record), ...rest }; + } + } + + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key === '$defs' || key === '$schema') continue; + result[key] = resolveRefs(value, defs); + } + return result; +} + +/** Convert an Effect Schema to a tool definition with JSON Schema parameters */ +function schemaToToolDefinition(schema: Schema.Schema): ToolDefinition { + const jsonSchema = JSONSchema.make(schema) as { + $schema: string; + $defs: Record; + $ref: string; + }; + + const defs = jsonSchema.$defs ?? {}; + const defName = (jsonSchema.$ref ?? '').replace('#/$defs/', ''); + const def = defs[defName] as { + description?: string; + type: string; + properties: Record; + required?: string[]; + } | undefined; + + return { + name: defName || 'unknown', + description: def?.description ?? '', + parameters: def + ? { + type: def.type, + properties: resolveRefs(def.properties, defs), + required: def.required, + additionalProperties: false, + } + : jsonSchema, + }; +} + +// ============================================================================= +// Auto-generated Tool Definitions +// ============================================================================= + +export const executionSchemas = Object.values(ExecutionSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +export const explorationSchemas = Object.values(ExplorationSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +export const mutationSchemas = Object.values(MutationSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +/** All tool schemas combined - ready for AI tool calling */ +export const allToolSchemas = [...executionSchemas, ...explorationSchemas, ...mutationSchemas]; + +// ============================================================================= +// Effect Schemas (for runtime validation) +// ============================================================================= + +export const EffectSchemas = { + Execution: ExecutionSchemas, + Exploration: ExplorationSchemas, + Mutation: MutationSchemas, +} as const; + +// ============================================================================= +// Validation Helper +// ============================================================================= + +const schemaMap: Record> = Object.fromEntries( + Object.entries(EffectSchemas).flatMap(([, group]) => + Object.entries(group).map(([name, schema]) => [ + name.charAt(0).toLowerCase() + name.slice(1), + schema as Schema.Schema, + ]), + ), +); + +/** + * Validate tool input against the Effect Schema + */ +export function validateToolInput( + toolName: string, + input: unknown, +): { success: true; data: unknown } | { success: false; errors: string[] } { + const schema = schemaMap[toolName]; + if (!schema) { + return { success: false, errors: [\`Unknown tool: \${toolName}\`] }; + } + + try { + const decoded = Schema.decodeUnknownSync(schema)(input); + return { success: true, data: decoded }; + } catch (error) { + if (error instanceof Error) { + return { success: false, errors: [error.message] }; + } + return { success: false, errors: ['Unknown validation error'] }; + } +} +`} + + ); +}; diff --git a/tools/spec-lib/src/ai-tools/field-schema.ts b/tools/spec-lib/src/ai-tools/field-schema.ts new file mode 100644 index 000000000..1ebb7aa0f --- /dev/null +++ b/tools/spec-lib/src/ai-tools/field-schema.ts @@ -0,0 +1,175 @@ +import { ModelProperty, Program } from '@typespec/compiler'; +import { $ } from '@typespec/compiler/typekit'; + +export interface FieldSchemaResult { + expression: string; + importFrom: 'common' | 'effect' | 'none'; + includesOptional: boolean; + needsDescription: boolean; + schemaName: string; +} + +export function getFieldSchema(property: ModelProperty, program: Program): FieldSchemaResult { + const { name, type } = property; + + // Check for known field names that map to common.ts schemas + const knownFieldSchemas: Record = { + code: 'JsCode', + condition: 'ConditionExpression', + edgeId: 'EdgeId', + errorHandling: 'ErrorHandling', + flowId: 'FlowId', + flowVariableId: 'UlidId', + httpId: 'UlidId', + nodeId: 'NodeId', + position: 'OptionalPosition', + sourceHandle: 'SourceHandle', + sourceId: 'NodeId', + targetId: 'NodeId', + }; + + // Position field is special - it uses OptionalPosition from common when optional + if (name === 'position') { + if (property.optional) { + return { + expression: 'OptionalPosition', + importFrom: 'common', + includesOptional: true, + needsDescription: false, + schemaName: 'OptionalPosition', + }; + } + return { + expression: 'Position', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'Position', + }; + } + + // Name field uses NodeName + if (name === 'name') { + return { + expression: 'NodeName', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'NodeName', + }; + } + + // Check if it's a known field + const knownSchema = knownFieldSchemas[name]; + if (knownSchema) { + return { + expression: knownSchema, + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: knownSchema, + }; + } + + // Check the actual type + if ($(program).scalar.is(type)) { + const scalarName = type.name; + + // bytes type → UlidId + if (scalarName === 'bytes') { + return { + expression: 'UlidId', + importFrom: 'common', + includesOptional: false, + needsDescription: true, + schemaName: 'UlidId', + }; + } + + // string type + if (scalarName === 'string') { + return { + expression: 'Schema.String', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.String', + }; + } + + // int32 type + if (scalarName === 'int32') { + return { + expression: 'Schema.Number.pipe(Schema.int())', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.Number', + }; + } + + // float32 type + if (scalarName === 'float32') { + return { + expression: 'Schema.Number', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.Number', + }; + } + + // boolean type + if (scalarName === 'boolean') { + return { + expression: 'Schema.Boolean', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.Boolean', + }; + } + } + + // Check for enum types + if ($(program).enum.is(type)) { + const enumName = type.name; + // Map known enum names to common.ts schemas + if (enumName === 'ErrorHandling') { + return { + expression: 'ErrorHandling', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'ErrorHandling', + }; + } + if (enumName === 'HandleKind') { + return { + expression: 'SourceHandle', + importFrom: 'common', + includesOptional: false, + needsDescription: false, + schemaName: 'SourceHandle', + }; + } + } + + // Default to Schema.String for unknown types + return { + expression: 'Schema.String', + importFrom: 'effect', + includesOptional: false, + needsDescription: true, + schemaName: 'Schema.String', + }; +} + +export function formatStringLiteral(str: string): string { + // Check if we need multi-line formatting + if (str.length > 80 || str.includes('\n')) { + return '`' + str.replace(/`/g, '\\`').replace(/\$/g, '\\$') + '`'; + } + // Use single quotes for short strings + return "'" + str.replace(/'/g, "\\'") + "'"; +} diff --git a/tools/spec-lib/src/ai-tools/index.ts b/tools/spec-lib/src/ai-tools/index.ts new file mode 100644 index 000000000..f27acb8e4 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/index.ts @@ -0,0 +1,2 @@ +export { $onEmit } from './emitter.jsx'; +export { $decorators, $lib } from './lib.js'; diff --git a/tools/spec-lib/src/ai-tools/lib.ts b/tools/spec-lib/src/ai-tools/lib.ts new file mode 100644 index 000000000..508a33029 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/lib.ts @@ -0,0 +1,112 @@ +import { createTypeSpecLibrary, DecoratorContext, EnumValue, Model } from '@typespec/compiler'; +import { makeStateFactory } from '../utils.js'; + +export const $lib = createTypeSpecLibrary({ + diagnostics: {}, + name: '@the-dev-tools/spec-lib/ai-tools', +}); + +export const $decorators = { + 'DevTools.AITools': { + aiTool, + explorationTool, + mutationTool, + }, +}; + +const { makeStateMap } = makeStateFactory((_) => $lib.createStateSymbol(_)); + +export type ToolCategory = 'Execution' | 'Exploration' | 'Mutation'; + +export interface AIToolOptions { + category: ToolCategory; + title?: string | undefined; +} + +export const aiTools = makeStateMap('aiTools'); + +interface RawAIToolOptions { + category: EnumValue; + title?: string; +} + +function aiTool({ program }: DecoratorContext, target: Model, options: RawAIToolOptions) { + // Extract category name from EnumValue + const category = options.category.value.name as ToolCategory; + aiTools(program).set(target, { + category, + title: options.title, + }); +} + +function pascalToWords(name: string): string[] { + return name.replace(/([a-z])([A-Z])/g, '$1 $2').split(' '); +} + +export type CrudOperation = 'Delete' | 'Insert' | 'Update'; + +export interface MutationToolOptions { + description?: string | undefined; + exclude?: string[] | undefined; + name?: string | undefined; + operation: CrudOperation; + parent?: string | undefined; + title?: string | undefined; +} + +export const mutationTools = makeStateMap('mutationTools'); + +interface RawMutationToolOptions { + description?: string; + exclude?: string[]; + name?: string; + operation: EnumValue; + parent?: string; + title?: string; +} + +function mutationTool({ program }: DecoratorContext, target: Model, ...tools: RawMutationToolOptions[]) { + const words = pascalToWords(target.name); + const spacedName = words.join(' '); + + const resolved: MutationToolOptions[] = tools.map((tool) => { + const operation = tool.operation.value.name as CrudOperation; + return { + description: tool.description, + exclude: tool.exclude, + name: tool.name ?? `${operation}${target.name}`, + operation, + parent: tool.parent, + title: tool.title ?? `${operation} ${spacedName}`, + }; + }); + mutationTools(program).set(target, resolved); +} + +export interface ExplorationToolOptions { + description?: string | undefined; + name?: string | undefined; + title?: string | undefined; +} + +export const explorationTools = makeStateMap('explorationTools'); + +interface RawExplorationToolOptions { + description?: string; + name?: string; + title?: string; +} + +function explorationTool({ program }: DecoratorContext, target: Model, ...tools: RawExplorationToolOptions[]) { + const words = pascalToWords(target.name); + const spacedName = words.join(' '); + + const effectiveTools = tools.length > 0 ? tools : [{}]; + + const resolved: ExplorationToolOptions[] = effectiveTools.map((tool) => ({ + description: tool.description ?? `Get a ${spacedName.toLowerCase()} by its primary key.`, + name: tool.name ?? `Get${target.name}`, + title: tool.title ?? `Get ${spacedName}`, + })); + explorationTools(program).set(target, resolved); +} diff --git a/tools/spec-lib/src/ai-tools/main.tsp b/tools/spec-lib/src/ai-tools/main.tsp new file mode 100644 index 000000000..7e706433d --- /dev/null +++ b/tools/spec-lib/src/ai-tools/main.tsp @@ -0,0 +1,42 @@ +import "../core"; +import "../../dist/src/ai-tools"; + +namespace DevTools.AITools { + enum ToolCategory { + Mutation, + Exploration, + Execution, + } + + model AIToolOptions { + category: ToolCategory; + title?: string; + } + + extern dec aiTool(target: Reflection.Model, options: valueof AIToolOptions); + + enum CrudOperation { + Insert, + Update, + Delete, + } + + model MutationToolOptions { + operation: CrudOperation; + title?: string; + name?: string; + description?: string; + parent?: string; + exclude?: string[]; + } + + extern dec mutationTool(target: Reflection.Model, ...tools: valueof MutationToolOptions[]); + + model ExplorationToolOptions { + name?: string; + title?: string; + description?: string; + } + + extern dec explorationTool(target: Reflection.Model, ...tools: valueof ExplorationToolOptions[]); +}