diff --git a/internal/tool/codegen.go b/internal/tool/codegen.go index 8033821b..740f127c 100644 --- a/internal/tool/codegen.go +++ b/internal/tool/codegen.go @@ -229,37 +229,31 @@ func (t *CodeGenTool) Description() string { return "Generate common code patterns from templates. Supports Go, Python, and TypeScript templates for handlers, tests, middleware, CRUD operations, and more." } -func (t *CodeGenTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"generate", "list", "preview", "suggest"}, - "description": "Action to perform", - }, - "template": map[string]interface{}{ - "type": "string", - "description": "Template name (e.g., go-handler, py-fastapi-endpoint)", - }, - "variables": map[string]interface{}{ - "type": "object", - "description": "Template variables as key-value pairs", - }, - "language": map[string]interface{}{ - "type": "string", - "description": "Filter templates by language (go, python, typescript)", - }, - "description": map[string]interface{}{ - "type": "string", - "description": "Natural language description for template suggestion", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (t *CodeGenTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"generate", "list", "preview", "suggest"}, Description: "Action to perform"}, + "template": {Type: "string", Description: "Template name (e.g., go-handler, py-fastapi-endpoint)"}, + "variables": {Type: "object", Description: "Template variables as key-value pairs"}, + "language": {Type: "string", Description: "Filter templates by language (go, python, typescript)"}, + "description": {Type: "string", Description: "Natural language description for template suggestion"}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } -type codeGenInput struct { +func (t *CodeGenTool) Parameters() map[string]interface{} { + return codeGenSchema.ToJSONSchema() +} + +// codeGenSchema is the single source of truth for CodeGen's input schema. +var codeGenSchema = (&CodeGenTool{}).Schema() + +// CodeGenInput is the typed input for CodeGenTool. +type CodeGenInput struct { Action string `json:"action"` Template string `json:"template"` Variables map[string]string `json:"variables"` @@ -267,12 +261,14 @@ type codeGenInput struct { Description string `json:"description"` } +// codeGenInput is an unexported alias for backward compatibility with tests. +type codeGenInput = CodeGenInput + func (t *CodeGenTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var in codeGenInput - if err := json.Unmarshal(input, &in); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + in, err := DecodeInput[CodeGenInput]("CodeGen", input) + if err != nil { + return "", err } - switch in.Action { case "generate": if in.Template == "" { diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index 9bd7de94..98215918 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -994,3 +994,100 @@ func TestRefactorSchemaProvider(t *testing.T) { t.Fatalf("required = %v, want [action file]", RefactorTool{}.Parameters()["required"]) } } + +func TestSQLSchemaProvider(t *testing.T) { + var _ SchemaProvider = SQLTool{} + props := schemaProps(t, SQLTool{}.Parameters()) + enum, ok := props["driver"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "sqlite" || enum[2] != "mysql" { + t.Fatalf("driver enum = %v, want 3 options", props["driver"]) + } + req, _ := SQLTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "dsn" || req[1] != "query" { + t.Fatalf("required = %v, want [dsn query]", SQLTool{}.Parameters()["required"]) + } +} + +func TestStructuredEditSchemaProvider(t *testing.T) { + var _ SchemaProvider = StructuredEditTool{} + props := schemaProps(t, StructuredEditTool{}.Parameters()) + if props["path"].(map[string]interface{})["type"] != "string" { + t.Fatal("path type wrong") + } + blocks := props["blocks"].(map[string]interface{}) + if blocks["type"] != "array" { + t.Fatalf("blocks type = %v, want array", blocks["type"]) + } + items := blocks["items"].(map[string]interface{}) + itemReq, ok := items["required"].([]string) + if !ok || len(itemReq) != 2 || itemReq[0] != "search" || itemReq[1] != "replace" { + t.Fatalf("items required = %v, want [search replace]", items["required"]) + } + req, _ := StructuredEditTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "path" || req[1] != "blocks" { + t.Fatalf("required = %v, want [path blocks]", StructuredEditTool{}.Parameters()["required"]) + } +} + +func TestVerifyPlanExecutionSchemaProvider(t *testing.T) { + var _ SchemaProvider = VerifyPlanExecutionTool{} + props := schemaProps(t, VerifyPlanExecutionTool{}.Parameters()) + steps := props["plan_steps"].(map[string]interface{}) + if steps["type"] != "array" { + t.Fatalf("plan_steps type = %v, want array", steps["type"]) + } + itemProps := steps["items"].(map[string]interface{})["properties"].(map[string]interface{}) + if itemProps["description"].(map[string]interface{})["type"] != "string" { + t.Fatal("description type wrong") + } + if itemProps["expected"].(map[string]interface{})["type"] != "string" { + t.Fatal("expected type wrong") + } + req, _ := VerifyPlanExecutionTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "plan_steps" { + t.Fatalf("required = %v, want [plan_steps]", VerifyPlanExecutionTool{}.Parameters()["required"]) + } +} + +func TestWorkflowSchemaProvider(t *testing.T) { + var _ SchemaProvider = WorkflowTool{} + props := schemaProps(t, WorkflowTool{}.Parameters()) + if props["workflow"].(map[string]interface{})["type"] != "string" { + t.Fatal("workflow type wrong") + } + if props["args"].(map[string]interface{})["type"] != "object" { + t.Fatal("args type wrong") + } + req, _ := WorkflowTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "workflow" { + t.Fatalf("required = %v, want [workflow]", WorkflowTool{}.Parameters()["required"]) + } +} + +func TestCodeGenSchemaProvider(t *testing.T) { + var inter SchemaProvider = (&CodeGenTool{}) + _ = inter + props := schemaProps(t, (&CodeGenTool{}).Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "generate" || enum[3] != "suggest" { + t.Fatalf("action enum = %v, want 4 options", props["action"]) + } + req, _ := (&CodeGenTool{}).Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", (&CodeGenTool{}).Parameters()["required"]) + } +} + +func TestTicketComplianceSchemaProvider(t *testing.T) { + var inter SchemaProvider = (&TicketComplianceTool{}) + _ = inter + props := schemaProps(t, (&TicketComplianceTool{}).Parameters()) + enum, ok := props["ticket_source"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "github" || enum[2] != "linear" { + t.Fatalf("ticket_source enum = %v, want 3 options", props["ticket_source"]) + } + req, _ := (&TicketComplianceTool{}).Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "ticket_content" || req[1] != "diff" { + t.Fatalf("required = %v, want [ticket_content diff]", (&TicketComplianceTool{}).Parameters()["required"]) + } +} diff --git a/internal/tool/sql.go b/internal/tool/sql.go index f75e598e..7f7385ad 100644 --- a/internal/tool/sql.go +++ b/internal/tool/sql.go @@ -24,6 +24,15 @@ import ( // dialect light up without further changes here. type SQLTool struct{} +// SQLInput is the typed input for SQLTool. +type SQLInput struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` + Query string `json:"query"` + AllowWrite bool `json:"allow_write"` + MaxRows int `json:"max_rows"` +} + func (SQLTool) Name() string { return "SQL" } func (SQLTool) Aliases() []string { return []string{"sql", "sql_query"} } @@ -40,38 +49,29 @@ func (SQLTool) Description() string { // can prompt when appropriate. func (SQLTool) RiskLevel() string { return "medium" } -func (SQLTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "driver": map[string]interface{}{ - "type": "string", - "description": "Database dialect: one of sqlite, postgres, mysql. Defaults to sqlite.", - "enum": []string{"sqlite", "postgres", "mysql"}, - }, - "dsn": map[string]interface{}{ - "type": "string", - "description": "Data source name / connection string. For sqlite " + - "this is the file path (or \":memory:\").", - }, - "query": map[string]interface{}{ - "type": "string", - "description": "The SQL statement to execute.", - }, - "allow_write": map[string]interface{}{ - "type": "boolean", - "description": "Allow destructive / mutating statements. When false " + - "(the default) only read-only queries are permitted.", - }, - "max_rows": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of rows to return (default 100).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SQLTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "driver": {Type: "string", Enum: []interface{}{"sqlite", "postgres", "mysql"}, Description: "Database dialect: one of sqlite, postgres, mysql. Defaults to sqlite."}, + "dsn": {Type: "string", Description: `Data source name / connection string. For sqlite this is the file path (or ":memory:").`}, + "query": {Type: "string", Description: "The SQL statement to execute."}, + "allow_write": {Type: "boolean", Description: "Allow destructive / mutating statements. When false (the default) only read-only queries are permitted."}, + "max_rows": {Type: "integer", Description: "Maximum number of rows to return (default 100)."}, }, - "required": []string{"dsn", "query"}, + Required: []string{"dsn", "query"}, } } +func (SQLTool) Parameters() map[string]interface{} { + return sqlSchema.ToJSONSchema() +} + +// sqlSchema is the single source of truth for SQL's input schema. +var sqlSchema = SQLTool{}.Schema() + // driverName maps a user-facing dialect to its database/sql driver name. The // bool reports whether rho bundles a driver for that dialect. func sqlDriverName(dialect string) (driver string, bundled bool, err error) { @@ -250,14 +250,8 @@ func firstSQLKeyword(query string) string { } func (t SQLTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Driver string `json:"driver"` - DSN string `json:"dsn"` - Query string `json:"query"` - AllowWrite bool `json:"allow_write"` - MaxRows int `json:"max_rows"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SQLInput]("SQL", input) + if err != nil { return "", err } if strings.TrimSpace(p.DSN) == "" { diff --git a/internal/tool/structured_edit.go b/internal/tool/structured_edit.go index 70bfcc53..e9a94a7e 100644 --- a/internal/tool/structured_edit.go +++ b/internal/tool/structured_edit.go @@ -21,41 +21,42 @@ func (StructuredEditTool) Description() string { return "Apply search-and-replace edits to a file. Provide one or more SEARCH/REPLACE blocks. Each block finds exact text and replaces it. The SEARCH text must match the file contents exactly, including whitespace." } -func (StructuredEditTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "Path to the file to edit", - }, - "blocks": map[string]interface{}{ - "type": "array", - "description": "List of SEARCH/REPLACE blocks", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "search": map[string]interface{}{ - "type": "string", - "description": "Exact text to find. Must match the file exactly, including whitespace.", - }, - "replace": map[string]interface{}{ - "type": "string", - "description": "Text to replace the search text with.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (StructuredEditTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "path": {Type: "string", Description: "Path to the file to edit"}, + "blocks": { + Type: "array", + Description: "List of SEARCH/REPLACE blocks", + Items: &SchemaProperty{ + Type: "object", + Properties: map[string]SchemaProperty{ + "search": {Type: "string", Description: "Exact text to find. Must match the file exactly, including whitespace."}, + "replace": {Type: "string", Description: "Text to replace the search text with."}, }, - "required": []string{"search", "replace"}, + Required: []string{"search", "replace"}, }, }, - "auto_format": map[string]interface{}{ - "type": "boolean", - "description": "If true, ignore insignificant whitespace differences when matching (default: false)", - }, + "auto_format": {Type: "boolean", Description: "If true, ignore insignificant whitespace differences when matching (default: false)"}, }, - "required": []string{"path", "blocks"}, + Required: []string{"path", "blocks"}, } } +func (StructuredEditTool) Parameters() map[string]interface{} { + return structuredEditSchema.ToJSONSchema() +} + +// structuredEditSchema is the single source of truth for StructuredEdit's input schema. +var structuredEditSchema = StructuredEditTool{}.Schema() + +// StructuredEditInput is the typed input for StructuredEditTool. +// Aliased from the legacy unexported name. +type StructuredEditInput = structuredEditInput + type structuredEditInput struct { Path string `json:"path"` Blocks []searchReplaceBlock `json:"blocks"` @@ -68,11 +69,10 @@ type searchReplaceBlock struct { } func (s StructuredEditTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p structuredEditInput - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[structuredEditInput]("StructuredEdit", input) + if err != nil { + return "", err } - if p.Path == "" { return "", fmt.Errorf("path is required") } diff --git a/internal/tool/ticket_compliance.go b/internal/tool/ticket_compliance.go index 8ffc636c..e82d5b6c 100644 --- a/internal/tool/ticket_compliance.go +++ b/internal/tool/ticket_compliance.go @@ -385,58 +385,47 @@ func (t *TicketComplianceTool) Description() string { } // Parameters returns the JSON schema for the tool's input. -func (t *TicketComplianceTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "branch_name": map[string]interface{}{ - "type": "string", - "description": "The current branch name to extract ticket references from", - }, - "pr_description": map[string]interface{}{ - "type": "string", - "description": "The PR description/body text to extract ticket references from", - }, - "ticket_content": map[string]interface{}{ - "type": "string", - "description": "The raw ticket/issue content including title, description, and acceptance criteria", - }, - "ticket_id": map[string]interface{}{ - "type": "string", - "description": "The ticket/issue identifier (e.g., RHO-123, #42)", - }, - "ticket_source": map[string]interface{}{ - "type": "string", - "description": "The ticket source system: github, jira, or linear", - "enum": []string{"github", "jira", "linear"}, - }, - "diff": map[string]interface{}{ - "type": "string", - "description": "The PR diff to check against ticket requirements", - }, - "commit_messages": map[string]interface{}{ - "type": "string", - "description": "The commit messages in the PR", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (t *TicketComplianceTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "branch_name": {Type: "string", Description: "The current branch name to extract ticket references from"}, + "pr_description": {Type: "string", Description: "The PR description/body text to extract ticket references from"}, + "ticket_content": {Type: "string", Description: "The raw ticket/issue content including title, description, and acceptance criteria"}, + "ticket_id": {Type: "string", Description: "The ticket/issue identifier (e.g., RHO-123, #42)"}, + "ticket_source": {Type: "string", Enum: []interface{}{"github", "jira", "linear"}, Description: "The ticket source system: github, jira, or linear"}, + "diff": {Type: "string", Description: "The PR diff to check against ticket requirements"}, + "commit_messages": {Type: "string", Description: "The commit messages in the PR"}, }, - "required": []string{"ticket_content", "diff"}, + Required: []string{"ticket_content", "diff"}, } } +func (t *TicketComplianceTool) Parameters() map[string]interface{} { + return ticketComplianceSchema.ToJSONSchema() +} + +// ticketComplianceSchema is the single source of truth for TicketCompliance's input schema. +var ticketComplianceSchema = (&TicketComplianceTool{}).Schema() + +// TicketComplianceInput is the typed input for TicketComplianceTool. +type TicketComplianceInput struct { + BranchName string `json:"branch_name"` + PRDescription string `json:"pr_description"` + TicketContent string `json:"ticket_content"` + TicketID string `json:"ticket_id"` + TicketSource string `json:"ticket_source"` + Diff string `json:"diff"` + CommitMsgs string `json:"commit_messages"` +} + // Execute runs the ticket compliance tool. func (t *TicketComplianceTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - BranchName string `json:"branch_name"` - PRDescription string `json:"pr_description"` - TicketContent string `json:"ticket_content"` - TicketID string `json:"ticket_id"` - TicketSource string `json:"ticket_source"` - Diff string `json:"diff"` - CommitMsgs string `json:"commit_messages"` - } - - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[TicketComplianceInput]("TicketCompliance", input) + if err != nil { + return "", err } if params.TicketContent == "" { diff --git a/internal/tool/verify_plan.go b/internal/tool/verify_plan.go index fe2f797f..b41a0703 100644 --- a/internal/tool/verify_plan.go +++ b/internal/tool/verify_plan.go @@ -10,40 +10,52 @@ import ( // VerifyPlanExecutionTool checks whether a plan's steps have been executed correctly. type VerifyPlanExecutionTool struct{} +// VerifyPlanExecutionInput is the typed input for VerifyPlanExecutionTool. +type VerifyPlanExecutionInput struct { + PlanSteps []struct { + Description string `json:"description"` + Expected string `json:"expected"` + } `json:"plan_steps"` +} + func (VerifyPlanExecutionTool) Name() string { return "VerifyPlanExecution" } func (VerifyPlanExecutionTool) Aliases() []string { return []string{"verify_plan_execution"} } func (VerifyPlanExecutionTool) Description() string { return "Verify that a plan's steps have been executed correctly by checking task completion and file changes" } -func (VerifyPlanExecutionTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "plan_steps": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "description": map[string]interface{}{"type": "string"}, - "expected": map[string]interface{}{"type": "string"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (VerifyPlanExecutionTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "plan_steps": { + Type: "array", + Description: "Steps to verify with their expected outcomes", + Items: &SchemaProperty{ + Type: "object", + Properties: map[string]SchemaProperty{ + "description": {Type: "string"}, + "expected": {Type: "string"}, }, }, - "description": "Steps to verify with their expected outcomes", }, }, - "required": []string{"plan_steps"}, + Required: []string{"plan_steps"}, } } +func (VerifyPlanExecutionTool) Parameters() map[string]interface{} { + return verifyPlanSchema.ToJSONSchema() +} + +// verifyPlanSchema is the single source of truth for VerifyPlanExecution's input schema. +var verifyPlanSchema = VerifyPlanExecutionTool{}.Schema() + func (VerifyPlanExecutionTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - PlanSteps []struct { - Description string `json:"description"` - Expected string `json:"expected"` - } `json:"plan_steps"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[VerifyPlanExecutionInput]("VerifyPlanExecution", input) + if err != nil { return "", err } if len(p.PlanSteps) == 0 { diff --git a/internal/tool/workflow.go b/internal/tool/workflow.go index 14088865..815d1a76 100644 --- a/internal/tool/workflow.go +++ b/internal/tool/workflow.go @@ -31,35 +31,41 @@ type WorkflowStep struct { // WorkflowTool executes scripted workflows. type WorkflowTool struct{} +// WorkflowInput is the typed input for WorkflowTool. +type WorkflowInput struct { + Workflow string `json:"workflow"` + Args map[string]any `json:"args"` +} + func (WorkflowTool) Name() string { return "Workflow" } func (WorkflowTool) Aliases() []string { return []string{"workflow"} } func (WorkflowTool) Description() string { return "Execute a scripted workflow" } -func (WorkflowTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "workflow": map[string]interface{}{ - "type": "string", - "description": "Name of the workflow to execute", - }, - "args": map[string]interface{}{ - "type": "object", - "description": "Arguments to pass to the workflow", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (WorkflowTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "workflow": {Type: "string", Description: "Name of the workflow to execute"}, + "args": {Type: "object", Description: "Arguments to pass to the workflow"}, }, - "required": []string{"workflow"}, + Required: []string{"workflow"}, } } +func (WorkflowTool) Parameters() map[string]interface{} { + return workflowSchema.ToJSONSchema() +} + +// workflowSchema is the single source of truth for Workflow's input schema. +var workflowSchema = WorkflowTool{}.Schema() + func (WorkflowTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Workflow string `json:"workflow"` - Args map[string]any `json:"args"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[WorkflowInput]("Workflow", input) + if err != nil { return "", err } if p.Workflow == "" {