diff --git a/packages/client/src/features/agent/index.ts b/packages/client/src/features/agent/index.ts new file mode 100644 index 000000000..4c46ee767 --- /dev/null +++ b/packages/client/src/features/agent/index.ts @@ -0,0 +1 @@ +export * from './tool-schemas.ts'; diff --git a/packages/client/src/features/agent/tool-schemas.ts b/packages/client/src/features/agent/tool-schemas.ts new file mode 100644 index 000000000..fe28625cb --- /dev/null +++ b/packages/client/src/features/agent/tool-schemas.ts @@ -0,0 +1,151 @@ +/** + * Runtime tool schema utilities - converts Effect Schemas to JSON Schema tool definitions. + * These utilities are used by the agent to handle AI tool calling. + */ + +import { Either, JSONSchema, ParseResult, Schema } from 'effect'; + +import { ExecutionSchemas } from '@the-dev-tools/spec/tools/execution'; +import { ExplorationSchemas } from '@the-dev-tools/spec/tools/exploration'; +import { MutationSchemas } from '@the-dev-tools/spec/tools/mutation'; + +// Re-export schemas for convenience +export { ExecutionSchemas, ExplorationSchemas, MutationSchemas }; +export * from '@the-dev-tools/spec-lib/common'; + +// ============================================================================= +// 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. + * Returns Either - Right on success, Left on failure. + */ +export function validateToolInput( + toolName: string, + input: unknown, +): Either.Either { + const schema = schemaMap[toolName]; + if (!schema) { + return Either.left([`Unknown tool: ${toolName}`]); + } + + return Schema.decodeUnknownEither(schema)(input).pipe( + Either.mapLeft((error) => [ParseResult.TreeFormatter.formatErrorSync(error)]), + ); +} 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..64347c0a1 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -4,22 +4,29 @@ "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/execution": "./dist/ai-tools/v1/execution.ts", + "./tools/exploration": "./dist/ai-tools/v1/exploration.ts", + "./tools/mutation": "./dist/ai-tools/v1/mutation.ts" + }, + "dependencies": { + "@the-dev-tools/spec-lib": "workspace:^", + "effect": "catalog:" }, "devDependencies": { "@bufbuild/buf": "catalog:", "@bufbuild/protobuf": "catalog:", "@bufbuild/protoc-gen-es": "catalog:", "@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 8f3443e52..6455ae6fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -828,6 +828,13 @@ importers: version: link:../spec packages/spec: + dependencies: + '@the-dev-tools/spec-lib': + specifier: workspace:^ + version: link:../../tools/spec-lib + effect: + specifier: 'catalog:' + version: 3.19.14 devDependencies: '@bufbuild/buf': specifier: 'catalog:' @@ -841,18 +848,15 @@ importers: '@the-dev-tools/eslint-config': specifier: workspace:^ version: link:../../tools/eslint - '@the-dev-tools/spec-lib': - specifier: workspace:^ - version: link:../../tools/spec-lib '@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 @@ -1087,6 +1091,10 @@ importers: version: 5.9.3 tools/spec-lib: + dependencies: + effect: + specifier: 'catalog:' + version: 3.19.14 devDependencies: '@alloy-js/cli': specifier: 'catalog:' @@ -1109,9 +1117,6 @@ importers: '@typespec/emitter-framework': specifier: 'catalog:' version: 0.14.0(@alloy-js/core@0.22.0)(@alloy-js/csharp@0.21.0)(@alloy-js/typescript@0.22.0)(@typespec/compiler@1.7.1(@types/node@25.0.3)) - effect: - specifier: 'catalog:' - version: 3.19.14 prettier: specifier: 'catalog:' version: 3.7.4 diff --git a/tools/spec-lib/package.json b/tools/spec-lib/package.json index c81a0de37..72ddeefa3 100755 --- a/tools/spec-lib/package.json +++ b/tools/spec-lib/package.json @@ -17,8 +17,20 @@ "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" + }, + "./common": { + "types": "./dist/src/common.d.ts", + "default": "./dist/src/common.js" } }, + "dependencies": { + "effect": "catalog:" + }, "devDependencies": { "@alloy-js/cli": "catalog:", "@alloy-js/core": "catalog:", @@ -27,7 +39,6 @@ "@the-dev-tools/eslint-config": "workspace:^", "@types/node": "catalog:", "@typespec/emitter-framework": "catalog:", - "effect": "catalog:", "prettier": "catalog:", "typescript": "catalog:" } 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..b70ad3932 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -0,0 +1,349 @@ +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 '@the-dev-tools/spec-lib/common'; + + + + ); +}; + +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} + + ); +}; 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[]); +} diff --git a/tools/spec-lib/src/common.ts b/tools/spec-lib/src/common.ts new file mode 100644 index 000000000..c4b3ca8f0 --- /dev/null +++ b/tools/spec-lib/src/common.ts @@ -0,0 +1,167 @@ +/** + * Common schemas and utilities for tool definitions. + */ + +import { Schema } from 'effect'; + +// ============================================================================= +// 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 - hardcoded values (matching protobuf definitions) +// ============================================================================= +// +// SYNC WARNING: These values are hardcoded to avoid circular dependencies with +// packages/spec. They MUST match the protobuf definitions in: +// api/flow/v1/flow.proto -> ErrorHandling, HandleKind enums +// +// If the protobuf enums change, update these literals accordingly. +// ============================================================================= + +export const ErrorHandling = Schema.Literal('ignore', 'break').pipe( + Schema.annotations({ + identifier: 'ErrorHandling', + description: 'How to handle errors: "ignore" continues, "break" stops the loop', + }), +); + +export const SourceHandle = Schema.Literal('then', 'else', 'loop').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;