From 4d89c567582e0913911a55e1dc344a7f0af9707e Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Wed, 21 Jan 2026 22:06:40 +0400 Subject: [PATCH 01/14] effect schema foundation --- packages/agent/src/tools/schemas-effect.ts | 209 ++++++++++ .../agent/src/tools/test-effect-schemas.ts | 79 ++++ packages/spec/package.json | 17 +- packages/spec/src/tools/common.ts | 250 ++++++++++++ packages/spec/src/tools/execution.ts | 78 ++++ packages/spec/src/tools/exploration.ts | 241 +++++++++++ packages/spec/src/tools/generate.ts | 89 ++++ packages/spec/src/tools/index.ts | 234 +++++++++++ packages/spec/src/tools/mutation.ts | 383 ++++++++++++++++++ pnpm-lock.yaml | 10 +- 10 files changed, 1585 insertions(+), 5 deletions(-) create mode 100644 packages/agent/src/tools/schemas-effect.ts create mode 100644 packages/agent/src/tools/test-effect-schemas.ts create mode 100644 packages/spec/src/tools/common.ts create mode 100644 packages/spec/src/tools/execution.ts create mode 100644 packages/spec/src/tools/exploration.ts create mode 100644 packages/spec/src/tools/generate.ts create mode 100644 packages/spec/src/tools/index.ts create mode 100644 packages/spec/src/tools/mutation.ts diff --git a/packages/agent/src/tools/schemas-effect.ts b/packages/agent/src/tools/schemas-effect.ts new file mode 100644 index 000000000..90ec8aecb --- /dev/null +++ b/packages/agent/src/tools/schemas-effect.ts @@ -0,0 +1,209 @@ +/** + * Integration with Effect Schema Tool Definitions + * + * This file demonstrates how to use the Effect Schema-based tool definitions + * from @the-dev-tools/spec instead of the hand-written JSON schemas. + * + * MIGRATION PATH: + * 1. Import from @the-dev-tools/spec/tools (dynamic generation) + * OR @the-dev-tools/spec/tools/schemas (pre-generated JSON) + * 2. Replace usage of local schemas with spec schemas + * 3. Delete local schema files once migration is complete + * + * BENEFITS: + * - Single source of truth in @the-dev-tools/spec + * - Type-safe schema definitions with runtime validation + * - Auto-generated JSON Schema for AI tool calling + * - Annotations (descriptions, examples) are code, not strings + * - When spec updates, regenerate schemas: `pnpm --filter @the-dev-tools/spec generate:schemas` + */ + +// ============================================================================= +// Option 1: Import pre-generated schemas (recommended for production) +// ============================================================================= +// These are generated at build time via `pnpm generate:schemas` - no runtime overhead +// Use this when you don't need runtime validation, just the JSON Schema for AI tools +// +// import { +// allToolSchemas, +// mutationSchemas, +// explorationSchemas, +// executionSchemas, +// createJsNodeSchema, +// } from '@the-dev-tools/spec/tools/schemas'; + +// ============================================================================= +// Option 2: Import Effect Schemas directly (for validation + generation) +// ============================================================================= +// These generate JSON Schema at runtime but also provide: +// - Runtime validation via Schema.decodeUnknown +// - Type inference via Schema.Type +// - Encoding/decoding transformations +// +import { + allToolSchemas, + createJsNodeSchema, + EffectSchemas, + executionSchemas, + explorationSchemas, + mutationSchemas, + type ToolDefinition, +} from '@the-dev-tools/spec/tools'; + +// ============================================================================= +// Re-export for backward compatibility +// ============================================================================= + +// Tool schema collections +export { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas }; + +// Individual mutation schemas +export { + connectNodesSchema, + createConditionNodeSchema, + createForEachNodeSchema, + createForNodeSchema, + createHttpNodeSchema, + createJsNodeSchema, + createVariableSchema, + deleteNodeSchema, + disconnectNodesSchema, + updateNodeCodeSchema, + updateNodeConfigSchema, + updateVariableSchema, +} from '@the-dev-tools/spec/tools'; + +// Individual exploration schemas +export { + getApiDocsSchema, + getExecutionHistorySchema, + getExecutionLogsSchema, + getNodeDetailsSchema, + getNodeTemplateSchema, + getWorkflowGraphSchema, + searchApiDocsSchema, + searchTemplatesSchema, +} from '@the-dev-tools/spec/tools'; + +// Individual execution schemas +export { + runWorkflowSchema, + stopWorkflowSchema, + validateWorkflowSchema, +} from '@the-dev-tools/spec/tools'; + +// ============================================================================= +// Type exports +// ============================================================================= + +export type { ToolDefinition }; + +// ============================================================================= +// Validation utilities using Effect Schema +// ============================================================================= + +import { Schema } from 'effect'; + +/** + * Validate tool input against the Effect Schema + * + * Example usage: + * ```typescript + * const result = validateToolInput('createJsNode', { + * flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + * name: 'Transform Data', + * code: 'return { result: ctx.value * 2 };' + * }); + * + * if (result.success) { + * console.log('Valid input:', result.data); + * } else { + * console.error('Validation errors:', result.errors); + * } + * ``` + */ +export function validateToolInput( + toolName: string, + input: unknown, +): { success: true; data: unknown } | { success: false; errors: string[] } { + // Find the matching Effect Schema + const schemaMap: Record> = { + // Mutation + createJsNode: EffectSchemas.Mutation.CreateJsNode, + createHttpNode: EffectSchemas.Mutation.CreateHttpNode, + createConditionNode: EffectSchemas.Mutation.CreateConditionNode, + createForNode: EffectSchemas.Mutation.CreateForNode, + createForEachNode: EffectSchemas.Mutation.CreateForEachNode, + updateNodeCode: EffectSchemas.Mutation.UpdateNodeCode, + updateNodeConfig: EffectSchemas.Mutation.UpdateNodeConfig, + connectNodes: EffectSchemas.Mutation.ConnectNodes, + disconnectNodes: EffectSchemas.Mutation.DisconnectNodes, + deleteNode: EffectSchemas.Mutation.DeleteNode, + createVariable: EffectSchemas.Mutation.CreateVariable, + updateVariable: EffectSchemas.Mutation.UpdateVariable, + // Exploration + getWorkflowGraph: EffectSchemas.Exploration.GetWorkflowGraph, + getNodeDetails: EffectSchemas.Exploration.GetNodeDetails, + getNodeTemplate: EffectSchemas.Exploration.GetNodeTemplate, + searchTemplates: EffectSchemas.Exploration.SearchTemplates, + getExecutionHistory: EffectSchemas.Exploration.GetExecutionHistory, + getExecutionLogs: EffectSchemas.Exploration.GetExecutionLogs, + searchApiDocs: EffectSchemas.Exploration.SearchApiDocs, + getApiDocs: EffectSchemas.Exploration.GetApiDocs, + // Execution + runWorkflow: EffectSchemas.Execution.RunWorkflow, + stopWorkflow: EffectSchemas.Execution.StopWorkflow, + validateWorkflow: EffectSchemas.Execution.ValidateWorkflow, + }; + + 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'] }; + } +} + +// ============================================================================= +// Example: Using schemas with Claude/Anthropic API +// ============================================================================= + +/** + * Example of how to use the schemas with Claude's tool_use API + * + * ```typescript + * import Anthropic from '@anthropic-ai/sdk'; + * import { allToolSchemas } from '@the-dev-tools/spec/tools'; + * + * const client = new Anthropic(); + * + * const response = await client.messages.create({ + * model: 'claude-sonnet-4-20250514', + * max_tokens: 1024, + * tools: allToolSchemas.map(schema => ({ + * name: schema.name, + * description: schema.description, + * input_schema: schema.parameters, + * })), + * messages: [{ role: 'user', content: 'Create a JS node that doubles the input' }], + * }); + * ``` + */ + +// ============================================================================= +// Debug: Print schema to verify generation +// ============================================================================= + +if (import.meta.url === `file://${process.argv[1]}`) { + console.log('Example createJsNode schema:'); + console.log(JSON.stringify(createJsNodeSchema, null, 2)); + console.log('\nTotal schemas:', allToolSchemas.length); +} diff --git a/packages/agent/src/tools/test-effect-schemas.ts b/packages/agent/src/tools/test-effect-schemas.ts new file mode 100644 index 000000000..14a1cf097 --- /dev/null +++ b/packages/agent/src/tools/test-effect-schemas.ts @@ -0,0 +1,79 @@ +/** + * Test script to verify Effect Schema integration + * Run: pnpm tsx src/tools/test-effect-schemas.ts + */ + +import { Schema } from 'effect'; + +import { + allToolSchemas, + createJsNodeSchema, + EffectSchemas, + executionSchemas, + explorationSchemas, + mutationSchemas, +} from '@the-dev-tools/spec/tools'; + +console.log('='.repeat(60)); +console.log('Effect Schema Tool Definitions Test'); +console.log('='.repeat(60)); + +// Test 1: Count schemas +console.log('\n1. Schema counts:'); +console.log(` Mutation tools: ${mutationSchemas.length}`); +console.log(` Exploration tools: ${explorationSchemas.length}`); +console.log(` Execution tools: ${executionSchemas.length}`); +console.log(` Total: ${allToolSchemas.length}`); + +// Test 2: Verify createJsNode schema structure +console.log('\n2. createJsNode schema:'); +console.log(JSON.stringify(createJsNodeSchema, null, 2)); + +// Test 3: Validate input using Effect Schema +console.log('\n3. Input validation test:'); + +const validInput = { + flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'Transform Data', + code: 'const result = ctx.value * 2; return { result };', +}; + +const invalidInput = { + flowId: 'not-a-valid-ulid', + name: '', // Empty name should fail minLength + code: 'return ctx;', +}; + +try { + const decoded = Schema.decodeUnknownSync(EffectSchemas.Mutation.CreateJsNode)(validInput); + console.log(' Valid input decoded successfully:', JSON.stringify(decoded)); +} catch (error) { + console.log(' Valid input failed (unexpected):', error); +} + +try { + const decoded = Schema.decodeUnknownSync(EffectSchemas.Mutation.CreateJsNode)(invalidInput); + console.log(' Invalid input decoded (unexpected):', decoded); +} catch (error) { + console.log(' Invalid input rejected (expected):', (error as Error).message.slice(0, 100) + '...'); +} + +// Test 4: Type inference +console.log('\n4. TypeScript type inference:'); +type CreateJsNodeInput = typeof EffectSchemas.Mutation.CreateJsNode.Type; +const typedInput: CreateJsNodeInput = { + flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'My Node', + code: 'return {};', +}; +console.log(' Type-safe input:', typedInput); + +// Test 5: List all tool names +console.log('\n5. All tool names:'); +allToolSchemas.forEach((schema, i) => { + console.log(` ${i + 1}. ${schema.name}`); +}); + +console.log('\n' + '='.repeat(60)); +console.log('All tests passed!'); +console.log('='.repeat(60)); diff --git a/packages/spec/package.json b/packages/spec/package.json index c5edb8bd0..1a499ca28 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -4,12 +4,25 @@ "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": "./src/tools/index.ts", + "./tools/schemas": "./dist/tools/schemas.ts", + "./tools/common": "./src/tools/common.ts", + "./tools/mutation": "./src/tools/mutation.ts", + "./tools/exploration": "./src/tools/exploration.ts", + "./tools/execution": "./src/tools/execution.ts" + }, + "scripts": { + "generate:schemas": "tsx src/tools/generate.ts" + }, + "dependencies": { + "effect": "catalog:" }, "devDependencies": { "@bufbuild/buf": "catalog:", @@ -18,8 +31,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/src/tools/common.ts b/packages/spec/src/tools/common.ts new file mode 100644 index 000000000..c3c275881 --- /dev/null +++ b/packages/spec/src/tools/common.ts @@ -0,0 +1,250 @@ +/** + * Common schemas and utilities for tool definitions + * These are shared building blocks used across multiple tool schemas + * + * IMPORTANT: Enums and types are DERIVED from the generated Protobuf types + * in @the-dev-tools/spec/buf/api/flow/v1/flow_pb to ensure consistency + * with the TypeSpec definitions. + */ + +import { Schema } from 'effect'; + +// ============================================================================= +// Import enums from generated Protobuf (derived from TypeSpec) +// ============================================================================= +import { + ErrorHandling as PbErrorHandling, + HandleKind as PbHandleKind, +} from '../../dist/buf/typescript/api/flow/v1/flow_pb.ts'; + +// ============================================================================= +// Common Field Schemas +// ============================================================================= + +/** + * ULID identifier schema - used for all entity IDs + * Matches the `Id` type in TypeSpec (main.tsp) + */ +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 + * Corresponds to Flow.flowId in flow.tsp + */ +export const FlowId = UlidId.pipe( + Schema.annotations({ + identifier: 'flowId', + description: 'The ULID of the workflow', + }), +); + +/** + * Node ID - references a node within a workflow + * Corresponds to Node.nodeId in flow.tsp + */ +export const NodeId = UlidId.pipe( + Schema.annotations({ + identifier: 'nodeId', + description: 'The ULID of the node', + }), +); + +/** + * Edge ID - references an edge connection + * Corresponds to Edge.edgeId in flow.tsp + */ +export const EdgeId = UlidId.pipe( + Schema.annotations({ + identifier: 'edgeId', + description: 'The ULID of the edge', + }), +); + +// ============================================================================= +// Position Schema (matches Position model in flow.tsp) +// ============================================================================= + +/** + * Canvas position for nodes + * Derived from: model Position { x: float32; y: float32; } in flow.tsp + */ +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 +// ============================================================================= + +/** + * SAFETY NET: These types and Record<> patterns ensure TypeScript will ERROR + * if the backend adds new enum values to TypeSpec that we haven't handled. + * + * How it works: + * 1. We exclude UNSPECIFIED (protobuf default) from each enum type + * 2. We use Record which REQUIRES all enum values as keys + * 3. If backend adds e.g. PARALLEL to HandleKind, TypeScript errors: + * "Property 'PARALLEL' is missing in type..." + * + * This turns silent drift into compile-time errors! + */ + +// Types that exclude the UNSPECIFIED protobuf default value +type ValidHandleKind = Exclude; +type ValidErrorHandling = Exclude; + +/** + * Helper: Creates a Schema.Literal from all values in an enum mapping. + * This ensures the schema automatically includes all mapped values. + */ +function literalFromValues>(mapping: T) { + const values = Object.values(mapping) as [string, ...string[]]; + return Schema.Literal(...values); +} + +/** + * Error handling strategies for loop nodes + * Derived from: enum ErrorHandling { Ignore, Break } in flow.tsp + * + * EXHAUSTIVE: Record forces all enum values to be present. + * If backend adds a new value, TypeScript will error until it's added here. + */ +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', + }), +); + +/** + * Source handle types for connecting nodes + * Derived from: enum HandleKind { Then, Else, Loop } in flow.tsp + * + * EXHAUSTIVE: Record forces all enum values to be present. + * If backend adds a new value (e.g., PARALLEL), TypeScript will error until it's added here. + */ +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.', + }), +); + +/** + * API documentation categories + */ +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 Schema +// ============================================================================= + +/** + * Display name for nodes + */ +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'], + }), +); + +// ============================================================================= +// Code Schema (for JS nodes) +// ============================================================================= + +/** + * JavaScript code for JS nodes + */ +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 };', + ], + }), +); + +// ============================================================================= +// Condition Expression Schema +// ============================================================================= + +/** + * Boolean condition expression using expr-lang syntax + */ +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; diff --git a/packages/spec/src/tools/execution.ts b/packages/spec/src/tools/execution.ts new file mode 100644 index 000000000..fd7dd812c --- /dev/null +++ b/packages/spec/src/tools/execution.ts @@ -0,0 +1,78 @@ +/** + * Effect Schema definitions for execution (control) tools + * These tools manage workflow execution: run, stop, validate + */ + +import { Schema } from 'effect'; + +import { FlowId } from './common.ts'; + +// ============================================================================= +// Execution Control Schemas +// ============================================================================= + +/** + * Run a workflow from the start + */ +export const RunWorkflow = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to run', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'runWorkflow', + title: 'Run Workflow', + description: 'Execute the workflow from the start node. Returns execution status.', + }), +); + +/** + * Stop a running workflow + */ +export const StopWorkflow = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to stop', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'stopWorkflow', + title: 'Stop Workflow', + description: 'Stop a running workflow execution.', + }), +); + +/** + * Validate workflow before running + */ +export const ValidateWorkflow = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to validate', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'validateWorkflow', + title: 'Validate Workflow', + description: + 'Validate the workflow for errors, missing connections, or configuration issues. Use this before running to catch problems.', + }), +); + +// ============================================================================= +// Exports +// ============================================================================= + +export const ExecutionSchemas = { + RunWorkflow, + StopWorkflow, + ValidateWorkflow, +} as const; + +export type RunWorkflow = typeof RunWorkflow.Type; +export type StopWorkflow = typeof StopWorkflow.Type; +export type ValidateWorkflow = typeof ValidateWorkflow.Type; diff --git a/packages/spec/src/tools/exploration.ts b/packages/spec/src/tools/exploration.ts new file mode 100644 index 000000000..2756fce4e --- /dev/null +++ b/packages/spec/src/tools/exploration.ts @@ -0,0 +1,241 @@ +/** + * Effect Schema definitions for exploration (read-only) tools + * These tools retrieve information without modifying state + */ + +import { Schema } from 'effect'; + +import { ApiCategory, FlowId, NodeId, UlidId } from './common.ts'; + +// ============================================================================= +// Workflow Graph Schemas +// ============================================================================= + +/** + * Get complete workflow structure + */ +export const GetWorkflowGraph = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to retrieve', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'getWorkflowGraph', + title: 'Get Workflow Graph', + description: + 'Get the complete workflow graph including all nodes and edges. Use this to understand the current structure of the workflow.', + }), +); + +/** + * Get detailed information about a specific node + */ +export const GetNodeDetails = Schema.Struct({ + nodeId: NodeId.pipe( + Schema.annotations({ + description: 'The ULID of the node to inspect', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'getNodeDetails', + title: 'Get Node Details', + description: + 'Get detailed information about a specific node including its configuration, code (for JS nodes), and connections.', + }), +); + +// ============================================================================= +// Template Schemas +// ============================================================================= + +/** + * Get a specific node template by name + */ +export const GetNodeTemplate = Schema.Struct({ + templateName: Schema.String.pipe( + Schema.annotations({ + description: + 'The name of the template to retrieve (e.g., "http-aggregator", "js-transformer")', + examples: ['http-aggregator', 'js-transformer', 'conditional-router', 'data-validator'], + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'getNodeTemplate', + title: 'Get Node Template', + description: + 'Read a markdown template that provides guidance on implementing a specific type of node or pattern.', + }), +); + +/** + * Search available templates + */ +export const SearchTemplates = Schema.Struct({ + query: Schema.String.pipe( + Schema.annotations({ + description: 'Search query to find relevant templates', + examples: ['http', 'transform', 'loop', 'condition'], + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'searchTemplates', + title: 'Search Templates', + description: 'Search for available templates by keyword or pattern.', + }), +); + +// ============================================================================= +// Execution History Schemas +// ============================================================================= + +/** + * Get workflow execution history + */ +export const GetExecutionHistory = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow', + }), + ), + limit: Schema.optional( + Schema.Number.pipe( + Schema.int(), + Schema.positive(), + Schema.annotations({ + description: 'Maximum number of executions to return (default: 10)', + }), + ), + ), +}).pipe( + Schema.annotations({ + identifier: 'getExecutionHistory', + title: 'Get Execution History', + description: 'Get the history of past workflow executions.', + }), +); + +/** + * Get execution logs for debugging + */ +export const GetExecutionLogs = Schema.Struct({ + flowId: Schema.optional(FlowId).pipe( + Schema.annotations({ + description: 'Filter to only show executions for nodes in this workflow', + }), + ), + limit: Schema.optional( + Schema.Number.pipe( + Schema.int(), + Schema.positive(), + Schema.annotations({ + description: 'Maximum number of node executions to return (default: 10)', + }), + ), + ), + executionId: Schema.optional(UlidId).pipe( + Schema.annotations({ + description: 'Optional: specific execution ID to get logs for', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'getExecutionLogs', + title: 'Get Execution Logs', + description: + 'Get the latest execution logs. Returns only the most recent execution per node to avoid showing full history.', + }), +); + +// ============================================================================= +// API Documentation Schemas +// ============================================================================= + +/** + * Search for API documentation + */ +export const SearchApiDocs = Schema.Struct({ + query: Schema.String.pipe( + Schema.annotations({ + description: + 'Search query - API name, description keywords, or use case (e.g., "send message", "payment", "telegram bot")', + examples: ['send message', 'payment processing', 'telegram bot', 'slack notification'], + }), + ), + category: Schema.optional(ApiCategory), + limit: Schema.optional( + Schema.Number.pipe( + Schema.int(), + Schema.positive(), + Schema.annotations({ + description: 'Maximum results to return (default: 5)', + }), + ), + ), +}).pipe( + Schema.annotations({ + identifier: 'searchApiDocs', + title: 'Search API Docs', + description: + 'Search for API documentation by name, description, or keywords. Returns lightweight metadata for matching APIs. Use getApiDocs to load full documentation for a specific API.', + }), +); + +/** + * Get full API documentation + */ +export const GetApiDocs = Schema.Struct({ + apiId: Schema.String.pipe( + Schema.annotations({ + description: 'API identifier from search results (e.g., "slack", "stripe", "telegram")', + examples: ['slack', 'stripe', 'telegram', 'github', 'notion'], + }), + ), + forceRefresh: Schema.optional(Schema.Boolean).pipe( + Schema.annotations({ + description: 'Force refresh from source, bypassing cache', + }), + ), + endpoint: Schema.optional(Schema.String).pipe( + Schema.annotations({ + description: + 'Optional filter to focus on specific endpoint (e.g., "chat.postMessage", "sendMessage")', + examples: ['chat.postMessage', 'sendMessage', 'charges.create'], + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'getApiDocs', + title: 'Get API Docs', + description: + 'Load full documentation for a specific API. Call this after searchApiDocs to get complete endpoint details, authentication info, and examples.', + }), +); + +// ============================================================================= +// Exports +// ============================================================================= + +export const ExplorationSchemas = { + GetWorkflowGraph, + GetNodeDetails, + GetNodeTemplate, + SearchTemplates, + GetExecutionHistory, + GetExecutionLogs, + SearchApiDocs, + GetApiDocs, +} as const; + +export type GetWorkflowGraph = typeof GetWorkflowGraph.Type; +export type GetNodeDetails = typeof GetNodeDetails.Type; +export type GetNodeTemplate = typeof GetNodeTemplate.Type; +export type SearchTemplates = typeof SearchTemplates.Type; +export type GetExecutionHistory = typeof GetExecutionHistory.Type; +export type GetExecutionLogs = typeof GetExecutionLogs.Type; +export type SearchApiDocs = typeof SearchApiDocs.Type; +export type GetApiDocs = typeof GetApiDocs.Type; diff --git a/packages/spec/src/tools/generate.ts b/packages/spec/src/tools/generate.ts new file mode 100644 index 000000000..c713ec0cd --- /dev/null +++ b/packages/spec/src/tools/generate.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx +/** + * JSON Schema Generator Script + * + * This script generates JSON Schema files from Effect Schema definitions. + * Run with: pnpm tsx src/tools/generate.ts + * + * Output: + * - dist/tools/schemas.json - All tool schemas as JSON + * - dist/tools/schemas.ts - TypeScript file with const exports + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas } from './index.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Output directory +const outDir = path.resolve(__dirname, '../../dist/tools'); + +// Ensure output directory exists +fs.mkdirSync(outDir, { recursive: true }); + +// ============================================================================= +// Generate JSON file +// ============================================================================= + +const jsonOutput = { + $schema: 'http://json-schema.org/draft-07/schema#', + generatedAt: new Date().toISOString(), + tools: { + all: allToolSchemas, + mutation: mutationSchemas, + exploration: explorationSchemas, + execution: executionSchemas, + }, +}; + +fs.writeFileSync( + path.join(outDir, 'schemas.json'), + JSON.stringify(jsonOutput, null, 2), +); + +console.log('✓ Generated dist/tools/schemas.json'); + +// ============================================================================= +// Generate TypeScript file (for direct imports without runtime generation) +// ============================================================================= + +const tsOutput = `/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from Effect Schema definitions + * Run 'pnpm generate:schemas' to regenerate + */ + +export const allToolSchemas = ${JSON.stringify(allToolSchemas, null, 2)} as const; + +export const mutationSchemas = ${JSON.stringify(mutationSchemas, null, 2)} as const; + +export const explorationSchemas = ${JSON.stringify(explorationSchemas, null, 2)} as const; + +export const executionSchemas = ${JSON.stringify(executionSchemas, null, 2)} as const; + +// Individual schema exports +${allToolSchemas + .map( + (schema) => + `export const ${schema.name}Schema = ${JSON.stringify(schema, null, 2)} as const;`, + ) + .join('\n\n')} +`; + +fs.writeFileSync(path.join(outDir, 'schemas.ts'), tsOutput); + +console.log('✓ Generated dist/tools/schemas.ts'); + +// ============================================================================= +// Summary +// ============================================================================= + +console.log('\nGenerated schemas summary:'); +console.log(` Mutation tools: ${mutationSchemas.length}`); +console.log(` Exploration tools: ${explorationSchemas.length}`); +console.log(` Execution tools: ${executionSchemas.length}`); +console.log(` Total: ${allToolSchemas.length}`); diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts new file mode 100644 index 000000000..bdb75f2e4 --- /dev/null +++ b/packages/spec/src/tools/index.ts @@ -0,0 +1,234 @@ +/** + * Tool Schema Definitions using Effect Schema + * + * This module provides type-safe tool definitions that can be: + * 1. Used directly as TypeScript types for validation + * 2. Converted to JSON Schema for AI tool calling (Claude, MCP, etc.) + * + * The schemas are the single source of truth for tool definitions. + * When spec changes, these schemas should be updated accordingly. + */ + +import { JSONSchema, Schema } from 'effect'; + +// Re-export all schemas +export * from './common.ts'; +export * from './execution.ts'; +export * from './exploration.ts'; +export * from './mutation.ts'; + +// Import schema groups +import { ExecutionSchemas } from './execution.ts'; +import { ExplorationSchemas } from './exploration.ts'; +import { MutationSchemas } from './mutation.ts'; + +// ============================================================================= +// Tool Definition Type +// ============================================================================= + +/** + * Standard tool definition format for AI tool calling + */ +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; + + // Handle $ref + if ('$ref' in record && typeof record.$ref === 'string') { + const refPath = record.$ref; + const defName = refPath.replace('#/$defs/', ''); + const resolved = defs[defName]; + if (resolved) { + // Merge any sibling properties (like description) with resolved ref + const { $ref: _, ...rest } = record; + const resolvedObj = resolveRefs(resolved, defs) as Record; + return { ...resolvedObj, ...rest }; + } + } + + // Handle allOf with single $ref (common pattern from Effect) + 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; + const resolved = resolveRefs(first, defs) as Record; + return { ...resolved, ...rest }; + } + } + + // Recursively process all properties + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key === '$defs' || key === '$schema') { + continue; // Skip $defs and $schema in output + } + 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 { + // Generate JSON Schema - Effect puts annotations in the $defs + const jsonSchema = JSONSchema.make(schema) as { + $schema: string; + $defs: Record; + $ref: string; + }; + + const defs = jsonSchema.$defs ?? {}; + + // Extract the definition name from $ref (e.g., "#/$defs/createJsNode" -> "createJsNode") + const refPath = jsonSchema.$ref ?? ''; + const defName = refPath.replace('#/$defs/', ''); + const def = defs[defName] as { + title?: string; + description?: string; + type: string; + properties: Record; + required?: string[]; + } | undefined; + + // Get name from identifier (the key in $defs) and description from the definition + const name = defName || 'unknown'; + const description = def?.description ?? ''; + + // Resolve all $refs in properties to inline nested schemas + const resolvedProperties = def?.properties + ? resolveRefs(def.properties, defs) + : {}; + + // Build flattened parameters schema + const parameters = def + ? { + type: def.type, + properties: resolvedProperties, + required: def.required, + additionalProperties: false, + } + : jsonSchema; + + return { + name, + description, + parameters, + }; +} + +// ============================================================================= +// Generated Tool Definitions +// ============================================================================= + +// Mutation tools +export const createJsNodeSchema = schemaToToolDefinition(MutationSchemas.CreateJsNode); +export const createHttpNodeSchema = schemaToToolDefinition(MutationSchemas.CreateHttpNode); +export const createConditionNodeSchema = schemaToToolDefinition(MutationSchemas.CreateConditionNode); +export const createForNodeSchema = schemaToToolDefinition(MutationSchemas.CreateForNode); +export const createForEachNodeSchema = schemaToToolDefinition(MutationSchemas.CreateForEachNode); +export const updateNodeCodeSchema = schemaToToolDefinition(MutationSchemas.UpdateNodeCode); +export const updateNodeConfigSchema = schemaToToolDefinition(MutationSchemas.UpdateNodeConfig); +export const connectNodesSchema = schemaToToolDefinition(MutationSchemas.ConnectNodes); +export const disconnectNodesSchema = schemaToToolDefinition(MutationSchemas.DisconnectNodes); +export const deleteNodeSchema = schemaToToolDefinition(MutationSchemas.DeleteNode); +export const createVariableSchema = schemaToToolDefinition(MutationSchemas.CreateVariable); +export const updateVariableSchema = schemaToToolDefinition(MutationSchemas.UpdateVariable); + +// Exploration tools +export const getWorkflowGraphSchema = schemaToToolDefinition(ExplorationSchemas.GetWorkflowGraph); +export const getNodeDetailsSchema = schemaToToolDefinition(ExplorationSchemas.GetNodeDetails); +export const getNodeTemplateSchema = schemaToToolDefinition(ExplorationSchemas.GetNodeTemplate); +export const searchTemplatesSchema = schemaToToolDefinition(ExplorationSchemas.SearchTemplates); +export const getExecutionHistorySchema = schemaToToolDefinition(ExplorationSchemas.GetExecutionHistory); +export const getExecutionLogsSchema = schemaToToolDefinition(ExplorationSchemas.GetExecutionLogs); +export const searchApiDocsSchema = schemaToToolDefinition(ExplorationSchemas.SearchApiDocs); +export const getApiDocsSchema = schemaToToolDefinition(ExplorationSchemas.GetApiDocs); + +// Execution tools +export const runWorkflowSchema = schemaToToolDefinition(ExecutionSchemas.RunWorkflow); +export const stopWorkflowSchema = schemaToToolDefinition(ExecutionSchemas.StopWorkflow); +export const validateWorkflowSchema = schemaToToolDefinition(ExecutionSchemas.ValidateWorkflow); + +// ============================================================================= +// Grouped Exports +// ============================================================================= + +export const mutationSchemas = [ + createJsNodeSchema, + createHttpNodeSchema, + createConditionNodeSchema, + createForNodeSchema, + createForEachNodeSchema, + updateNodeCodeSchema, + updateNodeConfigSchema, + connectNodesSchema, + disconnectNodesSchema, + deleteNodeSchema, + createVariableSchema, + updateVariableSchema, +]; + +export const explorationSchemas = [ + getWorkflowGraphSchema, + getNodeDetailsSchema, + getNodeTemplateSchema, + searchTemplatesSchema, + getExecutionHistorySchema, + getExecutionLogsSchema, + searchApiDocsSchema, + getApiDocsSchema, +]; + +export const executionSchemas = [ + runWorkflowSchema, + stopWorkflowSchema, + validateWorkflowSchema, +]; + +/** + * All tool schemas combined - ready for AI tool calling + */ +export const allToolSchemas = [ + ...explorationSchemas, + ...mutationSchemas, + ...executionSchemas, +]; + +// ============================================================================= +// Effect Schemas (for validation/parsing) +// ============================================================================= + +/** + * Raw Effect Schema objects for use with Effect's decode/encode + */ +export const EffectSchemas = { + Mutation: MutationSchemas, + Exploration: ExplorationSchemas, + Execution: ExecutionSchemas, +} as const; diff --git a/packages/spec/src/tools/mutation.ts b/packages/spec/src/tools/mutation.ts new file mode 100644 index 000000000..dc7018287 --- /dev/null +++ b/packages/spec/src/tools/mutation.ts @@ -0,0 +1,383 @@ +/** + * Effect Schema definitions for mutation (write) tools + * These tools modify workflow state: create nodes, update configs, manage connections + */ + +import { Schema } from 'effect'; + +import { + ConditionExpression, + EdgeId, + ErrorHandling, + FlowId, + JsCode, + NodeId, + NodeName, + OptionalPosition, + SourceHandle, + UlidId, +} from './common.ts'; + +// ============================================================================= +// Node Creation Schemas +// ============================================================================= + +/** + * Create a new JavaScript node in the workflow + */ +export const CreateJsNode = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to add the node to', + }), + ), + name: NodeName, + code: JsCode, + position: OptionalPosition, +}).pipe( + Schema.annotations({ + identifier: 'createJsNode', + title: 'Create JavaScript Node', + description: + 'Create a new JavaScript node in the workflow. JS nodes can transform data, make calculations, or perform custom logic.', + }), +); + +/** + * Create a new HTTP request node + */ +export const CreateHttpNode = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to add the node to', + }), + ), + name: NodeName, + httpId: UlidId.pipe( + Schema.annotations({ + description: 'The ULID of the HTTP request definition to use', + }), + ), + position: OptionalPosition, +}).pipe( + Schema.annotations({ + identifier: 'createHttpNode', + title: 'Create HTTP Node', + description: 'Create a new HTTP request node that makes an API call.', + }), +); + +/** + * Create a condition (if/else) node + */ +export const CreateConditionNode = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to add the node to', + }), + ), + name: NodeName, + condition: ConditionExpression, + position: OptionalPosition, +}).pipe( + Schema.annotations({ + identifier: 'createConditionNode', + title: 'Create Condition Node', + description: + 'Create a condition node that routes flow based on a boolean expression. Has THEN and ELSE output handles.', + }), +); + +/** + * Create a for-loop node with fixed iterations + */ +export const CreateForNode = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to add the node to', + }), + ), + name: NodeName, + iterations: Schema.Number.pipe( + Schema.int(), + Schema.positive(), + Schema.annotations({ + description: 'Number of iterations to perform', + }), + ), + condition: ConditionExpression.pipe( + Schema.annotations({ + description: + 'Optional condition to continue loop using expr-lang syntax (e.g., "i < 10"). Use == for equality (NOT ===)', + }), + ), + errorHandling: ErrorHandling, + position: OptionalPosition, +}).pipe( + Schema.annotations({ + identifier: 'createForNode', + title: 'Create For Loop Node', + description: 'Create a for-loop node that iterates a fixed number of times.', + }), +); + +/** + * Create a forEach loop node for iterating arrays/objects + */ +export const CreateForEachNode = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow to add the node to', + }), + ), + name: NodeName, + path: Schema.String.pipe( + Schema.annotations({ + description: 'Path to the array/object to iterate (e.g., "input.items")', + examples: ['input.items', 'data.users', 'response.results'], + }), + ), + condition: ConditionExpression.pipe( + Schema.annotations({ + description: + 'Optional condition to continue iteration using expr-lang syntax. Use == for equality (NOT ===)', + }), + ), + errorHandling: ErrorHandling, + position: OptionalPosition, +}).pipe( + Schema.annotations({ + identifier: 'createForEachNode', + title: 'Create ForEach Loop Node', + description: 'Create a forEach node that iterates over an array or object.', + }), +); + +// ============================================================================= +// Node Update Schemas +// ============================================================================= + +/** + * Update JavaScript code in a JS node + */ +export const UpdateNodeCode = Schema.Struct({ + nodeId: NodeId.pipe( + Schema.annotations({ + description: 'The ULID of the JS node to update', + }), + ), + code: JsCode, +}).pipe( + Schema.annotations({ + identifier: 'updateNodeCode', + title: 'Update Node Code', + description: 'Update the JavaScript code of a JS node.', + }), +); + +/** + * Update general node configuration (name, position) + */ +export const UpdateNodeConfig = Schema.Struct({ + nodeId: NodeId.pipe( + Schema.annotations({ + description: 'The ULID of the node to update', + }), + ), + name: Schema.optional(NodeName).pipe( + Schema.annotations({ + description: 'New display name (optional)', + }), + ), + position: OptionalPosition, +}).pipe( + Schema.annotations({ + identifier: 'updateNodeConfig', + title: 'Update Node Config', + description: 'Update general node properties like name or position.', + }), +); + +// ============================================================================= +// Edge (Connection) Schemas +// ============================================================================= + +/** + * Connect two nodes with an edge + */ +export const ConnectNodes = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow', + }), + ), + sourceId: NodeId.pipe( + Schema.annotations({ + description: 'The ULID of the source node', + }), + ), + targetId: NodeId.pipe( + Schema.annotations({ + description: 'The ULID of the target node', + }), + ), + sourceHandle: Schema.optional(SourceHandle).pipe( + Schema.annotations({ + description: + 'Output handle for branching nodes ONLY. Use "then"/"else" for Condition nodes, "loop"/"then" for For/ForEach nodes. OMIT this parameter for Manual Start, JS, and HTTP nodes.', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'connectNodes', + title: 'Connect Nodes', + description: + 'Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then).', + }), +); + +/** + * Remove an edge connection + */ +export const DisconnectNodes = Schema.Struct({ + edgeId: EdgeId.pipe( + Schema.annotations({ + description: 'The ULID of the edge to remove', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'disconnectNodes', + title: 'Disconnect Nodes', + description: 'Remove an edge connection between nodes.', + }), +); + +/** + * Delete a node from the workflow + */ +export const DeleteNode = Schema.Struct({ + nodeId: NodeId.pipe( + Schema.annotations({ + description: 'The ULID of the node to delete', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'deleteNode', + title: 'Delete Node', + description: 'Delete a node from the workflow. Also removes all connected edges.', + }), +); + +// ============================================================================= +// Variable Schemas +// ============================================================================= + +/** + * Create a new workflow variable + */ +export const CreateVariable = Schema.Struct({ + flowId: FlowId.pipe( + Schema.annotations({ + description: 'The ULID of the workflow', + }), + ), + key: Schema.String.pipe( + Schema.minLength(1), + Schema.annotations({ + description: 'Variable name (used to reference it in expressions)', + examples: ['apiKey', 'baseUrl', 'maxRetries'], + }), + ), + value: Schema.String.pipe( + Schema.annotations({ + description: 'Variable value', + }), + ), + description: Schema.optional(Schema.String).pipe( + Schema.annotations({ + description: 'Description of what the variable is for (optional)', + }), + ), + enabled: Schema.optional(Schema.Boolean).pipe( + Schema.annotations({ + description: 'Whether the variable is active (default: true)', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'createVariable', + title: 'Create Variable', + description: 'Create a new workflow variable that can be referenced in node expressions.', + }), +); + +/** + * Update an existing workflow variable + */ +export const UpdateVariable = Schema.Struct({ + flowVariableId: UlidId.pipe( + Schema.annotations({ + description: 'The ULID of the variable to update', + }), + ), + key: Schema.optional(Schema.String).pipe( + Schema.annotations({ + description: 'New variable name (optional)', + }), + ), + value: Schema.optional(Schema.String).pipe( + Schema.annotations({ + description: 'New variable value (optional)', + }), + ), + description: Schema.optional(Schema.String).pipe( + Schema.annotations({ + description: 'New description (optional)', + }), + ), + enabled: Schema.optional(Schema.Boolean).pipe( + Schema.annotations({ + description: 'Whether the variable is active (optional)', + }), + ), +}).pipe( + Schema.annotations({ + identifier: 'updateVariable', + title: 'Update Variable', + description: 'Update an existing workflow variable.', + }), +); + +// ============================================================================= +// Exports +// ============================================================================= + +export const MutationSchemas = { + CreateJsNode, + CreateHttpNode, + CreateConditionNode, + CreateForNode, + CreateForEachNode, + UpdateNodeCode, + UpdateNodeConfig, + ConnectNodes, + DisconnectNodes, + DeleteNode, + CreateVariable, + UpdateVariable, +} as const; + +export type CreateJsNode = typeof CreateJsNode.Type; +export type CreateHttpNode = typeof CreateHttpNode.Type; +export type CreateConditionNode = typeof CreateConditionNode.Type; +export type CreateForNode = typeof CreateForNode.Type; +export type CreateForEachNode = typeof CreateForEachNode.Type; +export type UpdateNodeCode = typeof UpdateNodeCode.Type; +export type UpdateNodeConfig = typeof UpdateNodeConfig.Type; +export type ConnectNodes = typeof ConnectNodes.Type; +export type DisconnectNodes = typeof DisconnectNodes.Type; +export type DeleteNode = typeof DeleteNode.Type; +export type CreateVariable = typeof CreateVariable.Type; +export type UpdateVariable = typeof UpdateVariable.Type; 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 From 1208a278a6d3de3180987b8cf1ee4f2aba535da4 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Wed, 21 Jan 2026 23:29:51 +0400 Subject: [PATCH 02/14] simplify schema generation: auto-generate arrays, remove boilerplate - Use Object.values().map() instead of manual listing in index.ts - Delete schemas-effect.ts (209 lines of unnecessary re-exports) - Delete test-effect-schemas.ts (development artifact) - Build validation map dynamically from EffectSchemas Adding a new tool now requires only 2 steps: 1. Add schema to MutationSchemas/ExplorationSchemas/ExecutionSchemas 2. Run pnpm generate:schemas --- packages/agent/src/tools/schemas-effect.ts | 209 ---------------- .../agent/src/tools/test-effect-schemas.ts | 79 ------- packages/spec/src/tools/index.ts | 223 ++++++------------ 3 files changed, 77 insertions(+), 434 deletions(-) delete mode 100644 packages/agent/src/tools/schemas-effect.ts delete mode 100644 packages/agent/src/tools/test-effect-schemas.ts diff --git a/packages/agent/src/tools/schemas-effect.ts b/packages/agent/src/tools/schemas-effect.ts deleted file mode 100644 index 90ec8aecb..000000000 --- a/packages/agent/src/tools/schemas-effect.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Integration with Effect Schema Tool Definitions - * - * This file demonstrates how to use the Effect Schema-based tool definitions - * from @the-dev-tools/spec instead of the hand-written JSON schemas. - * - * MIGRATION PATH: - * 1. Import from @the-dev-tools/spec/tools (dynamic generation) - * OR @the-dev-tools/spec/tools/schemas (pre-generated JSON) - * 2. Replace usage of local schemas with spec schemas - * 3. Delete local schema files once migration is complete - * - * BENEFITS: - * - Single source of truth in @the-dev-tools/spec - * - Type-safe schema definitions with runtime validation - * - Auto-generated JSON Schema for AI tool calling - * - Annotations (descriptions, examples) are code, not strings - * - When spec updates, regenerate schemas: `pnpm --filter @the-dev-tools/spec generate:schemas` - */ - -// ============================================================================= -// Option 1: Import pre-generated schemas (recommended for production) -// ============================================================================= -// These are generated at build time via `pnpm generate:schemas` - no runtime overhead -// Use this when you don't need runtime validation, just the JSON Schema for AI tools -// -// import { -// allToolSchemas, -// mutationSchemas, -// explorationSchemas, -// executionSchemas, -// createJsNodeSchema, -// } from '@the-dev-tools/spec/tools/schemas'; - -// ============================================================================= -// Option 2: Import Effect Schemas directly (for validation + generation) -// ============================================================================= -// These generate JSON Schema at runtime but also provide: -// - Runtime validation via Schema.decodeUnknown -// - Type inference via Schema.Type -// - Encoding/decoding transformations -// -import { - allToolSchemas, - createJsNodeSchema, - EffectSchemas, - executionSchemas, - explorationSchemas, - mutationSchemas, - type ToolDefinition, -} from '@the-dev-tools/spec/tools'; - -// ============================================================================= -// Re-export for backward compatibility -// ============================================================================= - -// Tool schema collections -export { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas }; - -// Individual mutation schemas -export { - connectNodesSchema, - createConditionNodeSchema, - createForEachNodeSchema, - createForNodeSchema, - createHttpNodeSchema, - createJsNodeSchema, - createVariableSchema, - deleteNodeSchema, - disconnectNodesSchema, - updateNodeCodeSchema, - updateNodeConfigSchema, - updateVariableSchema, -} from '@the-dev-tools/spec/tools'; - -// Individual exploration schemas -export { - getApiDocsSchema, - getExecutionHistorySchema, - getExecutionLogsSchema, - getNodeDetailsSchema, - getNodeTemplateSchema, - getWorkflowGraphSchema, - searchApiDocsSchema, - searchTemplatesSchema, -} from '@the-dev-tools/spec/tools'; - -// Individual execution schemas -export { - runWorkflowSchema, - stopWorkflowSchema, - validateWorkflowSchema, -} from '@the-dev-tools/spec/tools'; - -// ============================================================================= -// Type exports -// ============================================================================= - -export type { ToolDefinition }; - -// ============================================================================= -// Validation utilities using Effect Schema -// ============================================================================= - -import { Schema } from 'effect'; - -/** - * Validate tool input against the Effect Schema - * - * Example usage: - * ```typescript - * const result = validateToolInput('createJsNode', { - * flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', - * name: 'Transform Data', - * code: 'return { result: ctx.value * 2 };' - * }); - * - * if (result.success) { - * console.log('Valid input:', result.data); - * } else { - * console.error('Validation errors:', result.errors); - * } - * ``` - */ -export function validateToolInput( - toolName: string, - input: unknown, -): { success: true; data: unknown } | { success: false; errors: string[] } { - // Find the matching Effect Schema - const schemaMap: Record> = { - // Mutation - createJsNode: EffectSchemas.Mutation.CreateJsNode, - createHttpNode: EffectSchemas.Mutation.CreateHttpNode, - createConditionNode: EffectSchemas.Mutation.CreateConditionNode, - createForNode: EffectSchemas.Mutation.CreateForNode, - createForEachNode: EffectSchemas.Mutation.CreateForEachNode, - updateNodeCode: EffectSchemas.Mutation.UpdateNodeCode, - updateNodeConfig: EffectSchemas.Mutation.UpdateNodeConfig, - connectNodes: EffectSchemas.Mutation.ConnectNodes, - disconnectNodes: EffectSchemas.Mutation.DisconnectNodes, - deleteNode: EffectSchemas.Mutation.DeleteNode, - createVariable: EffectSchemas.Mutation.CreateVariable, - updateVariable: EffectSchemas.Mutation.UpdateVariable, - // Exploration - getWorkflowGraph: EffectSchemas.Exploration.GetWorkflowGraph, - getNodeDetails: EffectSchemas.Exploration.GetNodeDetails, - getNodeTemplate: EffectSchemas.Exploration.GetNodeTemplate, - searchTemplates: EffectSchemas.Exploration.SearchTemplates, - getExecutionHistory: EffectSchemas.Exploration.GetExecutionHistory, - getExecutionLogs: EffectSchemas.Exploration.GetExecutionLogs, - searchApiDocs: EffectSchemas.Exploration.SearchApiDocs, - getApiDocs: EffectSchemas.Exploration.GetApiDocs, - // Execution - runWorkflow: EffectSchemas.Execution.RunWorkflow, - stopWorkflow: EffectSchemas.Execution.StopWorkflow, - validateWorkflow: EffectSchemas.Execution.ValidateWorkflow, - }; - - 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'] }; - } -} - -// ============================================================================= -// Example: Using schemas with Claude/Anthropic API -// ============================================================================= - -/** - * Example of how to use the schemas with Claude's tool_use API - * - * ```typescript - * import Anthropic from '@anthropic-ai/sdk'; - * import { allToolSchemas } from '@the-dev-tools/spec/tools'; - * - * const client = new Anthropic(); - * - * const response = await client.messages.create({ - * model: 'claude-sonnet-4-20250514', - * max_tokens: 1024, - * tools: allToolSchemas.map(schema => ({ - * name: schema.name, - * description: schema.description, - * input_schema: schema.parameters, - * })), - * messages: [{ role: 'user', content: 'Create a JS node that doubles the input' }], - * }); - * ``` - */ - -// ============================================================================= -// Debug: Print schema to verify generation -// ============================================================================= - -if (import.meta.url === `file://${process.argv[1]}`) { - console.log('Example createJsNode schema:'); - console.log(JSON.stringify(createJsNodeSchema, null, 2)); - console.log('\nTotal schemas:', allToolSchemas.length); -} diff --git a/packages/agent/src/tools/test-effect-schemas.ts b/packages/agent/src/tools/test-effect-schemas.ts deleted file mode 100644 index 14a1cf097..000000000 --- a/packages/agent/src/tools/test-effect-schemas.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Test script to verify Effect Schema integration - * Run: pnpm tsx src/tools/test-effect-schemas.ts - */ - -import { Schema } from 'effect'; - -import { - allToolSchemas, - createJsNodeSchema, - EffectSchemas, - executionSchemas, - explorationSchemas, - mutationSchemas, -} from '@the-dev-tools/spec/tools'; - -console.log('='.repeat(60)); -console.log('Effect Schema Tool Definitions Test'); -console.log('='.repeat(60)); - -// Test 1: Count schemas -console.log('\n1. Schema counts:'); -console.log(` Mutation tools: ${mutationSchemas.length}`); -console.log(` Exploration tools: ${explorationSchemas.length}`); -console.log(` Execution tools: ${executionSchemas.length}`); -console.log(` Total: ${allToolSchemas.length}`); - -// Test 2: Verify createJsNode schema structure -console.log('\n2. createJsNode schema:'); -console.log(JSON.stringify(createJsNodeSchema, null, 2)); - -// Test 3: Validate input using Effect Schema -console.log('\n3. Input validation test:'); - -const validInput = { - flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', - name: 'Transform Data', - code: 'const result = ctx.value * 2; return { result };', -}; - -const invalidInput = { - flowId: 'not-a-valid-ulid', - name: '', // Empty name should fail minLength - code: 'return ctx;', -}; - -try { - const decoded = Schema.decodeUnknownSync(EffectSchemas.Mutation.CreateJsNode)(validInput); - console.log(' Valid input decoded successfully:', JSON.stringify(decoded)); -} catch (error) { - console.log(' Valid input failed (unexpected):', error); -} - -try { - const decoded = Schema.decodeUnknownSync(EffectSchemas.Mutation.CreateJsNode)(invalidInput); - console.log(' Invalid input decoded (unexpected):', decoded); -} catch (error) { - console.log(' Invalid input rejected (expected):', (error as Error).message.slice(0, 100) + '...'); -} - -// Test 4: Type inference -console.log('\n4. TypeScript type inference:'); -type CreateJsNodeInput = typeof EffectSchemas.Mutation.CreateJsNode.Type; -const typedInput: CreateJsNodeInput = { - flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', - name: 'My Node', - code: 'return {};', -}; -console.log(' Type-safe input:', typedInput); - -// Test 5: List all tool names -console.log('\n5. All tool names:'); -allToolSchemas.forEach((schema, i) => { - console.log(` ${i + 1}. ${schema.name}`); -}); - -console.log('\n' + '='.repeat(60)); -console.log('All tests passed!'); -console.log('='.repeat(60)); diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts index bdb75f2e4..f9423be1f 100644 --- a/packages/spec/src/tools/index.ts +++ b/packages/spec/src/tools/index.ts @@ -1,17 +1,14 @@ /** * Tool Schema Definitions using Effect Schema * - * This module provides type-safe tool definitions that can be: - * 1. Used directly as TypeScript types for validation - * 2. Converted to JSON Schema for AI tool calling (Claude, MCP, etc.) - * - * The schemas are the single source of truth for tool definitions. - * When spec changes, these schemas should be updated accordingly. + * Single source of truth for AI tool definitions. + * Adding a new tool: just add to MutationSchemas/ExplorationSchemas/ExecutionSchemas. + * Everything else is automatic. */ import { JSONSchema, Schema } from 'effect'; -// Re-export all schemas +// Re-export all schemas for direct use export * from './common.ts'; export * from './execution.ts'; export * from './exploration.ts'; @@ -26,9 +23,6 @@ import { MutationSchemas } from './mutation.ts'; // Tool Definition Type // ============================================================================= -/** - * Standard tool definition format for AI tool calling - */ export interface ToolDefinition { name: string; description: string; @@ -39,64 +33,43 @@ export interface ToolDefinition { // 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)); - } +/** 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; // Handle $ref if ('$ref' in record && typeof record.$ref === 'string') { - const refPath = record.$ref; - const defName = refPath.replace('#/$defs/', ''); + const defName = record.$ref.replace('#/$defs/', ''); const resolved = defs[defName]; if (resolved) { - // Merge any sibling properties (like description) with resolved ref const { $ref: _, ...rest } = record; - const resolvedObj = resolveRefs(resolved, defs) as Record; - return { ...resolvedObj, ...rest }; + return { ...(resolveRefs(resolved, defs) as Record), ...rest }; } } - // Handle allOf with single $ref (common pattern from Effect) + // Handle allOf with single $ref (common Effect pattern) 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; - const resolved = resolveRefs(first, defs) as Record; - return { ...resolved, ...rest }; + return { ...(resolveRefs(first, defs) as Record), ...rest }; } } // Recursively process all properties const result: Record = {}; for (const [key, value] of Object.entries(record)) { - if (key === '$defs' || key === '$schema') { - continue; // Skip $defs and $schema in output - } + 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 { - // Generate JSON Schema - Effect puts annotations in the $defs +/** 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; @@ -104,131 +77,89 @@ function schemaToToolDefinition( }; const defs = jsonSchema.$defs ?? {}; - - // Extract the definition name from $ref (e.g., "#/$defs/createJsNode" -> "createJsNode") - const refPath = jsonSchema.$ref ?? ''; - const defName = refPath.replace('#/$defs/', ''); + const defName = (jsonSchema.$ref ?? '').replace('#/$defs/', ''); const def = defs[defName] as { - title?: string; description?: string; type: string; properties: Record; required?: string[]; } | undefined; - // Get name from identifier (the key in $defs) and description from the definition - const name = defName || 'unknown'; - const description = def?.description ?? ''; - - // Resolve all $refs in properties to inline nested schemas - const resolvedProperties = def?.properties - ? resolveRefs(def.properties, defs) - : {}; - - // Build flattened parameters schema - const parameters = def - ? { - type: def.type, - properties: resolvedProperties, - required: def.required, - additionalProperties: false, - } - : jsonSchema; - return { - name, - description, - parameters, + name: defName || 'unknown', + description: def?.description ?? '', + parameters: def + ? { + type: def.type, + properties: resolveRefs(def.properties, defs), + required: def.required, + additionalProperties: false, + } + : jsonSchema, }; } // ============================================================================= -// Generated Tool Definitions -// ============================================================================= - -// Mutation tools -export const createJsNodeSchema = schemaToToolDefinition(MutationSchemas.CreateJsNode); -export const createHttpNodeSchema = schemaToToolDefinition(MutationSchemas.CreateHttpNode); -export const createConditionNodeSchema = schemaToToolDefinition(MutationSchemas.CreateConditionNode); -export const createForNodeSchema = schemaToToolDefinition(MutationSchemas.CreateForNode); -export const createForEachNodeSchema = schemaToToolDefinition(MutationSchemas.CreateForEachNode); -export const updateNodeCodeSchema = schemaToToolDefinition(MutationSchemas.UpdateNodeCode); -export const updateNodeConfigSchema = schemaToToolDefinition(MutationSchemas.UpdateNodeConfig); -export const connectNodesSchema = schemaToToolDefinition(MutationSchemas.ConnectNodes); -export const disconnectNodesSchema = schemaToToolDefinition(MutationSchemas.DisconnectNodes); -export const deleteNodeSchema = schemaToToolDefinition(MutationSchemas.DeleteNode); -export const createVariableSchema = schemaToToolDefinition(MutationSchemas.CreateVariable); -export const updateVariableSchema = schemaToToolDefinition(MutationSchemas.UpdateVariable); - -// Exploration tools -export const getWorkflowGraphSchema = schemaToToolDefinition(ExplorationSchemas.GetWorkflowGraph); -export const getNodeDetailsSchema = schemaToToolDefinition(ExplorationSchemas.GetNodeDetails); -export const getNodeTemplateSchema = schemaToToolDefinition(ExplorationSchemas.GetNodeTemplate); -export const searchTemplatesSchema = schemaToToolDefinition(ExplorationSchemas.SearchTemplates); -export const getExecutionHistorySchema = schemaToToolDefinition(ExplorationSchemas.GetExecutionHistory); -export const getExecutionLogsSchema = schemaToToolDefinition(ExplorationSchemas.GetExecutionLogs); -export const searchApiDocsSchema = schemaToToolDefinition(ExplorationSchemas.SearchApiDocs); -export const getApiDocsSchema = schemaToToolDefinition(ExplorationSchemas.GetApiDocs); - -// Execution tools -export const runWorkflowSchema = schemaToToolDefinition(ExecutionSchemas.RunWorkflow); -export const stopWorkflowSchema = schemaToToolDefinition(ExecutionSchemas.StopWorkflow); -export const validateWorkflowSchema = schemaToToolDefinition(ExecutionSchemas.ValidateWorkflow); - -// ============================================================================= -// Grouped Exports +// Auto-generated Tool Definitions (no manual listing needed) // ============================================================================= -export const mutationSchemas = [ - createJsNodeSchema, - createHttpNodeSchema, - createConditionNodeSchema, - createForNodeSchema, - createForEachNodeSchema, - updateNodeCodeSchema, - updateNodeConfigSchema, - connectNodesSchema, - disconnectNodesSchema, - deleteNodeSchema, - createVariableSchema, - updateVariableSchema, -]; - -export const explorationSchemas = [ - getWorkflowGraphSchema, - getNodeDetailsSchema, - getNodeTemplateSchema, - searchTemplatesSchema, - getExecutionHistorySchema, - getExecutionLogsSchema, - searchApiDocsSchema, - getApiDocsSchema, -]; - -export const executionSchemas = [ - runWorkflowSchema, - stopWorkflowSchema, - validateWorkflowSchema, -]; +export const mutationSchemas = Object.values(MutationSchemas).map(schemaToToolDefinition); +export const explorationSchemas = Object.values(ExplorationSchemas).map(schemaToToolDefinition); +export const executionSchemas = Object.values(ExecutionSchemas).map(schemaToToolDefinition); -/** - * All tool schemas combined - ready for AI tool calling - */ -export const allToolSchemas = [ - ...explorationSchemas, - ...mutationSchemas, - ...executionSchemas, -]; +/** All tool schemas combined - ready for AI tool calling */ +export const allToolSchemas = [...explorationSchemas, ...mutationSchemas, ...executionSchemas]; // ============================================================================= -// Effect Schemas (for validation/parsing) +// Effect Schemas (for runtime validation) // ============================================================================= -/** - * Raw Effect Schema objects for use with Effect's decode/encode - */ export const EffectSchemas = { Mutation: MutationSchemas, Exploration: ExplorationSchemas, Execution: ExecutionSchemas, } as const; + +// ============================================================================= +// Validation Helper +// ============================================================================= + +// Build schema map dynamically - no manual maintenance needed +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 + * + * @example + * const result = validateToolInput('createJsNode', { + * flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + * name: 'Transform Data', + * code: 'return { result: ctx.value * 2 };' + * }); + */ +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'] }; + } +} From b2520c5625e75e47c9eec550e02e9b90d944aaee Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Thu, 22 Jan 2026 00:50:33 +0400 Subject: [PATCH 03/14] Add TypeSpec AI Tools emitter to generate Effect Schemas Replace hand-written Effect Schema definitions with TypeSpec-generated code. This introduces a new emitter that transforms @aiTool decorated models into Effect Schema TypeScript files. New emitter infrastructure (tools/spec-lib/src/ai-tools/): - lib.ts: @aiTool decorator and ToolCategory enum - main.tsp: TypeSpec declarations - emitter.tsx: Alloy.js emitter generating Effect Schemas - index.ts: Module exports Tool definitions moved to TypeSpec (packages/spec/api/flow.tsp): - 12 Mutation tools (CreateJsNode, ConnectNodes, etc.) - 8 Exploration tools (GetWorkflowGraph, SearchApiDocs, etc.) - 3 Execution tools (RunWorkflow, StopWorkflow, ValidateWorkflow) The emitter generates code that imports shared schemas from common.ts and produces the same JSON Schema output as the hand-written files. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 237 +++++++++++++++ packages/spec/api/main.tsp | 1 + packages/spec/package.json | 6 +- packages/spec/src/tools/execution.ts | 78 ----- packages/spec/src/tools/exploration.ts | 241 --------------- packages/spec/src/tools/index.ts | 18 +- packages/spec/src/tools/mutation.ts | 383 ------------------------ packages/spec/tspconfig.yaml | 1 + tools/spec-lib/package.json | 5 + tools/spec-lib/src/ai-tools/emitter.tsx | 379 +++++++++++++++++++++++ tools/spec-lib/src/ai-tools/index.ts | 2 + tools/spec-lib/src/ai-tools/lib.ts | 38 +++ tools/spec-lib/src/ai-tools/main.tsp | 17 ++ 13 files changed, 692 insertions(+), 714 deletions(-) delete mode 100644 packages/spec/src/tools/execution.ts delete mode 100644 packages/spec/src/tools/exploration.ts delete mode 100644 packages/spec/src/tools/mutation.ts create mode 100644 tools/spec-lib/src/ai-tools/emitter.tsx create mode 100644 tools/spec-lib/src/ai-tools/index.ts create mode 100644 tools/spec-lib/src/ai-tools/lib.ts create mode 100644 tools/spec-lib/src/ai-tools/main.tsp diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 02181ff0c..34e83a03a 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -129,6 +129,243 @@ model NodeJs { code: string; } +// ============================================================================= +// AI Tool Definitions - Mutation Tools +// ============================================================================= + +@doc("Create a new JavaScript node in the workflow. JS nodes can transform data, make calculations, or perform custom logic.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create JavaScript Node" }) +model CreateJsNode { + @doc("The ULID of the workflow to add the node to") + flowId: Id; + name: string; + code: string; + position?: Position; +} + +@doc("Create a new HTTP request node that makes an API call.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create HTTP Node" }) +model CreateHttpNode { + @doc("The ULID of the workflow to add the node to") + flowId: Id; + name: string; + @doc("The ULID of the HTTP request definition to use") + httpId: Id; + position?: Position; +} + +@doc("Create a condition node that routes flow based on a boolean expression. Has THEN and ELSE output handles.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create Condition Node" }) +model CreateConditionNode { + @doc("The ULID of the workflow to add the node to") + flowId: Id; + name: string; + condition: string; + position?: Position; +} + +@doc("Create a for-loop node that iterates a fixed number of times.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create For Loop Node" }) +model CreateForNode { + @doc("The ULID of the workflow to add the node to") + flowId: Id; + name: string; + @doc("Number of iterations to perform") + iterations: int32; + @doc("Optional condition to continue loop using expr-lang syntax (e.g., \"i < 10\"). Use == for equality (NOT ===)") + condition: string; + errorHandling: ErrorHandling; + position?: Position; +} + +@doc("Create a forEach node that iterates over an array or object.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create ForEach Loop Node" }) +model CreateForEachNode { + @doc("The ULID of the workflow to add the node to") + flowId: Id; + name: string; + @doc("Path to the array/object to iterate (e.g., \"input.items\")") + path: string; + @doc("Optional condition to continue iteration using expr-lang syntax. Use == for equality (NOT ===)") + condition: string; + errorHandling: ErrorHandling; + position?: Position; +} + +@doc("Update the JavaScript code of a JS node.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Update Node Code" }) +model UpdateNodeCode { + @doc("The ULID of the JS node to update") + nodeId: Id; + code: string; +} + +@doc("Update general node properties like name or position.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Update Node Config" }) +model UpdateNodeConfig { + @doc("The ULID of the node to update") + nodeId: Id; + @doc("New display name (optional)") + name?: string; + position?: Position; +} + +@doc("Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then).") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Connect Nodes" }) +model ConnectNodes { + @doc("The ULID of the workflow") + flowId: Id; + @doc("The ULID of the source node") + sourceId: Id; + @doc("The ULID of the target node") + targetId: Id; + @doc("Output handle for branching nodes ONLY. Use \"then\"/\"else\" for Condition nodes, \"loop\"/\"then\" for For/ForEach nodes. OMIT this parameter for Manual Start, JS, and HTTP nodes.") + sourceHandle?: HandleKind; +} + +@doc("Remove an edge connection between nodes.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Disconnect Nodes" }) +model DisconnectNodes { + @doc("The ULID of the edge to remove") + edgeId: Id; +} + +@doc("Delete a node from the workflow. Also removes all connected edges.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Delete Node" }) +model DeleteNode { + @doc("The ULID of the node to delete") + nodeId: Id; +} + +@doc("Create a new workflow variable that can be referenced in node expressions.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create Variable" }) +model CreateVariable { + @doc("The ULID of the workflow") + flowId: Id; + @doc("Variable name (used to reference it in expressions)") + key: string; + @doc("Variable value") + value: string; + @doc("Description of what the variable is for (optional)") + description?: string; + @doc("Whether the variable is active (default: true)") + enabled?: boolean; +} + +@doc("Update an existing workflow variable.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Update Variable" }) +model UpdateVariable { + @doc("The ULID of the variable to update") + flowVariableId: Id; + @doc("New variable name (optional)") + key?: string; + @doc("New variable value (optional)") + value?: string; + @doc("New description (optional)") + description?: string; + @doc("Whether the variable is active (optional)") + enabled?: boolean; +} + +// ============================================================================= +// AI Tool Definitions - Exploration Tools +// ============================================================================= + +@doc("Get the complete workflow graph including all nodes and edges. Use this to understand the current structure of the workflow.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Workflow Graph" }) +model GetWorkflowGraph { + @doc("The ULID of the workflow to retrieve") + flowId: Id; +} + +@doc("Get detailed information about a specific node including its configuration, code (for JS nodes), and connections.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Node Details" }) +model GetNodeDetails { + @doc("The ULID of the node to inspect") + nodeId: Id; +} + +@doc("Read a markdown template that provides guidance on implementing a specific type of node or pattern.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Node Template" }) +model GetNodeTemplate { + @doc("The name of the template to retrieve (e.g., \"http-aggregator\", \"js-transformer\")") + templateName: string; +} + +@doc("Search for available templates by keyword or pattern.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Search Templates" }) +model SearchTemplates { + @doc("Search query to find relevant templates") + query: string; +} + +@doc("Get the history of past workflow executions.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Execution History" }) +model GetExecutionHistory { + @doc("The ULID of the workflow") + flowId: Id; + @doc("Maximum number of executions to return (default: 10)") + limit?: int32; +} + +@doc("Get the latest execution logs. Returns only the most recent execution per node to avoid showing full history.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Execution Logs" }) +model GetExecutionLogs { + @doc("Filter to only show executions for nodes in this workflow") + flowId?: Id; + @doc("Maximum number of node executions to return (default: 10)") + limit?: int32; + @doc("Optional: specific execution ID to get logs for") + executionId?: Id; +} + +@doc("Search for API documentation by name, description, or keywords. Returns lightweight metadata for matching APIs. Use getApiDocs to load full documentation for a specific API.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Search API Docs" }) +model SearchApiDocs { + @doc("Search query - API name, description keywords, or use case (e.g., \"send message\", \"payment\", \"telegram bot\")") + query: string; + @doc("Category of the API") + category?: string; + @doc("Maximum results to return (default: 5)") + limit?: int32; +} + +@doc("Load full documentation for a specific API. Call this after searchApiDocs to get complete endpoint details, authentication info, and examples.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get API Docs" }) +model GetApiDocs { + @doc("API identifier from search results (e.g., \"slack\", \"stripe\", \"telegram\")") + apiId: string; + @doc("Force refresh from source, bypassing cache") + forceRefresh?: boolean; + @doc("Optional filter to focus on specific endpoint (e.g., \"chat.postMessage\", \"sendMessage\")") + endpoint?: string; +} + +// ============================================================================= +// AI Tool Definitions - Execution Tools +// ============================================================================= + +@doc("Execute the workflow from the start node. Returns execution status.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Run Workflow" }) +model RunWorkflow { + @doc("The ULID of the workflow to run") + flowId: Id; +} + +@doc("Stop a running workflow execution.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Stop Workflow" }) +model StopWorkflow { + @doc("The ULID of the workflow to stop") + flowId: Id; +} + +@doc("Validate the workflow for errors, missing connections, or configuration issues. Use this before running to catch problems.") +@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Validate Workflow" }) +model ValidateWorkflow { + @doc("The ULID of the workflow to validate") + flowId: Id; +} + @TanStackDB.collection(#{ isReadOnly: true }) model NodeExecution { @primaryKey nodeExecutionId: Id; diff --git a/packages/spec/api/main.tsp b/packages/spec/api/main.tsp index 6062bcea7..9cbc94666 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"; diff --git a/packages/spec/package.json b/packages/spec/package.json index 1a499ca28..e9e46a0d6 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -14,9 +14,9 @@ "./tools": "./src/tools/index.ts", "./tools/schemas": "./dist/tools/schemas.ts", "./tools/common": "./src/tools/common.ts", - "./tools/mutation": "./src/tools/mutation.ts", - "./tools/exploration": "./src/tools/exploration.ts", - "./tools/execution": "./src/tools/execution.ts" + "./tools/mutation": "./dist/ai-tools/v1/mutation.ts", + "./tools/exploration": "./dist/ai-tools/v1/exploration.ts", + "./tools/execution": "./dist/ai-tools/v1/execution.ts" }, "scripts": { "generate:schemas": "tsx src/tools/generate.ts" diff --git a/packages/spec/src/tools/execution.ts b/packages/spec/src/tools/execution.ts deleted file mode 100644 index fd7dd812c..000000000 --- a/packages/spec/src/tools/execution.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Effect Schema definitions for execution (control) tools - * These tools manage workflow execution: run, stop, validate - */ - -import { Schema } from 'effect'; - -import { FlowId } from './common.ts'; - -// ============================================================================= -// Execution Control Schemas -// ============================================================================= - -/** - * Run a workflow from the start - */ -export const RunWorkflow = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to run', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'runWorkflow', - title: 'Run Workflow', - description: 'Execute the workflow from the start node. Returns execution status.', - }), -); - -/** - * Stop a running workflow - */ -export const StopWorkflow = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to stop', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'stopWorkflow', - title: 'Stop Workflow', - description: 'Stop a running workflow execution.', - }), -); - -/** - * Validate workflow before running - */ -export const ValidateWorkflow = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to validate', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'validateWorkflow', - title: 'Validate Workflow', - description: - 'Validate the workflow for errors, missing connections, or configuration issues. Use this before running to catch problems.', - }), -); - -// ============================================================================= -// Exports -// ============================================================================= - -export const ExecutionSchemas = { - RunWorkflow, - StopWorkflow, - ValidateWorkflow, -} as const; - -export type RunWorkflow = typeof RunWorkflow.Type; -export type StopWorkflow = typeof StopWorkflow.Type; -export type ValidateWorkflow = typeof ValidateWorkflow.Type; diff --git a/packages/spec/src/tools/exploration.ts b/packages/spec/src/tools/exploration.ts deleted file mode 100644 index 2756fce4e..000000000 --- a/packages/spec/src/tools/exploration.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Effect Schema definitions for exploration (read-only) tools - * These tools retrieve information without modifying state - */ - -import { Schema } from 'effect'; - -import { ApiCategory, FlowId, NodeId, UlidId } from './common.ts'; - -// ============================================================================= -// Workflow Graph Schemas -// ============================================================================= - -/** - * Get complete workflow structure - */ -export const GetWorkflowGraph = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to retrieve', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'getWorkflowGraph', - title: 'Get Workflow Graph', - description: - 'Get the complete workflow graph including all nodes and edges. Use this to understand the current structure of the workflow.', - }), -); - -/** - * Get detailed information about a specific node - */ -export const GetNodeDetails = Schema.Struct({ - nodeId: NodeId.pipe( - Schema.annotations({ - description: 'The ULID of the node to inspect', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'getNodeDetails', - title: 'Get Node Details', - description: - 'Get detailed information about a specific node including its configuration, code (for JS nodes), and connections.', - }), -); - -// ============================================================================= -// Template Schemas -// ============================================================================= - -/** - * Get a specific node template by name - */ -export const GetNodeTemplate = Schema.Struct({ - templateName: Schema.String.pipe( - Schema.annotations({ - description: - 'The name of the template to retrieve (e.g., "http-aggregator", "js-transformer")', - examples: ['http-aggregator', 'js-transformer', 'conditional-router', 'data-validator'], - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'getNodeTemplate', - title: 'Get Node Template', - description: - 'Read a markdown template that provides guidance on implementing a specific type of node or pattern.', - }), -); - -/** - * Search available templates - */ -export const SearchTemplates = Schema.Struct({ - query: Schema.String.pipe( - Schema.annotations({ - description: 'Search query to find relevant templates', - examples: ['http', 'transform', 'loop', 'condition'], - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'searchTemplates', - title: 'Search Templates', - description: 'Search for available templates by keyword or pattern.', - }), -); - -// ============================================================================= -// Execution History Schemas -// ============================================================================= - -/** - * Get workflow execution history - */ -export const GetExecutionHistory = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow', - }), - ), - limit: Schema.optional( - Schema.Number.pipe( - Schema.int(), - Schema.positive(), - Schema.annotations({ - description: 'Maximum number of executions to return (default: 10)', - }), - ), - ), -}).pipe( - Schema.annotations({ - identifier: 'getExecutionHistory', - title: 'Get Execution History', - description: 'Get the history of past workflow executions.', - }), -); - -/** - * Get execution logs for debugging - */ -export const GetExecutionLogs = Schema.Struct({ - flowId: Schema.optional(FlowId).pipe( - Schema.annotations({ - description: 'Filter to only show executions for nodes in this workflow', - }), - ), - limit: Schema.optional( - Schema.Number.pipe( - Schema.int(), - Schema.positive(), - Schema.annotations({ - description: 'Maximum number of node executions to return (default: 10)', - }), - ), - ), - executionId: Schema.optional(UlidId).pipe( - Schema.annotations({ - description: 'Optional: specific execution ID to get logs for', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'getExecutionLogs', - title: 'Get Execution Logs', - description: - 'Get the latest execution logs. Returns only the most recent execution per node to avoid showing full history.', - }), -); - -// ============================================================================= -// API Documentation Schemas -// ============================================================================= - -/** - * Search for API documentation - */ -export const SearchApiDocs = Schema.Struct({ - query: Schema.String.pipe( - Schema.annotations({ - description: - 'Search query - API name, description keywords, or use case (e.g., "send message", "payment", "telegram bot")', - examples: ['send message', 'payment processing', 'telegram bot', 'slack notification'], - }), - ), - category: Schema.optional(ApiCategory), - limit: Schema.optional( - Schema.Number.pipe( - Schema.int(), - Schema.positive(), - Schema.annotations({ - description: 'Maximum results to return (default: 5)', - }), - ), - ), -}).pipe( - Schema.annotations({ - identifier: 'searchApiDocs', - title: 'Search API Docs', - description: - 'Search for API documentation by name, description, or keywords. Returns lightweight metadata for matching APIs. Use getApiDocs to load full documentation for a specific API.', - }), -); - -/** - * Get full API documentation - */ -export const GetApiDocs = Schema.Struct({ - apiId: Schema.String.pipe( - Schema.annotations({ - description: 'API identifier from search results (e.g., "slack", "stripe", "telegram")', - examples: ['slack', 'stripe', 'telegram', 'github', 'notion'], - }), - ), - forceRefresh: Schema.optional(Schema.Boolean).pipe( - Schema.annotations({ - description: 'Force refresh from source, bypassing cache', - }), - ), - endpoint: Schema.optional(Schema.String).pipe( - Schema.annotations({ - description: - 'Optional filter to focus on specific endpoint (e.g., "chat.postMessage", "sendMessage")', - examples: ['chat.postMessage', 'sendMessage', 'charges.create'], - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'getApiDocs', - title: 'Get API Docs', - description: - 'Load full documentation for a specific API. Call this after searchApiDocs to get complete endpoint details, authentication info, and examples.', - }), -); - -// ============================================================================= -// Exports -// ============================================================================= - -export const ExplorationSchemas = { - GetWorkflowGraph, - GetNodeDetails, - GetNodeTemplate, - SearchTemplates, - GetExecutionHistory, - GetExecutionLogs, - SearchApiDocs, - GetApiDocs, -} as const; - -export type GetWorkflowGraph = typeof GetWorkflowGraph.Type; -export type GetNodeDetails = typeof GetNodeDetails.Type; -export type GetNodeTemplate = typeof GetNodeTemplate.Type; -export type SearchTemplates = typeof SearchTemplates.Type; -export type GetExecutionHistory = typeof GetExecutionHistory.Type; -export type GetExecutionLogs = typeof GetExecutionLogs.Type; -export type SearchApiDocs = typeof SearchApiDocs.Type; -export type GetApiDocs = typeof GetApiDocs.Type; diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts index f9423be1f..16f324fa9 100644 --- a/packages/spec/src/tools/index.ts +++ b/packages/spec/src/tools/index.ts @@ -8,16 +8,16 @@ import { JSONSchema, Schema } from 'effect'; -// Re-export all schemas for direct use +// Re-export common schemas and generated tool schemas export * from './common.ts'; -export * from './execution.ts'; -export * from './exploration.ts'; -export * from './mutation.ts'; - -// Import schema groups -import { ExecutionSchemas } from './execution.ts'; -import { ExplorationSchemas } from './exploration.ts'; -import { MutationSchemas } from './mutation.ts'; +export * from '../../dist/ai-tools/v1/execution.ts'; +export * from '../../dist/ai-tools/v1/exploration.ts'; +export * from '../../dist/ai-tools/v1/mutation.ts'; + +// Import schema groups from generated files +import { ExecutionSchemas } from '../../dist/ai-tools/v1/execution.ts'; +import { ExplorationSchemas } from '../../dist/ai-tools/v1/exploration.ts'; +import { MutationSchemas } from '../../dist/ai-tools/v1/mutation.ts'; // ============================================================================= // Tool Definition Type diff --git a/packages/spec/src/tools/mutation.ts b/packages/spec/src/tools/mutation.ts deleted file mode 100644 index dc7018287..000000000 --- a/packages/spec/src/tools/mutation.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * Effect Schema definitions for mutation (write) tools - * These tools modify workflow state: create nodes, update configs, manage connections - */ - -import { Schema } from 'effect'; - -import { - ConditionExpression, - EdgeId, - ErrorHandling, - FlowId, - JsCode, - NodeId, - NodeName, - OptionalPosition, - SourceHandle, - UlidId, -} from './common.ts'; - -// ============================================================================= -// Node Creation Schemas -// ============================================================================= - -/** - * Create a new JavaScript node in the workflow - */ -export const CreateJsNode = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to add the node to', - }), - ), - name: NodeName, - code: JsCode, - position: OptionalPosition, -}).pipe( - Schema.annotations({ - identifier: 'createJsNode', - title: 'Create JavaScript Node', - description: - 'Create a new JavaScript node in the workflow. JS nodes can transform data, make calculations, or perform custom logic.', - }), -); - -/** - * Create a new HTTP request node - */ -export const CreateHttpNode = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to add the node to', - }), - ), - name: NodeName, - httpId: UlidId.pipe( - Schema.annotations({ - description: 'The ULID of the HTTP request definition to use', - }), - ), - position: OptionalPosition, -}).pipe( - Schema.annotations({ - identifier: 'createHttpNode', - title: 'Create HTTP Node', - description: 'Create a new HTTP request node that makes an API call.', - }), -); - -/** - * Create a condition (if/else) node - */ -export const CreateConditionNode = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to add the node to', - }), - ), - name: NodeName, - condition: ConditionExpression, - position: OptionalPosition, -}).pipe( - Schema.annotations({ - identifier: 'createConditionNode', - title: 'Create Condition Node', - description: - 'Create a condition node that routes flow based on a boolean expression. Has THEN and ELSE output handles.', - }), -); - -/** - * Create a for-loop node with fixed iterations - */ -export const CreateForNode = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to add the node to', - }), - ), - name: NodeName, - iterations: Schema.Number.pipe( - Schema.int(), - Schema.positive(), - Schema.annotations({ - description: 'Number of iterations to perform', - }), - ), - condition: ConditionExpression.pipe( - Schema.annotations({ - description: - 'Optional condition to continue loop using expr-lang syntax (e.g., "i < 10"). Use == for equality (NOT ===)', - }), - ), - errorHandling: ErrorHandling, - position: OptionalPosition, -}).pipe( - Schema.annotations({ - identifier: 'createForNode', - title: 'Create For Loop Node', - description: 'Create a for-loop node that iterates a fixed number of times.', - }), -); - -/** - * Create a forEach loop node for iterating arrays/objects - */ -export const CreateForEachNode = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow to add the node to', - }), - ), - name: NodeName, - path: Schema.String.pipe( - Schema.annotations({ - description: 'Path to the array/object to iterate (e.g., "input.items")', - examples: ['input.items', 'data.users', 'response.results'], - }), - ), - condition: ConditionExpression.pipe( - Schema.annotations({ - description: - 'Optional condition to continue iteration using expr-lang syntax. Use == for equality (NOT ===)', - }), - ), - errorHandling: ErrorHandling, - position: OptionalPosition, -}).pipe( - Schema.annotations({ - identifier: 'createForEachNode', - title: 'Create ForEach Loop Node', - description: 'Create a forEach node that iterates over an array or object.', - }), -); - -// ============================================================================= -// Node Update Schemas -// ============================================================================= - -/** - * Update JavaScript code in a JS node - */ -export const UpdateNodeCode = Schema.Struct({ - nodeId: NodeId.pipe( - Schema.annotations({ - description: 'The ULID of the JS node to update', - }), - ), - code: JsCode, -}).pipe( - Schema.annotations({ - identifier: 'updateNodeCode', - title: 'Update Node Code', - description: 'Update the JavaScript code of a JS node.', - }), -); - -/** - * Update general node configuration (name, position) - */ -export const UpdateNodeConfig = Schema.Struct({ - nodeId: NodeId.pipe( - Schema.annotations({ - description: 'The ULID of the node to update', - }), - ), - name: Schema.optional(NodeName).pipe( - Schema.annotations({ - description: 'New display name (optional)', - }), - ), - position: OptionalPosition, -}).pipe( - Schema.annotations({ - identifier: 'updateNodeConfig', - title: 'Update Node Config', - description: 'Update general node properties like name or position.', - }), -); - -// ============================================================================= -// Edge (Connection) Schemas -// ============================================================================= - -/** - * Connect two nodes with an edge - */ -export const ConnectNodes = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow', - }), - ), - sourceId: NodeId.pipe( - Schema.annotations({ - description: 'The ULID of the source node', - }), - ), - targetId: NodeId.pipe( - Schema.annotations({ - description: 'The ULID of the target node', - }), - ), - sourceHandle: Schema.optional(SourceHandle).pipe( - Schema.annotations({ - description: - 'Output handle for branching nodes ONLY. Use "then"/"else" for Condition nodes, "loop"/"then" for For/ForEach nodes. OMIT this parameter for Manual Start, JS, and HTTP nodes.', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'connectNodes', - title: 'Connect Nodes', - description: - 'Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then).', - }), -); - -/** - * Remove an edge connection - */ -export const DisconnectNodes = Schema.Struct({ - edgeId: EdgeId.pipe( - Schema.annotations({ - description: 'The ULID of the edge to remove', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'disconnectNodes', - title: 'Disconnect Nodes', - description: 'Remove an edge connection between nodes.', - }), -); - -/** - * Delete a node from the workflow - */ -export const DeleteNode = Schema.Struct({ - nodeId: NodeId.pipe( - Schema.annotations({ - description: 'The ULID of the node to delete', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'deleteNode', - title: 'Delete Node', - description: 'Delete a node from the workflow. Also removes all connected edges.', - }), -); - -// ============================================================================= -// Variable Schemas -// ============================================================================= - -/** - * Create a new workflow variable - */ -export const CreateVariable = Schema.Struct({ - flowId: FlowId.pipe( - Schema.annotations({ - description: 'The ULID of the workflow', - }), - ), - key: Schema.String.pipe( - Schema.minLength(1), - Schema.annotations({ - description: 'Variable name (used to reference it in expressions)', - examples: ['apiKey', 'baseUrl', 'maxRetries'], - }), - ), - value: Schema.String.pipe( - Schema.annotations({ - description: 'Variable value', - }), - ), - description: Schema.optional(Schema.String).pipe( - Schema.annotations({ - description: 'Description of what the variable is for (optional)', - }), - ), - enabled: Schema.optional(Schema.Boolean).pipe( - Schema.annotations({ - description: 'Whether the variable is active (default: true)', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'createVariable', - title: 'Create Variable', - description: 'Create a new workflow variable that can be referenced in node expressions.', - }), -); - -/** - * Update an existing workflow variable - */ -export const UpdateVariable = Schema.Struct({ - flowVariableId: UlidId.pipe( - Schema.annotations({ - description: 'The ULID of the variable to update', - }), - ), - key: Schema.optional(Schema.String).pipe( - Schema.annotations({ - description: 'New variable name (optional)', - }), - ), - value: Schema.optional(Schema.String).pipe( - Schema.annotations({ - description: 'New variable value (optional)', - }), - ), - description: Schema.optional(Schema.String).pipe( - Schema.annotations({ - description: 'New description (optional)', - }), - ), - enabled: Schema.optional(Schema.Boolean).pipe( - Schema.annotations({ - description: 'Whether the variable is active (optional)', - }), - ), -}).pipe( - Schema.annotations({ - identifier: 'updateVariable', - title: 'Update Variable', - description: 'Update an existing workflow variable.', - }), -); - -// ============================================================================= -// Exports -// ============================================================================= - -export const MutationSchemas = { - CreateJsNode, - CreateHttpNode, - CreateConditionNode, - CreateForNode, - CreateForEachNode, - UpdateNodeCode, - UpdateNodeConfig, - ConnectNodes, - DisconnectNodes, - DeleteNode, - CreateVariable, - UpdateVariable, -} as const; - -export type CreateJsNode = typeof CreateJsNode.Type; -export type CreateHttpNode = typeof CreateHttpNode.Type; -export type CreateConditionNode = typeof CreateConditionNode.Type; -export type CreateForNode = typeof CreateForNode.Type; -export type CreateForEachNode = typeof CreateForEachNode.Type; -export type UpdateNodeCode = typeof UpdateNodeCode.Type; -export type UpdateNodeConfig = typeof UpdateNodeConfig.Type; -export type ConnectNodes = typeof ConnectNodes.Type; -export type DisconnectNodes = typeof DisconnectNodes.Type; -export type DeleteNode = typeof DeleteNode.Type; -export type CreateVariable = typeof CreateVariable.Type; -export type UpdateVariable = typeof UpdateVariable.Type; 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/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..fa8a44db8 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -0,0 +1,379 @@ +import { For, Indent, refkey, Show, SourceDirectory } from '@alloy-js/core'; +import { SourceFile, VarDeclaration } from '@alloy-js/typescript'; +import { EmitContext, getDoc, Model, ModelProperty } from '@typespec/compiler'; +import { $ } from '@typespec/compiler/typekit'; +import { Output, useTsp, writeOutput } from '@typespec/emitter-framework'; +import { Array, pipe, Record, String } from 'effect'; +import { join } from 'node:path/posix'; +import { aiTools, AIToolOptions, ToolCategory } from './lib.js'; + +export const $onEmit = async (context: EmitContext) => { + const { emitterOutputDir, program } = context; + + if (program.compilerOptions.noEmit) return; + + // Check if there are any AI tools to emit + const tools = aiTools(program); + if (tools.size === 0) { + return; + } + + await writeOutput( + program, + + + + + , + join(emitterOutputDir, 'ai-tools'), + ); +}; + +const CategoryFiles = () => { + const { program } = useTsp(); + + // Group tools by category + const toolsByCategory = pipe( + aiTools(program).entries(), + Array.fromIterable, + Array.groupBy(([, options]) => options.category), + Record.map(Array.map(([model, options]) => ({ model, options }))), + ); + + const categories: ToolCategory[] = ['Mutation', 'Exploration', 'Execution']; + + return ( + + {(category) => { + const tools = toolsByCategory[category] ?? []; + if (tools.length === 0) return null; + + const fileName = category.toLowerCase() + '.ts'; + const exportName = category + 'Schemas'; + + return ( + + + {'\n'} + + {({ model, options }) => } + + + + {'{'} + {'\n'} + + + {({ model }) => <>{model.name}} + + , + + {'\n'} + {'}'} as const + + {'\n\n'} + + {({ model }) => ( + <> + export type {model.name} = typeof {model.name}.Type;{'\n'} + + )} + + + ); + }} + + ); +}; + +interface SchemaImportsProps { + tools: { model: Model; options: AIToolOptions }[]; +} + +const SchemaImports = ({ tools }: SchemaImportsProps) => { + const { program } = useTsp(); + + // Collect all imported field schemas from common.ts + const commonImports = new Set(); + + tools.forEach(({ model }) => { + model.properties.forEach((prop) => { + const fieldSchema = getFieldSchema(prop, program); + if (fieldSchema.importFrom === 'common') { + commonImports.add(fieldSchema.schemaName); + } + }); + }); + + const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); + + return ( + <> + import {'{'} Schema {'}'} from 'effect'; + {'\n\n'} + 0}> + import {'{'} + {'\n'} + + + {(name) => <>{name}} + + + {'\n'} + {'}'} from '../../../src/tools/common.ts'; + {'\n'} + + + ); +}; + +interface ToolSchemaProps { + model: Model; + options: AIToolOptions; +} + +const ToolSchema = ({ model, options }: ToolSchemaProps) => { + const { program } = useTsp(); + const doc = getDoc(program, model); + const identifier = String.uncapitalize(model.name); + + const properties = model.properties.values().toArray(); + + return ( + + Schema.Struct({'{'} + {'\n'} + + + {(prop) => } + + + {'\n'} + {'}'}).pipe( + {'\n'} + + Schema.annotations({'{'} + {'\n'} + + identifier: '{identifier}',{'\n'} + title: '{options.title}',{'\n'} + description: {formatStringLiteral(doc ?? '')},{'\n'} + + {'}'}), + + {'\n'}) + + ); +}; + +interface PropertySchemaProps { + property: ModelProperty; +} + +const PropertySchema = ({ property }: PropertySchemaProps) => { + const { program } = useTsp(); + const doc = getDoc(program, property); + const fieldSchema = getFieldSchema(property, program); + + // Handle optional wrapping - but not for schemas that already include optional + const schemaExpr = property.optional && !fieldSchema.includesOptional + ? `Schema.optional(${fieldSchema.expression})` + : fieldSchema.expression; + + // Add description annotation if doc exists + if (doc || fieldSchema.needsDescription) { + const description = doc ?? ''; + return ( + <> + {property.name}: {schemaExpr}.pipe( + {'\n'} + + Schema.annotations({'{'} + {'\n'} + description: {formatStringLiteral(description)},{'\n'} + {'}'}), + + {'\n'}) + + ); + } + + return ( + <> + {property.name}: {schemaExpr} + + ); +}; + +interface FieldSchemaResult { + expression: string; + importFrom: 'common' | 'effect' | 'none'; + includesOptional: boolean; + needsDescription: boolean; + schemaName: string; +} + +function getFieldSchema(property: ModelProperty, program: ReturnType['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', + }; +} + +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..16d6c2a61 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/lib.ts @@ -0,0 +1,38 @@ +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, + }, +}; + +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, + }); +} 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..17d6b1fa9 --- /dev/null +++ b/tools/spec-lib/src/ai-tools/main.tsp @@ -0,0 +1,17 @@ +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); +} From dd6d2dd7c0d9dea388353337a9c2739e8a976402 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Fri, 23 Jan 2026 13:10:19 +0400 Subject: [PATCH 04/14] Derive mutation tool schemas from collection models via @mutationTool decorator Replace 23 duplicate AI tool models with a @mutationTool decorator on collection models. The emitter resolves Insert/Update/Delete properties at emit time based on primary keys, visibility, and exclude lists. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 271 +++--------------------- packages/spec/api/main.tsp | 10 +- packages/spec/package.json | 4 +- packages/spec/src/tools/generate.ts | 10 +- packages/spec/src/tools/index.ts | 13 +- tools/spec-lib/src/ai-tools/emitter.tsx | 255 +++++++++++++++++++++- tools/spec-lib/src/ai-tools/lib.ts | 35 +++ tools/spec-lib/src/ai-tools/main.tsp | 17 ++ 8 files changed, 341 insertions(+), 274 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 34e83a03a..3b23a4426 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -35,6 +35,10 @@ model FlowVersion { @foreignKey flowId: Id; } +@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 +51,10 @@ enum HandleKind { Loop, } +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Insert, title: "Connect Nodes", name: "ConnectNodes", description: "Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then)." }, + #{ 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 +86,10 @@ model Position { y: float32; } +@AITools.mutationTool( + #{ operation: AITools.CrudOperation.Update, title: "Update Node Config", name: "UpdateNodeConfig", description: "Update general node properties like name or position." }, + #{ operation: AITools.CrudOperation.Delete, title: "Delete Node", name: "DeleteNode", description: "Delete a node from the workflow. Also removes all connected edges." } +) @TanStackDB.collection model Node { @primaryKey nodeId: Id; @@ -89,10 +101,13 @@ model Node { @visibility(Lifecycle.Read) info?: string; } +@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,271 +116,47 @@ enum ErrorHandling { Break, } +@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.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.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.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; code: string; } -// ============================================================================= -// AI Tool Definitions - Mutation Tools -// ============================================================================= - -@doc("Create a new JavaScript node in the workflow. JS nodes can transform data, make calculations, or perform custom logic.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create JavaScript Node" }) -model CreateJsNode { - @doc("The ULID of the workflow to add the node to") - flowId: Id; - name: string; - code: string; - position?: Position; -} - -@doc("Create a new HTTP request node that makes an API call.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create HTTP Node" }) -model CreateHttpNode { - @doc("The ULID of the workflow to add the node to") - flowId: Id; - name: string; - @doc("The ULID of the HTTP request definition to use") - httpId: Id; - position?: Position; -} - -@doc("Create a condition node that routes flow based on a boolean expression. Has THEN and ELSE output handles.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create Condition Node" }) -model CreateConditionNode { - @doc("The ULID of the workflow to add the node to") - flowId: Id; - name: string; - condition: string; - position?: Position; -} - -@doc("Create a for-loop node that iterates a fixed number of times.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create For Loop Node" }) -model CreateForNode { - @doc("The ULID of the workflow to add the node to") - flowId: Id; - name: string; - @doc("Number of iterations to perform") - iterations: int32; - @doc("Optional condition to continue loop using expr-lang syntax (e.g., \"i < 10\"). Use == for equality (NOT ===)") - condition: string; - errorHandling: ErrorHandling; - position?: Position; -} - -@doc("Create a forEach node that iterates over an array or object.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create ForEach Loop Node" }) -model CreateForEachNode { - @doc("The ULID of the workflow to add the node to") - flowId: Id; - name: string; - @doc("Path to the array/object to iterate (e.g., \"input.items\")") - path: string; - @doc("Optional condition to continue iteration using expr-lang syntax. Use == for equality (NOT ===)") - condition: string; - errorHandling: ErrorHandling; - position?: Position; -} - -@doc("Update the JavaScript code of a JS node.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Update Node Code" }) -model UpdateNodeCode { - @doc("The ULID of the JS node to update") - nodeId: Id; - code: string; -} - -@doc("Update general node properties like name or position.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Update Node Config" }) -model UpdateNodeConfig { - @doc("The ULID of the node to update") - nodeId: Id; - @doc("New display name (optional)") - name?: string; - position?: Position; -} - -@doc("Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then).") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Connect Nodes" }) -model ConnectNodes { - @doc("The ULID of the workflow") - flowId: Id; - @doc("The ULID of the source node") - sourceId: Id; - @doc("The ULID of the target node") - targetId: Id; - @doc("Output handle for branching nodes ONLY. Use \"then\"/\"else\" for Condition nodes, \"loop\"/\"then\" for For/ForEach nodes. OMIT this parameter for Manual Start, JS, and HTTP nodes.") - sourceHandle?: HandleKind; -} - -@doc("Remove an edge connection between nodes.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Disconnect Nodes" }) -model DisconnectNodes { - @doc("The ULID of the edge to remove") - edgeId: Id; -} - -@doc("Delete a node from the workflow. Also removes all connected edges.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Delete Node" }) -model DeleteNode { - @doc("The ULID of the node to delete") - nodeId: Id; -} - -@doc("Create a new workflow variable that can be referenced in node expressions.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Create Variable" }) -model CreateVariable { - @doc("The ULID of the workflow") - flowId: Id; - @doc("Variable name (used to reference it in expressions)") - key: string; - @doc("Variable value") - value: string; - @doc("Description of what the variable is for (optional)") - description?: string; - @doc("Whether the variable is active (default: true)") - enabled?: boolean; -} - -@doc("Update an existing workflow variable.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Mutation, title: "Update Variable" }) -model UpdateVariable { - @doc("The ULID of the variable to update") - flowVariableId: Id; - @doc("New variable name (optional)") - key?: string; - @doc("New variable value (optional)") - value?: string; - @doc("New description (optional)") - description?: string; - @doc("Whether the variable is active (optional)") - enabled?: boolean; -} - -// ============================================================================= -// AI Tool Definitions - Exploration Tools -// ============================================================================= - -@doc("Get the complete workflow graph including all nodes and edges. Use this to understand the current structure of the workflow.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Workflow Graph" }) -model GetWorkflowGraph { - @doc("The ULID of the workflow to retrieve") - flowId: Id; -} - -@doc("Get detailed information about a specific node including its configuration, code (for JS nodes), and connections.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Node Details" }) -model GetNodeDetails { - @doc("The ULID of the node to inspect") - nodeId: Id; -} - -@doc("Read a markdown template that provides guidance on implementing a specific type of node or pattern.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Node Template" }) -model GetNodeTemplate { - @doc("The name of the template to retrieve (e.g., \"http-aggregator\", \"js-transformer\")") - templateName: string; -} - -@doc("Search for available templates by keyword or pattern.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Search Templates" }) -model SearchTemplates { - @doc("Search query to find relevant templates") - query: string; -} - -@doc("Get the history of past workflow executions.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Execution History" }) -model GetExecutionHistory { - @doc("The ULID of the workflow") - flowId: Id; - @doc("Maximum number of executions to return (default: 10)") - limit?: int32; -} - -@doc("Get the latest execution logs. Returns only the most recent execution per node to avoid showing full history.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get Execution Logs" }) -model GetExecutionLogs { - @doc("Filter to only show executions for nodes in this workflow") - flowId?: Id; - @doc("Maximum number of node executions to return (default: 10)") - limit?: int32; - @doc("Optional: specific execution ID to get logs for") - executionId?: Id; -} - -@doc("Search for API documentation by name, description, or keywords. Returns lightweight metadata for matching APIs. Use getApiDocs to load full documentation for a specific API.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Search API Docs" }) -model SearchApiDocs { - @doc("Search query - API name, description keywords, or use case (e.g., \"send message\", \"payment\", \"telegram bot\")") - query: string; - @doc("Category of the API") - category?: string; - @doc("Maximum results to return (default: 5)") - limit?: int32; -} - -@doc("Load full documentation for a specific API. Call this after searchApiDocs to get complete endpoint details, authentication info, and examples.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Exploration, title: "Get API Docs" }) -model GetApiDocs { - @doc("API identifier from search results (e.g., \"slack\", \"stripe\", \"telegram\")") - apiId: string; - @doc("Force refresh from source, bypassing cache") - forceRefresh?: boolean; - @doc("Optional filter to focus on specific endpoint (e.g., \"chat.postMessage\", \"sendMessage\")") - endpoint?: string; -} - -// ============================================================================= -// AI Tool Definitions - Execution Tools -// ============================================================================= - -@doc("Execute the workflow from the start node. Returns execution status.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Run Workflow" }) -model RunWorkflow { - @doc("The ULID of the workflow to run") - flowId: Id; -} - -@doc("Stop a running workflow execution.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Stop Workflow" }) -model StopWorkflow { - @doc("The ULID of the workflow to stop") - flowId: Id; -} - -@doc("Validate the workflow for errors, missing connections, or configuration issues. Use this before running to catch problems.") -@AITools.aiTool(#{ category: AITools.ToolCategory.Execution, title: "Validate Workflow" }) -model ValidateWorkflow { - @doc("The ULID of the workflow to validate") - flowId: Id; -} - @TanStackDB.collection(#{ isReadOnly: true }) model NodeExecution { @primaryKey nodeExecutionId: Id; diff --git a/packages/spec/api/main.tsp b/packages/spec/api/main.tsp index 9cbc94666..abe3d50da 100644 --- a/packages/spec/api/main.tsp +++ b/packages/spec/api/main.tsp @@ -21,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 e9e46a0d6..a882e6891 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -14,9 +14,7 @@ "./tools": "./src/tools/index.ts", "./tools/schemas": "./dist/tools/schemas.ts", "./tools/common": "./src/tools/common.ts", - "./tools/mutation": "./dist/ai-tools/v1/mutation.ts", - "./tools/exploration": "./dist/ai-tools/v1/exploration.ts", - "./tools/execution": "./dist/ai-tools/v1/execution.ts" + "./tools/mutation": "./dist/ai-tools/v1/mutation.ts" }, "scripts": { "generate:schemas": "tsx src/tools/generate.ts" diff --git a/packages/spec/src/tools/generate.ts b/packages/spec/src/tools/generate.ts index c713ec0cd..a7b6de15e 100644 --- a/packages/spec/src/tools/generate.ts +++ b/packages/spec/src/tools/generate.ts @@ -14,7 +14,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas } from './index.ts'; +import { allToolSchemas, mutationSchemas } from './index.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -35,8 +35,6 @@ const jsonOutput = { tools: { all: allToolSchemas, mutation: mutationSchemas, - exploration: explorationSchemas, - execution: executionSchemas, }, }; @@ -61,10 +59,6 @@ export const allToolSchemas = ${JSON.stringify(allToolSchemas, null, 2)} as cons export const mutationSchemas = ${JSON.stringify(mutationSchemas, null, 2)} as const; -export const explorationSchemas = ${JSON.stringify(explorationSchemas, null, 2)} as const; - -export const executionSchemas = ${JSON.stringify(executionSchemas, null, 2)} as const; - // Individual schema exports ${allToolSchemas .map( @@ -84,6 +78,4 @@ console.log('✓ Generated dist/tools/schemas.ts'); console.log('\nGenerated schemas summary:'); console.log(` Mutation tools: ${mutationSchemas.length}`); -console.log(` Exploration tools: ${explorationSchemas.length}`); -console.log(` Execution tools: ${executionSchemas.length}`); console.log(` Total: ${allToolSchemas.length}`); diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts index 16f324fa9..400a31cdf 100644 --- a/packages/spec/src/tools/index.ts +++ b/packages/spec/src/tools/index.ts @@ -2,21 +2,16 @@ * Tool Schema Definitions using Effect Schema * * Single source of truth for AI tool definitions. - * Adding a new tool: just add to MutationSchemas/ExplorationSchemas/ExecutionSchemas. - * Everything else is automatic. + * Mutation tools are auto-generated from @mutationTool decorator on collection models. */ import { JSONSchema, Schema } from 'effect'; // Re-export common schemas and generated tool schemas export * from './common.ts'; -export * from '../../dist/ai-tools/v1/execution.ts'; -export * from '../../dist/ai-tools/v1/exploration.ts'; export * from '../../dist/ai-tools/v1/mutation.ts'; // Import schema groups from generated files -import { ExecutionSchemas } from '../../dist/ai-tools/v1/execution.ts'; -import { ExplorationSchemas } from '../../dist/ai-tools/v1/exploration.ts'; import { MutationSchemas } from '../../dist/ai-tools/v1/mutation.ts'; // ============================================================================= @@ -104,11 +99,9 @@ function schemaToToolDefinition(schema: Schema.Schema): ToolDe // ============================================================================= export const mutationSchemas = Object.values(MutationSchemas).map(schemaToToolDefinition); -export const explorationSchemas = Object.values(ExplorationSchemas).map(schemaToToolDefinition); -export const executionSchemas = Object.values(ExecutionSchemas).map(schemaToToolDefinition); /** All tool schemas combined - ready for AI tool calling */ -export const allToolSchemas = [...explorationSchemas, ...mutationSchemas, ...executionSchemas]; +export const allToolSchemas = [...mutationSchemas]; // ============================================================================= // Effect Schemas (for runtime validation) @@ -116,8 +109,6 @@ export const allToolSchemas = [...explorationSchemas, ...mutationSchemas, ...exe export const EffectSchemas = { Mutation: MutationSchemas, - Exploration: ExplorationSchemas, - Execution: ExecutionSchemas, } as const; // ============================================================================= diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index fa8a44db8..b557c26ca 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -1,11 +1,12 @@ import { For, Indent, refkey, Show, SourceDirectory } from '@alloy-js/core'; import { SourceFile, VarDeclaration } from '@alloy-js/typescript'; -import { EmitContext, getDoc, Model, ModelProperty } from '@typespec/compiler'; +import { EmitContext, getDoc, Model, ModelProperty, Program } from '@typespec/compiler'; import { $ } from '@typespec/compiler/typekit'; import { Output, useTsp, writeOutput } from '@typespec/emitter-framework'; import { Array, pipe, Record, String } from 'effect'; import { join } from 'node:path/posix'; -import { aiTools, AIToolOptions, ToolCategory } from './lib.js'; +import { primaryKeys } from '../core/index.jsx'; +import { aiTools, AIToolOptions, MutationToolOptions, mutationTools, ToolCategory } from './lib.js'; export const $onEmit = async (context: EmitContext) => { const { emitterOutputDir, program } = context; @@ -14,7 +15,8 @@ export const $onEmit = async (context: EmitContext) => { // Check if there are any AI tools to emit const tools = aiTools(program); - if (tools.size === 0) { + const mutations = mutationTools(program); + if (tools.size === 0 && mutations.size === 0) { return; } @@ -29,10 +31,103 @@ export const $onEmit = async (context: EmitContext) => { ); }; +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; // No restriction = visible everywhere + + // Check if any of the visibility args matches our phase + 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; + + // Resolve parent model by name from the collection model's namespace + 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 { + 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 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; +} + const CategoryFiles = () => { const { program } = useTsp(); - // Group tools by category + // Group aiTool-decorated tools by category (for Exploration/Execution) const toolsByCategory = pipe( aiTools(program).entries(), Array.fromIterable, @@ -40,11 +135,51 @@ const CategoryFiles = () => { Record.map(Array.map(([model, options]) => ({ model, options }))), ); - const categories: ToolCategory[] = ['Mutation', 'Exploration', 'Execution']; + // Resolve mutation tools from @mutationTool decorator + const resolvedMutationTools = resolveMutationTools(program); + + const allCategories: ToolCategory[] = ['Mutation', 'Exploration', 'Execution']; return ( - + {(category) => { + if (category === 'Mutation') { + // Mutation tools from @mutationTool decorator + if (resolvedMutationTools.length === 0) return null; + + return ( + + + {'\n'} + + {(tool) => } + + + + {'{'} + {'\n'} + + + {(tool) => <>{tool.name}} + + , + + {'\n'} + {'}'} as const + + {'\n\n'} + + {(tool) => ( + <> + export type {tool.name} = typeof {tool.name}.Type;{'\n'} + + )} + + + ); + } + + // Exploration/Execution tools from @aiTool decorator const tools = toolsByCategory[category] ?? []; if (tools.length === 0) return null; @@ -127,6 +262,114 @@ const SchemaImports = ({ tools }: SchemaImportsProps) => { ); }; +interface ResolvedSchemaImportsProps { + tools: ResolvedTool[]; +} + +const ResolvedSchemaImports = ({ tools }: ResolvedSchemaImportsProps) => { + const { program } = useTsp(); + + const commonImports = new Set(); + + tools.forEach(({ properties }) => { + properties.forEach(({ property }) => { + const fieldSchema = getFieldSchema(property, program); + if (fieldSchema.importFrom === 'common') { + commonImports.add(fieldSchema.schemaName); + } + }); + }); + + const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); + + return ( + <> + import {'{'} Schema {'}'} from 'effect'; + {'\n\n'} + 0}> + import {'{'} + {'\n'} + + + {(name) => <>{name}} + + + {'\n'} + {'}'} from '../../../src/tools/common.ts'; + {'\n'} + + + ); +}; + +const ResolvedToolSchema = ({ tool }: { tool: ResolvedTool }) => { + const identifier = String.uncapitalize(tool.name); + + return ( + + Schema.Struct({'{'} + {'\n'} + + + {({ optional, property }) => } + + + {'\n'} + {'}'}).pipe( + {'\n'} + + Schema.annotations({'{'} + {'\n'} + + identifier: '{identifier}',{'\n'} + title: '{tool.title}',{'\n'} + description: {formatStringLiteral(tool.description ?? '')},{'\n'} + + {'}'}), + + {'\n'}) + + ); +}; + +interface ResolvedPropertySchemaProps { + isOptional: boolean; + property: ModelProperty; +} + +const ResolvedPropertySchema = ({ isOptional, property }: ResolvedPropertySchemaProps) => { + const { program } = useTsp(); + const doc = getDoc(program, property); + const fieldSchema = getFieldSchema(property, program); + + const schemaExpr = isOptional && !fieldSchema.includesOptional + ? `Schema.optional(${fieldSchema.expression})` + : fieldSchema.expression; + + if (doc || fieldSchema.needsDescription) { + const description = doc ?? ''; + return ( + <> + {property.name}: {schemaExpr}.pipe( + {'\n'} + + Schema.annotations({'{'} + {'\n'} + description: {formatStringLiteral(description)},{'\n'} + {'}'}), + + {'\n'}) + + ); + } + + return ( + <> + {property.name}: {schemaExpr} + + ); +}; + interface ToolSchemaProps { model: Model; options: AIToolOptions; diff --git a/tools/spec-lib/src/ai-tools/lib.ts b/tools/spec-lib/src/ai-tools/lib.ts index 16d6c2a61..8fa01578e 100644 --- a/tools/spec-lib/src/ai-tools/lib.ts +++ b/tools/spec-lib/src/ai-tools/lib.ts @@ -9,6 +9,7 @@ export const $lib = createTypeSpecLibrary({ export const $decorators = { 'DevTools.AITools': { aiTool, + mutationTool, }, }; @@ -36,3 +37,37 @@ function aiTool({ program }: DecoratorContext, target: Model, options: RawAITool title: options.title, }); } + +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; +} + +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 resolved: MutationToolOptions[] = tools.map((tool) => ({ + description: tool.description, + exclude: tool.exclude, + name: tool.name, + operation: tool.operation.value.name as CrudOperation, + parent: tool.parent, + title: tool.title, + })); + mutationTools(program).set(target, resolved); +} diff --git a/tools/spec-lib/src/ai-tools/main.tsp b/tools/spec-lib/src/ai-tools/main.tsp index 17d6b1fa9..be033f9ec 100644 --- a/tools/spec-lib/src/ai-tools/main.tsp +++ b/tools/spec-lib/src/ai-tools/main.tsp @@ -14,4 +14,21 @@ namespace DevTools.AITools { } 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[]); } From 173498bd4bb4ac40ec919620b41dfe7a84e55449 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Fri, 23 Jan 2026 20:33:29 +0400 Subject: [PATCH 05/14] Add exploration and execution tool schema generation Auto-generate Get{ModelName} exploration tools from @mutationTool-decorated collections using PK fields. Annotate FlowRunRequest/FlowStopRequest with @aiTool for execution schema generation. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 8 +- tools/spec-lib/src/ai-tools/emitter.tsx | 131 +++++++++++++++++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 3b23a4426..e6275cdd5 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): {}; diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index b557c26ca..e2dd015b0 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -105,6 +105,34 @@ function resolveToolProperties(program: Program, collectionModel: Model, toolDef } } +function resolveExplorationTools(program: Program): ResolvedTool[] { + const tools: ResolvedTool[] = []; + const seen = new Set(); + + for (const [model] of mutationTools(program).entries()) { + if (seen.has(model.name)) continue; + seen.add(model.name); + + 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; + + const spacedName = model.name.replace(/([a-z])([A-Z])/g, '$1 $2'); + tools.push({ + description: `Get a ${spacedName.toLowerCase()} by its primary key.`, + name: `Get${model.name}`, + properties, + title: `Get ${spacedName}`, + }); + } + + return tools; +} + function resolveMutationTools(program: Program): ResolvedTool[] { const tools: ResolvedTool[] = []; @@ -138,6 +166,9 @@ const CategoryFiles = () => { // Resolve mutation tools from @mutationTool decorator const resolvedMutationTools = resolveMutationTools(program); + // Resolve exploration tools (auto-generated Get tools from @mutationTool models) + const resolvedExplorationTools = resolveExplorationTools(program); + const allCategories: ToolCategory[] = ['Mutation', 'Exploration', 'Execution']; return ( @@ -179,7 +210,60 @@ const CategoryFiles = () => { ); } - // Exploration/Execution tools from @aiTool decorator + if (category === 'Exploration') { + const aiToolModels = toolsByCategory[category] ?? []; + if (resolvedExplorationTools.length === 0 && aiToolModels.length === 0) return null; + + return ( + + + {'\n'} + + {(tool) => } + + + 0}> + + {({ model, options }) => } + + + + + {'{'} + {'\n'} + + + {(tool) => <>{tool.name}} + + 0 && aiToolModels.length > 0}>,{'\n'} + + {({ model }) => <>{model.name}} + + , + + {'\n'} + {'}'} as const + + {'\n\n'} + + {(tool) => ( + <> + export type {tool.name} = typeof {tool.name}.Type;{'\n'} + + )} + + + {({ model }) => ( + <> + export type {model.name} = typeof {model.name}.Type;{'\n'} + + )} + + + ); + } + + // Execution tools from @aiTool decorator const tools = toolsByCategory[category] ?? []; if (tools.length === 0) return null; @@ -302,6 +386,51 @@ const ResolvedSchemaImports = ({ tools }: ResolvedSchemaImportsProps) => { ); }; +interface ExplorationImportsProps { + aiToolModels: { model: Model; options: AIToolOptions }[]; + resolvedTools: ResolvedTool[]; +} + +const ExplorationImports = ({ aiToolModels, resolvedTools }: ExplorationImportsProps) => { + const { program } = useTsp(); + const commonImports = new Set(); + + resolvedTools.forEach(({ properties }) => { + properties.forEach(({ property }) => { + const fieldSchema = getFieldSchema(property, program); + if (fieldSchema.importFrom === 'common') commonImports.add(fieldSchema.schemaName); + }); + }); + + aiToolModels.forEach(({ model }) => { + model.properties.forEach((prop) => { + const fieldSchema = getFieldSchema(prop, program); + if (fieldSchema.importFrom === 'common') commonImports.add(fieldSchema.schemaName); + }); + }); + + const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); + + return ( + <> + import {'{'} Schema {'}'} from 'effect'; + {'\n\n'} + 0}> + import {'{'} + {'\n'} + + + {(name) => <>{name}} + + + {'\n'} + {'}'} from '../../../src/tools/common.ts'; + {'\n'} + + + ); +}; + const ResolvedToolSchema = ({ tool }: { tool: ResolvedTool }) => { const identifier = String.uncapitalize(tool.name); From 83550269db0e8075e0844bf7ce2f0f340f3213f2 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Fri, 23 Jan 2026 23:30:37 +0400 Subject: [PATCH 06/14] Add @explorationTool decorator and include all tool categories in schema generation Add a new @explorationTool decorator that generates custom exploration tools from collection model primary keys. Apply it to the Flow model to create a ValidateWorkflow tool. Also wire up execution and exploration schemas into the JSON schema generator alongside mutations. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 1 + packages/spec/package.json | 2 ++ packages/spec/src/tools/generate.ts | 10 +++++++++- packages/spec/src/tools/index.ts | 12 +++++++++++- tools/spec-lib/src/ai-tools/emitter.tsx | 25 +++++++++++++++++++++++-- tools/spec-lib/src/ai-tools/lib.ts | 24 ++++++++++++++++++++++++ tools/spec-lib/src/ai-tools/main.tsp | 8 ++++++++ 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index e6275cdd5..94158982d 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -2,6 +2,7 @@ using DevTools; namespace Api.Flow; +@AITools.explorationTool(#{ name: "ValidateWorkflow", title: "Validate Workflow", description: "Validate a workflow by checking its structure, connections, and node configurations for errors." }) @TanStackDB.collection model Flow { @primaryKey flowId: Id; diff --git a/packages/spec/package.json b/packages/spec/package.json index a882e6891..c35d863bc 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -14,6 +14,8 @@ "./tools": "./src/tools/index.ts", "./tools/schemas": "./dist/tools/schemas.ts", "./tools/common": "./src/tools/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" }, "scripts": { diff --git a/packages/spec/src/tools/generate.ts b/packages/spec/src/tools/generate.ts index a7b6de15e..9b865b595 100644 --- a/packages/spec/src/tools/generate.ts +++ b/packages/spec/src/tools/generate.ts @@ -14,7 +14,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { allToolSchemas, mutationSchemas } from './index.ts'; +import { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas } from './index.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -34,6 +34,8 @@ const jsonOutput = { generatedAt: new Date().toISOString(), tools: { all: allToolSchemas, + execution: executionSchemas, + exploration: explorationSchemas, mutation: mutationSchemas, }, }; @@ -57,6 +59,10 @@ const tsOutput = `/** export const allToolSchemas = ${JSON.stringify(allToolSchemas, null, 2)} as const; +export const executionSchemas = ${JSON.stringify(executionSchemas, null, 2)} as const; + +export const explorationSchemas = ${JSON.stringify(explorationSchemas, null, 2)} as const; + export const mutationSchemas = ${JSON.stringify(mutationSchemas, null, 2)} as const; // Individual schema exports @@ -77,5 +83,7 @@ console.log('✓ Generated dist/tools/schemas.ts'); // ============================================================================= console.log('\nGenerated schemas summary:'); +console.log(` Execution tools: ${executionSchemas.length}`); +console.log(` Exploration tools: ${explorationSchemas.length}`); console.log(` Mutation tools: ${mutationSchemas.length}`); console.log(` Total: ${allToolSchemas.length}`); diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts index 400a31cdf..65ea64d2b 100644 --- a/packages/spec/src/tools/index.ts +++ b/packages/spec/src/tools/index.ts @@ -9,9 +9,13 @@ import { JSONSchema, Schema } from 'effect'; // Re-export common schemas and generated tool schemas export * from './common.ts'; +export * from '../../dist/ai-tools/v1/execution.ts'; +export * from '../../dist/ai-tools/v1/exploration.ts'; export * from '../../dist/ai-tools/v1/mutation.ts'; // Import schema groups from generated files +import { ExecutionSchemas } from '../../dist/ai-tools/v1/execution.ts'; +import { ExplorationSchemas } from '../../dist/ai-tools/v1/exploration.ts'; import { MutationSchemas } from '../../dist/ai-tools/v1/mutation.ts'; // ============================================================================= @@ -98,16 +102,22 @@ function schemaToToolDefinition(schema: Schema.Schema): ToolDe // Auto-generated Tool Definitions (no manual listing needed) // ============================================================================= +export const executionSchemas = Object.values(ExecutionSchemas).map(schemaToToolDefinition); + +export const explorationSchemas = Object.values(ExplorationSchemas).map(schemaToToolDefinition); + export const mutationSchemas = Object.values(MutationSchemas).map(schemaToToolDefinition); /** All tool schemas combined - ready for AI tool calling */ -export const allToolSchemas = [...mutationSchemas]; +export const allToolSchemas = [...executionSchemas, ...explorationSchemas, ...mutationSchemas]; // ============================================================================= // Effect Schemas (for runtime validation) // ============================================================================= export const EffectSchemas = { + Execution: ExecutionSchemas, + Exploration: ExplorationSchemas, Mutation: MutationSchemas, } as const; diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index e2dd015b0..8a154664a 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -6,7 +6,7 @@ import { Output, useTsp, writeOutput } from '@typespec/emitter-framework'; import { Array, pipe, Record, String } from 'effect'; import { join } from 'node:path/posix'; import { primaryKeys } from '../core/index.jsx'; -import { aiTools, AIToolOptions, MutationToolOptions, mutationTools, ToolCategory } from './lib.js'; +import { aiTools, AIToolOptions, explorationTools, MutationToolOptions, mutationTools, ToolCategory } from './lib.js'; export const $onEmit = async (context: EmitContext) => { const { emitterOutputDir, program } = context; @@ -16,7 +16,8 @@ export const $onEmit = async (context: EmitContext) => { // Check if there are any AI tools to emit const tools = aiTools(program); const mutations = mutationTools(program); - if (tools.size === 0 && mutations.size === 0) { + const explorations = explorationTools(program); + if (tools.size === 0 && mutations.size === 0 && explorations.size === 0) { return; } @@ -130,6 +131,26 @@ function resolveExplorationTools(program: Program): ResolvedTool[] { }); } + // Custom exploration tools from @explorationTool decorator + 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; } diff --git a/tools/spec-lib/src/ai-tools/lib.ts b/tools/spec-lib/src/ai-tools/lib.ts index 8fa01578e..197de6b13 100644 --- a/tools/spec-lib/src/ai-tools/lib.ts +++ b/tools/spec-lib/src/ai-tools/lib.ts @@ -9,6 +9,7 @@ export const $lib = createTypeSpecLibrary({ export const $decorators = { 'DevTools.AITools': { aiTool, + explorationTool, mutationTool, }, }; @@ -71,3 +72,26 @@ function mutationTool({ program }: DecoratorContext, target: Model, ...tools: Ra })); mutationTools(program).set(target, resolved); } + +export interface ExplorationToolOptions { + description?: string | undefined; + name: string; + title: string; +} + +export const explorationTools = makeStateMap('explorationTools'); + +interface RawExplorationToolOptions { + description?: string; + name: string; + title: string; +} + +function explorationTool({ program }: DecoratorContext, target: Model, ...tools: RawExplorationToolOptions[]) { + const resolved: ExplorationToolOptions[] = tools.map((tool) => ({ + description: tool.description, + name: tool.name, + title: tool.title, + })); + 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 index be033f9ec..ff813727e 100644 --- a/tools/spec-lib/src/ai-tools/main.tsp +++ b/tools/spec-lib/src/ai-tools/main.tsp @@ -31,4 +31,12 @@ namespace DevTools.AITools { } 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[]); } From 143152882bd13e67c560cf0b6bad4c9d754f160b Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Sat, 24 Jan 2026 00:26:41 +0400 Subject: [PATCH 07/14] Slim down emitter.tsx by extracting field-schema and unifying components Extract getFieldSchema/formatStringLiteral to field-schema.ts, replace implicit auto-generated Get* exploration tools with explicit @explorationTool decorators on each mutation model, unify 3 import components and 4 tool/property schema components into single generic versions, and simplify CategoryFiles to a uniform rendering path. Reduces emitter from 772 to 331 lines. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 8 + tools/spec-lib/src/ai-tools/emitter.tsx | 594 +++----------------- tools/spec-lib/src/ai-tools/field-schema.ts | 175 ++++++ 3 files changed, 259 insertions(+), 518 deletions(-) create mode 100644 tools/spec-lib/src/ai-tools/field-schema.ts diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 94158982d..40521c9c7 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -40,6 +40,7 @@ model FlowVersion { @foreignKey flowId: Id; } +@AITools.explorationTool(#{ name: "GetFlowVariable", title: "Get Flow Variable", description: "Get a flow variable by its primary key." }) @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." } @@ -56,6 +57,7 @@ enum HandleKind { Loop, } +@AITools.explorationTool(#{ name: "GetEdge", title: "Get Edge", description: "Get a edge by its primary key." }) @AITools.mutationTool( #{ operation: AITools.CrudOperation.Insert, title: "Connect Nodes", name: "ConnectNodes", description: "Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then)." }, #{ operation: AITools.CrudOperation.Delete, title: "Disconnect Nodes", name: "DisconnectNodes", description: "Remove an edge connection between nodes." } @@ -91,6 +93,7 @@ model Position { y: float32; } +@AITools.explorationTool(#{ name: "GetNode", title: "Get Node", description: "Get a node by its primary key." }) @AITools.mutationTool( #{ operation: AITools.CrudOperation.Update, title: "Update Node Config", name: "UpdateNodeConfig", description: "Update general node properties like name or position." }, #{ operation: AITools.CrudOperation.Delete, title: "Delete Node", name: "DeleteNode", description: "Delete a node from the workflow. Also removes all connected edges." } @@ -106,6 +109,7 @@ model Node { @visibility(Lifecycle.Read) info?: string; } +@AITools.explorationTool(#{ name: "GetNodeHttp", title: "Get Node Http", description: "Get a node http by its primary key." }) @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." } ) @@ -121,6 +125,7 @@ enum ErrorHandling { Break, } +@AITools.explorationTool(#{ name: "GetNodeFor", title: "Get Node For", description: "Get a node for by its primary key." }) @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." } ) @@ -132,6 +137,7 @@ model NodeFor { errorHandling: ErrorHandling; } +@AITools.explorationTool(#{ name: "GetNodeForEach", title: "Get Node For Each", description: "Get a node for each by its primary key." }) @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." } ) @@ -143,6 +149,7 @@ model NodeForEach { errorHandling: ErrorHandling; } +@AITools.explorationTool(#{ name: "GetNodeCondition", title: "Get Node Condition", description: "Get a node condition by its primary key." }) @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." } ) @@ -152,6 +159,7 @@ model NodeCondition { condition: string; } +@AITools.explorationTool(#{ name: "GetNodeJs", title: "Get Node Js", description: "Get a node js by its primary key." }) @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." } diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index 8a154664a..5c0751d1f 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -1,19 +1,18 @@ import { 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 { $ } from '@typespec/compiler/typekit'; import { Output, useTsp, writeOutput } from '@typespec/emitter-framework'; -import { Array, pipe, Record, String } from 'effect'; +import { Array, String } from 'effect'; import { join } from 'node:path/posix'; import { primaryKeys } from '../core/index.jsx'; -import { aiTools, AIToolOptions, explorationTools, MutationToolOptions, mutationTools, ToolCategory } from './lib.js'; +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; - // Check if there are any AI tools to emit const tools = aiTools(program); const mutations = mutationTools(program); const explorations = explorationTools(program); @@ -48,9 +47,8 @@ function isVisibleFor(property: ModelProperty, phase: 'Create' | 'Update'): bool const visibilityDec = property.decorators.find( (d) => d.decorator.name === '$visibility', ); - if (!visibilityDec) return true; // No restriction = visible everywhere + if (!visibilityDec) return true; - // Check if any of the visibility args matches our phase return visibilityDec.args.some((arg) => { const val = arg.value as { value?: { name?: string } } | undefined; return val?.value?.name === phase; @@ -59,8 +57,6 @@ function isVisibleFor(property: ModelProperty, phase: 'Create' | 'Update'): bool function resolveToolProperties(program: Program, collectionModel: Model, toolDef: MutationToolOptions): ResolvedProperty[] { const { exclude = [], operation, parent: parentName } = toolDef; - - // Resolve parent model by name from the collection model's namespace const parent = parentName ? collectionModel.namespace?.models.get(parentName) : undefined; switch (operation) { @@ -108,30 +104,7 @@ function resolveToolProperties(program: Program, collectionModel: Model, toolDef function resolveExplorationTools(program: Program): ResolvedTool[] { const tools: ResolvedTool[] = []; - const seen = new Set(); - - for (const [model] of mutationTools(program).entries()) { - if (seen.has(model.name)) continue; - seen.add(model.name); - - 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; - const spacedName = model.name.replace(/([a-z])([A-Z])/g, '$1 $2'); - tools.push({ - description: `Get a ${spacedName.toLowerCase()} by its primary key.`, - name: `Get${model.name}`, - properties, - title: `Get ${spacedName}`, - }); - } - - // Custom exploration tools from @explorationTool decorator for (const [model, toolDefs] of explorationTools(program).entries()) { for (const toolDef of toolDefs) { const properties: ResolvedProperty[] = []; @@ -173,262 +146,98 @@ function resolveMutationTools(program: Program): ResolvedTool[] { 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(); - // Group aiTool-decorated tools by category (for Exploration/Execution) - const toolsByCategory = pipe( - aiTools(program).entries(), - Array.fromIterable, - Array.groupBy(([, options]) => options.category), - Record.map(Array.map(([model, options]) => ({ model, options }))), - ); - - // Resolve mutation tools from @mutationTool decorator const resolvedMutationTools = resolveMutationTools(program); - - // Resolve exploration tools (auto-generated Get tools from @mutationTool models) const resolvedExplorationTools = resolveExplorationTools(program); + const aiToolsByCategory = resolveAiTools(program); - const allCategories: ToolCategory[] = ['Mutation', 'Exploration', 'Execution']; + const categories: { category: ToolCategory; tools: ResolvedTool[] }[] = []; - return ( - - {(category) => { - if (category === 'Mutation') { - // Mutation tools from @mutationTool decorator - if (resolvedMutationTools.length === 0) return null; - - return ( - - - {'\n'} - - {(tool) => } - + if (resolvedMutationTools.length > 0) { + categories.push({ category: 'Mutation', tools: resolvedMutationTools }); + } - - {'{'} - {'\n'} - - - {(tool) => <>{tool.name}} - - , - - {'\n'} - {'}'} as const - - {'\n\n'} - - {(tool) => ( - <> - export type {tool.name} = typeof {tool.name}.Type;{'\n'} - - )} - - - ); - } + const allExploration = [...resolvedExplorationTools, ...(aiToolsByCategory['Exploration'] ?? [])]; + if (allExploration.length > 0) { + categories.push({ category: 'Exploration', tools: allExploration }); + } - if (category === 'Exploration') { - const aiToolModels = toolsByCategory[category] ?? []; - if (resolvedExplorationTools.length === 0 && aiToolModels.length === 0) return null; + const executionTools = aiToolsByCategory['Execution'] ?? []; + if (executionTools.length > 0) { + categories.push({ category: 'Execution', tools: executionTools }); + } - return ( - - - {'\n'} - - {(tool) => } - + return ( + + {({ category, tools }) => ( + + + {'\n'} + + {(tool) => } + - 0}> - - {({ model, options }) => } - - - - - {'{'} - {'\n'} - - - {(tool) => <>{tool.name}} - - 0 && aiToolModels.length > 0}>,{'\n'} - - {({ model }) => <>{model.name}} - - , - - {'\n'} - {'}'} as const - - {'\n\n'} - - {(tool) => ( - <> - export type {tool.name} = typeof {tool.name}.Type;{'\n'} - - )} - - - {({ model }) => ( - <> - export type {model.name} = typeof {model.name}.Type;{'\n'} - - )} + + {'{'} + {'\n'} + + + {(tool) => <>{tool.name}} - - ); - } - - // Execution tools from @aiTool decorator - const tools = toolsByCategory[category] ?? []; - if (tools.length === 0) return null; - - const fileName = category.toLowerCase() + '.ts'; - const exportName = category + 'Schemas'; - - return ( - - + , + {'\n'} - - {({ model, options }) => } - - - - {'{'} - {'\n'} - - - {({ model }) => <>{model.name}} - - , - - {'\n'} - {'}'} as const - - {'\n\n'} - - {({ model }) => ( - <> - export type {model.name} = typeof {model.name}.Type;{'\n'} - - )} - - - ); - }} - - ); -}; - -interface SchemaImportsProps { - tools: { model: Model; options: AIToolOptions }[]; -} - -const SchemaImports = ({ tools }: SchemaImportsProps) => { - const { program } = useTsp(); - - // Collect all imported field schemas from common.ts - const commonImports = new Set(); - - tools.forEach(({ model }) => { - model.properties.forEach((prop) => { - const fieldSchema = getFieldSchema(prop, program); - if (fieldSchema.importFrom === 'common') { - commonImports.add(fieldSchema.schemaName); - } - }); - }); - - const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); - - return ( - <> - import {'{'} Schema {'}'} from 'effect'; - {'\n\n'} - 0}> - import {'{'} - {'\n'} - - - {(name) => <>{name}} + {'}'} as const + + {'\n\n'} + + {(tool) => ( + <> + export type {tool.name} = typeof {tool.name}.Type;{'\n'} + + )} - - {'\n'} - {'}'} from '../../../src/tools/common.ts'; - {'\n'} - - + + )} + ); }; -interface ResolvedSchemaImportsProps { - tools: ResolvedTool[]; -} - -const ResolvedSchemaImports = ({ tools }: ResolvedSchemaImportsProps) => { +const SchemaImports = ({ tools }: { tools: ResolvedTool[] }) => { const { program } = useTsp(); - const commonImports = new Set(); - tools.forEach(({ properties }) => { - properties.forEach(({ property }) => { + 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 ( - <> - import {'{'} Schema {'}'} from 'effect'; - {'\n\n'} - 0}> - import {'{'} - {'\n'} - - - {(name) => <>{name}} - - - {'\n'} - {'}'} from '../../../src/tools/common.ts'; - {'\n'} - - - ); -}; - -interface ExplorationImportsProps { - aiToolModels: { model: Model; options: AIToolOptions }[]; - resolvedTools: ResolvedTool[]; -} - -const ExplorationImports = ({ aiToolModels, resolvedTools }: ExplorationImportsProps) => { - const { program } = useTsp(); - const commonImports = new Set(); - - resolvedTools.forEach(({ properties }) => { - properties.forEach(({ property }) => { - const fieldSchema = getFieldSchema(property, program); - if (fieldSchema.importFrom === 'common') commonImports.add(fieldSchema.schemaName); - }); - }); - - aiToolModels.forEach(({ model }) => { - model.properties.forEach((prop) => { - const fieldSchema = getFieldSchema(prop, program); - if (fieldSchema.importFrom === 'common') commonImports.add(fieldSchema.schemaName); - }); - }); + } + } const commonImportList = Array.sort(Array.fromIterable(commonImports), String.Order); @@ -452,7 +261,7 @@ const ExplorationImports = ({ aiToolModels, resolvedTools }: ExplorationImportsP ); }; -const ResolvedToolSchema = ({ tool }: { tool: ResolvedTool }) => { +const ToolSchema = ({ tool }: { tool: ResolvedTool }) => { const identifier = String.uncapitalize(tool.name); return ( @@ -461,7 +270,7 @@ const ResolvedToolSchema = ({ tool }: { tool: ResolvedTool }) => { {'\n'} - {({ optional, property }) => } + {({ optional, property }) => } {'\n'} @@ -482,12 +291,12 @@ const ResolvedToolSchema = ({ tool }: { tool: ResolvedTool }) => { ); }; -interface ResolvedPropertySchemaProps { +interface PropertySchemaProps { isOptional: boolean; property: ModelProperty; } -const ResolvedPropertySchema = ({ isOptional, property }: ResolvedPropertySchemaProps) => { +const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { const { program } = useTsp(); const doc = getDoc(program, property); const fieldSchema = getFieldSchema(property, program); @@ -519,254 +328,3 @@ const ResolvedPropertySchema = ({ isOptional, property }: ResolvedPropertySchema ); }; - -interface ToolSchemaProps { - model: Model; - options: AIToolOptions; -} - -const ToolSchema = ({ model, options }: ToolSchemaProps) => { - const { program } = useTsp(); - const doc = getDoc(program, model); - const identifier = String.uncapitalize(model.name); - - const properties = model.properties.values().toArray(); - - return ( - - Schema.Struct({'{'} - {'\n'} - - - {(prop) => } - - - {'\n'} - {'}'}).pipe( - {'\n'} - - Schema.annotations({'{'} - {'\n'} - - identifier: '{identifier}',{'\n'} - title: '{options.title}',{'\n'} - description: {formatStringLiteral(doc ?? '')},{'\n'} - - {'}'}), - - {'\n'}) - - ); -}; - -interface PropertySchemaProps { - property: ModelProperty; -} - -const PropertySchema = ({ property }: PropertySchemaProps) => { - const { program } = useTsp(); - const doc = getDoc(program, property); - const fieldSchema = getFieldSchema(property, program); - - // Handle optional wrapping - but not for schemas that already include optional - const schemaExpr = property.optional && !fieldSchema.includesOptional - ? `Schema.optional(${fieldSchema.expression})` - : fieldSchema.expression; - - // Add description annotation if doc exists - if (doc || fieldSchema.needsDescription) { - const description = doc ?? ''; - return ( - <> - {property.name}: {schemaExpr}.pipe( - {'\n'} - - Schema.annotations({'{'} - {'\n'} - description: {formatStringLiteral(description)},{'\n'} - {'}'}), - - {'\n'}) - - ); - } - - return ( - <> - {property.name}: {schemaExpr} - - ); -}; - -interface FieldSchemaResult { - expression: string; - importFrom: 'common' | 'effect' | 'none'; - includesOptional: boolean; - needsDescription: boolean; - schemaName: string; -} - -function getFieldSchema(property: ModelProperty, program: ReturnType['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', - }; -} - -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/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, "\\'") + "'"; +} From 4923a381b338e7b712c0003ca8c077d05ae9f938 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Sat, 24 Jan 2026 20:30:37 +0400 Subject: [PATCH 08/14] Fix schema generation for optional sourceHandle and excluded kind field Make sourceHandle optional on Edge model so ConnectNodes allows omitting it for sequential flows. Add exclude support to the emitter's Update case so UpdateNodeConfig correctly omits the immutable kind field. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 4 ++-- tools/spec-lib/src/ai-tools/emitter.tsx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 40521c9c7..41c2dd837 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -68,7 +68,7 @@ model Edge { @foreignKey flowId: Id; @foreignKey sourceId: Id; @foreignKey targetId: Id; - sourceHandle: HandleKind; + sourceHandle?: HandleKind; @visibility(Lifecycle.Read) state: FlowItemState; } @@ -95,7 +95,7 @@ model Position { @AITools.explorationTool(#{ name: "GetNode", title: "Get Node", description: "Get a node by its primary key." }) @AITools.mutationTool( - #{ operation: AITools.CrudOperation.Update, title: "Update Node Config", name: "UpdateNodeConfig", description: "Update general node properties like name or position." }, + #{ 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, title: "Delete Node", name: "DeleteNode", description: "Delete a node from the workflow. Also removes all connected edges." } ) @TanStackDB.collection diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index 5c0751d1f..2798ca0c3 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -85,6 +85,7 @@ function resolveToolProperties(program: Program, collectionModel: Model, toolDef if (primaryKeys(program).has(prop)) { props.push({ optional: false, property: prop }); } else { + if (exclude.includes(prop.name)) continue; props.push({ optional: true, property: prop }); } } From d07a174a3b7cd17565ee63e5726c478e98d13f6c Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Mon, 26 Jan 2026 16:32:24 +0400 Subject: [PATCH 09/14] Split ConnectNodes into sequential and branching tools Replace single ConnectNodes tool with two specialized tools: - ConnectSequentialNodes: excludes sourceHandle for ManualStart/JS/HTTP nodes - ConnectBranchingNodes: requires sourceHandle for Condition/For/ForEach nodes This eliminates the need for optional sourceHandle which caused Go type issues. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 41c2dd837..507a9f53a 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -59,7 +59,8 @@ enum HandleKind { @AITools.explorationTool(#{ name: "GetEdge", title: "Get Edge", description: "Get a edge by its primary key." }) @AITools.mutationTool( - #{ operation: AITools.CrudOperation.Insert, title: "Connect Nodes", name: "ConnectNodes", description: "Create an edge connection between two nodes. IMPORTANT: For sequential flows (Manual Start, JS, HTTP nodes), do NOT specify sourceHandle - omit it entirely. Only use sourceHandle for Condition nodes (then/else) and Loop nodes (loop/then)." }, + #{ 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 @@ -68,7 +69,7 @@ model Edge { @foreignKey flowId: Id; @foreignKey sourceId: Id; @foreignKey targetId: Id; - sourceHandle?: HandleKind; + sourceHandle: HandleKind; @visibility(Lifecycle.Read) state: FlowItemState; } From eaa923c5ee4cd1b5fd3a6f4e1f2b941f53f49ad7 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Mon, 26 Jan 2026 17:30:41 +0400 Subject: [PATCH 10/14] Fix TypeScript errors in schema generation - Fix OptionalPosition to apply annotations inside Schema.optional wrapper - Fix emitter to generate correct code for optional fields with descriptions - Add src/tools to tsconfig.lib.json include list - Fix index signature access to use bracket notation in index.ts Schema.optional() returns a PropertySignature that cannot be piped, so annotations must be applied to the inner schema first. Co-Authored-By: Claude Opus 4.5 --- packages/spec/src/tools/common.ts | 10 +++++---- packages/spec/src/tools/index.ts | 20 +++++++++++------- packages/spec/tsconfig.lib.json | 2 +- tools/spec-lib/src/ai-tools/emitter.tsx | 28 ++++++++++++++++++++----- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/packages/spec/src/tools/common.ts b/packages/spec/src/tools/common.ts index c3c275881..66b9da040 100644 --- a/packages/spec/src/tools/common.ts +++ b/packages/spec/src/tools/common.ts @@ -93,10 +93,12 @@ export const Position = Schema.Struct({ }), ); -export const OptionalPosition = Schema.optional(Position).pipe( - Schema.annotations({ - description: 'Position on the canvas (optional)', - }), +export const OptionalPosition = Schema.optional( + Position.pipe( + Schema.annotations({ + description: 'Position on the canvas (optional)', + }), + ), ); // ============================================================================= diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts index 65ea64d2b..6a563c707 100644 --- a/packages/spec/src/tools/index.ts +++ b/packages/spec/src/tools/index.ts @@ -40,8 +40,8 @@ function resolveRefs(obj: unknown, defs: Record): unknown { const record = obj as Record; // Handle $ref - if ('$ref' in record && typeof record.$ref === 'string') { - const defName = record.$ref.replace('#/$defs/', ''); + if ('$ref' in record && typeof record['$ref'] === 'string') { + const defName = record['$ref'].replace('#/$defs/', ''); const resolved = defs[defName]; if (resolved) { const { $ref: _, ...rest } = record; @@ -50,8 +50,8 @@ function resolveRefs(obj: unknown, defs: Record): unknown { } // Handle allOf with single $ref (common Effect pattern) - if ('allOf' in record && Array.isArray(record.allOf) && record.allOf.length === 1) { - const first = record.allOf[0] as Record; + 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 }; @@ -102,11 +102,17 @@ function schemaToToolDefinition(schema: Schema.Schema): ToolDe // Auto-generated Tool Definitions (no manual listing needed) // ============================================================================= -export const executionSchemas = Object.values(ExecutionSchemas).map(schemaToToolDefinition); +export const executionSchemas = Object.values(ExecutionSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); -export const explorationSchemas = Object.values(ExplorationSchemas).map(schemaToToolDefinition); +export const explorationSchemas = Object.values(ExplorationSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); -export const mutationSchemas = Object.values(MutationSchemas).map(schemaToToolDefinition); +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]; 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/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index 2798ca0c3..7808c9c95 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -302,15 +302,15 @@ const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { const doc = getDoc(program, property); const fieldSchema = getFieldSchema(property, program); - const schemaExpr = isOptional && !fieldSchema.includesOptional - ? `Schema.optional(${fieldSchema.expression})` - : fieldSchema.expression; + const needsOptionalWrapper = isOptional && !fieldSchema.includesOptional; if (doc || fieldSchema.needsDescription) { const description = doc ?? ''; - return ( + // When optional, wrap the annotated inner schema with Schema.optional() + // Schema.optional() returns a PropertySignature that can't be piped + const annotatedInner = ( <> - {property.name}: {schemaExpr}.pipe( + {fieldSchema.expression}.pipe( {'\n'} Schema.annotations({'{'} @@ -321,8 +321,26 @@ const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { {'\n'}) ); + + 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} From 0e78548c70cd36fa9e425c69c05cc171b0a22a2a Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Tue, 27 Jan 2026 23:52:52 +0400 Subject: [PATCH 11/14] Move schema generation to runtime by inlining common schemas into emitter output The emitter now generates common.ts and index.ts alongside the category files in dist/ai-tools/v1/, removing the dependency on hand-written src/tools/ files. JSON Schema conversion happens at module import time instead of a separate build step. Co-Authored-By: Claude Opus 4.5 --- packages/spec/package.json | 8 +- packages/spec/src/tools/common.ts | 252 ---------------- packages/spec/src/tools/generate.ts | 89 ------ packages/spec/src/tools/index.ts | 172 ----------- tools/spec-lib/src/ai-tools/emitter.tsx | 372 +++++++++++++++++++++++- 5 files changed, 373 insertions(+), 520 deletions(-) delete mode 100644 packages/spec/src/tools/common.ts delete mode 100644 packages/spec/src/tools/generate.ts delete mode 100644 packages/spec/src/tools/index.ts diff --git a/packages/spec/package.json b/packages/spec/package.json index c35d863bc..eb975af5b 100755 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -11,16 +11,12 @@ "exports": { "./buf/*": "./dist/buf/typescript/*.ts", "./tanstack-db/*": "./dist/tanstack-db/typescript/*.ts", - "./tools": "./src/tools/index.ts", - "./tools/schemas": "./dist/tools/schemas.ts", - "./tools/common": "./src/tools/common.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" }, - "scripts": { - "generate:schemas": "tsx src/tools/generate.ts" - }, "dependencies": { "effect": "catalog:" }, diff --git a/packages/spec/src/tools/common.ts b/packages/spec/src/tools/common.ts deleted file mode 100644 index 66b9da040..000000000 --- a/packages/spec/src/tools/common.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Common schemas and utilities for tool definitions - * These are shared building blocks used across multiple tool schemas - * - * IMPORTANT: Enums and types are DERIVED from the generated Protobuf types - * in @the-dev-tools/spec/buf/api/flow/v1/flow_pb to ensure consistency - * with the TypeSpec definitions. - */ - -import { Schema } from 'effect'; - -// ============================================================================= -// Import enums from generated Protobuf (derived from TypeSpec) -// ============================================================================= -import { - ErrorHandling as PbErrorHandling, - HandleKind as PbHandleKind, -} from '../../dist/buf/typescript/api/flow/v1/flow_pb.ts'; - -// ============================================================================= -// Common Field Schemas -// ============================================================================= - -/** - * ULID identifier schema - used for all entity IDs - * Matches the `Id` type in TypeSpec (main.tsp) - */ -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 - * Corresponds to Flow.flowId in flow.tsp - */ -export const FlowId = UlidId.pipe( - Schema.annotations({ - identifier: 'flowId', - description: 'The ULID of the workflow', - }), -); - -/** - * Node ID - references a node within a workflow - * Corresponds to Node.nodeId in flow.tsp - */ -export const NodeId = UlidId.pipe( - Schema.annotations({ - identifier: 'nodeId', - description: 'The ULID of the node', - }), -); - -/** - * Edge ID - references an edge connection - * Corresponds to Edge.edgeId in flow.tsp - */ -export const EdgeId = UlidId.pipe( - Schema.annotations({ - identifier: 'edgeId', - description: 'The ULID of the edge', - }), -); - -// ============================================================================= -// Position Schema (matches Position model in flow.tsp) -// ============================================================================= - -/** - * Canvas position for nodes - * Derived from: model Position { x: float32; y: float32; } in flow.tsp - */ -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 -// ============================================================================= - -/** - * SAFETY NET: These types and Record<> patterns ensure TypeScript will ERROR - * if the backend adds new enum values to TypeSpec that we haven't handled. - * - * How it works: - * 1. We exclude UNSPECIFIED (protobuf default) from each enum type - * 2. We use Record which REQUIRES all enum values as keys - * 3. If backend adds e.g. PARALLEL to HandleKind, TypeScript errors: - * "Property 'PARALLEL' is missing in type..." - * - * This turns silent drift into compile-time errors! - */ - -// Types that exclude the UNSPECIFIED protobuf default value -type ValidHandleKind = Exclude; -type ValidErrorHandling = Exclude; - -/** - * Helper: Creates a Schema.Literal from all values in an enum mapping. - * This ensures the schema automatically includes all mapped values. - */ -function literalFromValues>(mapping: T) { - const values = Object.values(mapping) as [string, ...string[]]; - return Schema.Literal(...values); -} - -/** - * Error handling strategies for loop nodes - * Derived from: enum ErrorHandling { Ignore, Break } in flow.tsp - * - * EXHAUSTIVE: Record forces all enum values to be present. - * If backend adds a new value, TypeScript will error until it's added here. - */ -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', - }), -); - -/** - * Source handle types for connecting nodes - * Derived from: enum HandleKind { Then, Else, Loop } in flow.tsp - * - * EXHAUSTIVE: Record forces all enum values to be present. - * If backend adds a new value (e.g., PARALLEL), TypeScript will error until it's added here. - */ -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.', - }), -); - -/** - * API documentation categories - */ -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 Schema -// ============================================================================= - -/** - * Display name for nodes - */ -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'], - }), -); - -// ============================================================================= -// Code Schema (for JS nodes) -// ============================================================================= - -/** - * JavaScript code for JS nodes - */ -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 };', - ], - }), -); - -// ============================================================================= -// Condition Expression Schema -// ============================================================================= - -/** - * Boolean condition expression using expr-lang syntax - */ -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; diff --git a/packages/spec/src/tools/generate.ts b/packages/spec/src/tools/generate.ts deleted file mode 100644 index 9b865b595..000000000 --- a/packages/spec/src/tools/generate.ts +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env tsx -/** - * JSON Schema Generator Script - * - * This script generates JSON Schema files from Effect Schema definitions. - * Run with: pnpm tsx src/tools/generate.ts - * - * Output: - * - dist/tools/schemas.json - All tool schemas as JSON - * - dist/tools/schemas.ts - TypeScript file with const exports - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { allToolSchemas, executionSchemas, explorationSchemas, mutationSchemas } from './index.ts'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Output directory -const outDir = path.resolve(__dirname, '../../dist/tools'); - -// Ensure output directory exists -fs.mkdirSync(outDir, { recursive: true }); - -// ============================================================================= -// Generate JSON file -// ============================================================================= - -const jsonOutput = { - $schema: 'http://json-schema.org/draft-07/schema#', - generatedAt: new Date().toISOString(), - tools: { - all: allToolSchemas, - execution: executionSchemas, - exploration: explorationSchemas, - mutation: mutationSchemas, - }, -}; - -fs.writeFileSync( - path.join(outDir, 'schemas.json'), - JSON.stringify(jsonOutput, null, 2), -); - -console.log('✓ Generated dist/tools/schemas.json'); - -// ============================================================================= -// Generate TypeScript file (for direct imports without runtime generation) -// ============================================================================= - -const tsOutput = `/** - * AUTO-GENERATED FILE - DO NOT EDIT - * Generated from Effect Schema definitions - * Run 'pnpm generate:schemas' to regenerate - */ - -export const allToolSchemas = ${JSON.stringify(allToolSchemas, null, 2)} as const; - -export const executionSchemas = ${JSON.stringify(executionSchemas, null, 2)} as const; - -export const explorationSchemas = ${JSON.stringify(explorationSchemas, null, 2)} as const; - -export const mutationSchemas = ${JSON.stringify(mutationSchemas, null, 2)} as const; - -// Individual schema exports -${allToolSchemas - .map( - (schema) => - `export const ${schema.name}Schema = ${JSON.stringify(schema, null, 2)} as const;`, - ) - .join('\n\n')} -`; - -fs.writeFileSync(path.join(outDir, 'schemas.ts'), tsOutput); - -console.log('✓ Generated dist/tools/schemas.ts'); - -// ============================================================================= -// Summary -// ============================================================================= - -console.log('\nGenerated schemas summary:'); -console.log(` Execution tools: ${executionSchemas.length}`); -console.log(` Exploration tools: ${explorationSchemas.length}`); -console.log(` Mutation tools: ${mutationSchemas.length}`); -console.log(` Total: ${allToolSchemas.length}`); diff --git a/packages/spec/src/tools/index.ts b/packages/spec/src/tools/index.ts deleted file mode 100644 index 6a563c707..000000000 --- a/packages/spec/src/tools/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Tool Schema Definitions using Effect Schema - * - * Single source of truth for AI tool definitions. - * Mutation tools are auto-generated from @mutationTool decorator on collection models. - */ - -import { JSONSchema, Schema } from 'effect'; - -// Re-export common schemas and generated tool schemas -export * from './common.ts'; -export * from '../../dist/ai-tools/v1/execution.ts'; -export * from '../../dist/ai-tools/v1/exploration.ts'; -export * from '../../dist/ai-tools/v1/mutation.ts'; - -// Import schema groups from generated files -import { ExecutionSchemas } from '../../dist/ai-tools/v1/execution.ts'; -import { ExplorationSchemas } from '../../dist/ai-tools/v1/exploration.ts'; -import { MutationSchemas } from '../../dist/ai-tools/v1/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; - - // Handle $ref - 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 }; - } - } - - // Handle allOf with single $ref (common Effect pattern) - 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 }; - } - } - - // Recursively process all properties - 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 (no manual listing needed) -// ============================================================================= - -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 -// ============================================================================= - -// Build schema map dynamically - no manual maintenance needed -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 - * - * @example - * const result = validateToolInput('createJsNode', { - * flowId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', - * name: 'Transform Data', - * code: 'return { result: ctx.value * 2 };' - * }); - */ -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/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index 7808c9c95..266d5ee1a 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -24,7 +24,9 @@ export const $onEmit = async (context: EmitContext) => { program, + + , join(emitterOutputDir, 'ai-tools'), @@ -255,7 +257,7 @@ const SchemaImports = ({ tools }: { tools: ResolvedTool[] }) => { {'\n'} - {'}'} from '../../../src/tools/common.ts'; + {'}'} from './common.ts'; {'\n'} @@ -347,3 +349,371 @@ const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { ); }; + +// ============================================================================= +// 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'] }; + } +} +`} + + ); +}; From 6399fd8ef7cf9193aaeb4332951df53f18a2bab5 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Wed, 28 Jan 2026 00:22:26 +0400 Subject: [PATCH 12/14] Auto-derive decorator defaults from model names for explorationTool and mutationTool Makes name, title, and description optional on explorationTool/mutationTool decorators, computing defaults from the model name using PascalCase splitting. Simplifies flow.tsp by removing redundant decorator arguments that match the derived defaults. Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 18 ++++----- tools/spec-lib/src/ai-tools/emitter.tsx | 6 +-- tools/spec-lib/src/ai-tools/lib.ts | 51 ++++++++++++++++--------- tools/spec-lib/src/ai-tools/main.tsp | 6 +-- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 507a9f53a..f9f6a5a13 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -40,7 +40,7 @@ model FlowVersion { @foreignKey flowId: Id; } -@AITools.explorationTool(#{ name: "GetFlowVariable", title: "Get Flow Variable", description: "Get a flow variable by its primary key." }) +@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." } @@ -57,7 +57,7 @@ enum HandleKind { Loop, } -@AITools.explorationTool(#{ name: "GetEdge", title: "Get Edge", description: "Get a edge by its primary key." }) +@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." }, @@ -94,10 +94,10 @@ model Position { y: float32; } -@AITools.explorationTool(#{ name: "GetNode", title: "Get Node", description: "Get a node by its primary key." }) +@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, title: "Delete Node", name: "DeleteNode", description: "Delete a node from the workflow. Also removes all connected edges." } + #{ operation: AITools.CrudOperation.Delete, description: "Delete a node from the workflow. Also removes all connected edges." } ) @TanStackDB.collection model Node { @@ -110,7 +110,7 @@ model Node { @visibility(Lifecycle.Read) info?: string; } -@AITools.explorationTool(#{ name: "GetNodeHttp", title: "Get Node Http", description: "Get a node http by its primary key." }) +@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." } ) @@ -126,7 +126,7 @@ enum ErrorHandling { Break, } -@AITools.explorationTool(#{ name: "GetNodeFor", title: "Get Node For", description: "Get a node for by its primary key." }) +@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." } ) @@ -138,7 +138,7 @@ model NodeFor { errorHandling: ErrorHandling; } -@AITools.explorationTool(#{ name: "GetNodeForEach", title: "Get Node For Each", description: "Get a node for each by its primary key." }) +@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." } ) @@ -150,7 +150,7 @@ model NodeForEach { errorHandling: ErrorHandling; } -@AITools.explorationTool(#{ name: "GetNodeCondition", title: "Get Node Condition", description: "Get a node condition by its primary key." }) +@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." } ) @@ -160,7 +160,7 @@ model NodeCondition { condition: string; } -@AITools.explorationTool(#{ name: "GetNodeJs", title: "Get Node Js", description: "Get a node js by its primary key." }) +@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." } diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index 266d5ee1a..545871e4d 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -120,9 +120,9 @@ function resolveExplorationTools(program: Program): ResolvedTool[] { tools.push({ description: toolDef.description, - name: toolDef.name, + name: toolDef.name!, properties, - title: toolDef.title, + title: toolDef.title!, }); } } @@ -141,7 +141,7 @@ function resolveMutationTools(program: Program): ResolvedTool[] { description: toolDef.description, name, properties, - title: toolDef.title, + title: toolDef.title!, }); } } diff --git a/tools/spec-lib/src/ai-tools/lib.ts b/tools/spec-lib/src/ai-tools/lib.ts index 197de6b13..508a33029 100644 --- a/tools/spec-lib/src/ai-tools/lib.ts +++ b/tools/spec-lib/src/ai-tools/lib.ts @@ -39,6 +39,10 @@ function aiTool({ program }: DecoratorContext, target: Model, options: RawAITool }); } +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 { @@ -47,7 +51,7 @@ export interface MutationToolOptions { name?: string | undefined; operation: CrudOperation; parent?: string | undefined; - title: string; + title?: string | undefined; } export const mutationTools = makeStateMap('mutationTools'); @@ -58,40 +62,51 @@ interface RawMutationToolOptions { name?: string; operation: EnumValue; parent?: string; - title: string; + title?: string; } function mutationTool({ program }: DecoratorContext, target: Model, ...tools: RawMutationToolOptions[]) { - const resolved: MutationToolOptions[] = tools.map((tool) => ({ - description: tool.description, - exclude: tool.exclude, - name: tool.name, - operation: tool.operation.value.name as CrudOperation, - parent: tool.parent, - title: tool.title, - })); + 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; - title: string; + name?: string | undefined; + title?: string | undefined; } export const explorationTools = makeStateMap('explorationTools'); interface RawExplorationToolOptions { description?: string; - name: string; - title: string; + name?: string; + title?: string; } function explorationTool({ program }: DecoratorContext, target: Model, ...tools: RawExplorationToolOptions[]) { - const resolved: ExplorationToolOptions[] = tools.map((tool) => ({ - description: tool.description, - name: tool.name, - title: tool.title, + 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 index ff813727e..7e706433d 100644 --- a/tools/spec-lib/src/ai-tools/main.tsp +++ b/tools/spec-lib/src/ai-tools/main.tsp @@ -23,7 +23,7 @@ namespace DevTools.AITools { model MutationToolOptions { operation: CrudOperation; - title: string; + title?: string; name?: string; description?: string; parent?: string; @@ -33,8 +33,8 @@ namespace DevTools.AITools { extern dec mutationTool(target: Reflection.Model, ...tools: valueof MutationToolOptions[]); model ExplorationToolOptions { - name: string; - title: string; + name?: string; + title?: string; description?: string; } From e9364c51296a2a26cbeae7b0b6aa1b0fa87490d9 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Wed, 28 Jan 2026 01:17:21 +0400 Subject: [PATCH 13/14] Replace raw string expressions with alloy-js primitives in emitter Use instead of {'\n'} and code tag instead of manual brace expressions for improved readability. Output is unchanged. Co-Authored-By: Claude Opus 4.5 --- tools/spec-lib/src/ai-tools/emitter.tsx | 46 ++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tools/spec-lib/src/ai-tools/emitter.tsx b/tools/spec-lib/src/ai-tools/emitter.tsx index 545871e4d..6a8ff96f8 100644 --- a/tools/spec-lib/src/ai-tools/emitter.tsx +++ b/tools/spec-lib/src/ai-tools/emitter.tsx @@ -1,4 +1,4 @@ -import { For, Indent, refkey, Show, SourceDirectory } from '@alloy-js/core'; +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'; @@ -198,28 +198,28 @@ const CategoryFiles = () => { {({ category, tools }) => ( - {'\n'} + {(tool) => } {'{'} - {'\n'} + {(tool) => <>{tool.name}} , - {'\n'} + {'}'} as const - {'\n\n'} + {(tool) => ( <> - export type {tool.name} = typeof {tool.name}.Type;{'\n'} + export type {tool.name} = typeof {tool.name}.Type; )} @@ -246,19 +246,19 @@ const SchemaImports = ({ tools }: { tools: ResolvedTool[] }) => { return ( <> - import {'{'} Schema {'}'} from 'effect'; - {'\n\n'} + {code`import { Schema } from 'effect';`} + 0}> import {'{'} - {'\n'} + {(name) => <>{name}} - {'\n'} + {'}'} from './common.ts'; - {'\n'} + ); @@ -270,26 +270,26 @@ const ToolSchema = ({ tool }: { tool: ResolvedTool }) => { return ( Schema.Struct({'{'} - {'\n'} + {({ optional, property }) => } - {'\n'} + {'}'}).pipe( - {'\n'} + Schema.annotations({'{'} - {'\n'} + - identifier: '{identifier}',{'\n'} - title: '{tool.title}',{'\n'} - description: {formatStringLiteral(tool.description ?? '')},{'\n'} + identifier: '{identifier}', + title: '{tool.title}', + description: {formatStringLiteral(tool.description ?? '')}, {'}'}), - {'\n'}) + ) ); }; @@ -313,14 +313,14 @@ const PropertySchema = ({ isOptional, property }: PropertySchemaProps) => { const annotatedInner = ( <> {fieldSchema.expression}.pipe( - {'\n'} + Schema.annotations({'{'} - {'\n'} - description: {formatStringLiteral(description)},{'\n'} + + description: {formatStringLiteral(description)}, {'}'}), - {'\n'}) + ) ); From 783b5c85fce37580bdc2e18de9a16f7f93caf6f2 Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Wed, 28 Jan 2026 01:32:53 +0400 Subject: [PATCH 14/14] Remove ValidateWorkflow and add NodeExecution exploration tools Co-Authored-By: Claude Opus 4.5 --- packages/spec/api/flow.tsp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index f9f6a5a13..8d3bb01df 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -2,7 +2,6 @@ using DevTools; namespace Api.Flow; -@AITools.explorationTool(#{ name: "ValidateWorkflow", title: "Validate Workflow", description: "Validate a workflow by checking its structure, connections, and node configurations for errors." }) @TanStackDB.collection model Flow { @primaryKey flowId: Id; @@ -171,13 +170,17 @@ model NodeJs { code: string; } +@AITools.explorationTool( + #{ description: "Get the execution history for a node by its ID. Returns all executions including their state (Running, Success, Failure, Canceled) and error messages for failed executions. Use this to diagnose why a node failed." }, + #{ name: "GetNodeOutput", description: "Get the input and output data from a node's most recent execution. Use this to inspect what data a node produced or received.", keyField: "nodeId" } +) @TanStackDB.collection(#{ isReadOnly: true }) model NodeExecution { @primaryKey nodeExecutionId: Id; @foreignKey nodeId: Id; name: string; state: FlowItemState; - error?: string; + @doc("Error message if the node execution failed") error?: string; input?: Protobuf.WellKnown.Json; output?: Protobuf.WellKnown.Json; httpResponseId?: Id;