diff --git a/internal/tool/batch_exec.go b/internal/tool/batch_exec.go index 97365539..215c0671 100644 --- a/internal/tool/batch_exec.go +++ b/internal/tool/batch_exec.go @@ -26,49 +26,36 @@ func (BatchExecTool) Description() string { return "Submit one or more prompts to the Anthropic Message Batches API for 50%-cost async execution. Returns a batch ID; poll with action=poll to check status." } -func (BatchExecTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"submit", "poll", "wait"}, - "description": "submit: send prompts; poll: single status check; wait: poll until the batch reaches a terminal state (with backoff + Retry-After honoring).", - }, - "prompts": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - "description": "Prompts to submit (action=submit).", - }, - "model": map[string]interface{}{ - "type": "string", - "description": "Model ID (default claude-sonnet-4-20250514).", - }, - "batch_id": map[string]interface{}{ - "type": "string", - "description": "Batch ID to poll/wait on.", - }, - "max_tokens": map[string]interface{}{ - "type": "integer", - "description": "Max output tokens per request (default 4096).", - }, - "timeout_seconds": map[string]interface{}{ - "type": "integer", - "description": "Max seconds to wait (default 600).", - }, - "poll_interval_seconds": map[string]interface{}{ - "type": "integer", - "description": "Initial poll interval in seconds (default 2).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (BatchExecTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Description: "submit: send prompts; poll: single status check; wait: poll until the batch reaches a terminal state (with backoff + Retry-After honoring).", Enum: []interface{}{"submit", "poll", "wait"}}, + "prompts": {Type: "array", Description: "Prompts to submit (action=submit).", Items: &SchemaProperty{Type: "string"}}, + "model": {Type: "string", Description: "Model ID (default claude-sonnet-4-20250514)."}, + "batch_id": {Type: "string", Description: "Batch ID to poll/wait on."}, + "max_tokens": {Type: "integer", Description: "Max output tokens per request (default 4096)."}, + "timeout_seconds": {Type: "integer", Description: "Max seconds to wait (default 600)."}, + "poll_interval_seconds": {Type: "integer", Description: "Initial poll interval in seconds (default 2)."}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } +func (BatchExecTool) Parameters() map[string]interface{} { + return batchExecSchema.ToJSONSchema() +} + +// batchExecSchema is the single source of truth for BatchExec's input schema. +var batchExecSchema = BatchExecTool{}.Schema() + var batchHTTP = &http.Client{Timeout: 5 * time.Minute} -// batchExecParams are the parsed parameters for all BatchExec actions. -type batchExecParams struct { +// BatchExecInput are the parsed parameters for all BatchExec actions. +// BatchExecInput is the typed input for BatchExecTool. +type BatchExecInput struct { Action string `json:"action"` Prompts []string `json:"prompts"` Model string `json:"model"` @@ -102,9 +89,9 @@ type batchPollResult struct { } func (BatchExecTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p batchExecParams - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[BatchExecInput]("BatchExec", input) + if err != nil { + return "", err } apiKey := batchAPIKey() @@ -130,7 +117,7 @@ func (BatchExecTool) Execute(ctx context.Context, input json.RawMessage) (string } } -func batchSubmit(ctx context.Context, apiKey string, p batchExecParams) (string, error) { +func batchSubmit(ctx context.Context, apiKey string, p BatchExecInput) (string, error) { if len(p.Prompts) == 0 { return "", fmt.Errorf("at least one prompt is required") } diff --git a/internal/tool/code_match.go b/internal/tool/code_match.go index d2b0a031..b58479b9 100644 --- a/internal/tool/code_match.go +++ b/internal/tool/code_match.go @@ -21,6 +21,14 @@ import ( // and strings cannot produce false positives. type CodeMatchTool struct{} +// CodeMatchInput is the typed input for CodeMatchTool. +type CodeMatchInput struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + Language string `json:"language"` + Limit int `json:"limit"` +} + func (CodeMatchTool) Name() string { return "CodeMatch" } func (CodeMatchTool) RiskLevel() string { return "low" } func (CodeMatchTool) Aliases() []string { return []string{"code_match", "match_code"} } @@ -29,34 +37,28 @@ func (CodeMatchTool) Description() string { return `Structural code search with tree-sitter query patterns. Matches the AST, not text: comments/strings cannot false-positive. Pattern is a tree-sitter query S-expression; use @captures to extract parts. Examples - Go functions: "(function_declaration name: (identifier) @name) @fn" | Go calls of one function: "(call_expression function: (identifier) @callee) @call" | Python defs: "(function_definition name: (identifier) @name) @fn". Language auto-detects per file; restrict with language=go|python|typescript|tsx.` } -func (CodeMatchTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "pattern": map[string]interface{}{ - "type": "string", - "description": "Tree-sitter query pattern (S-expression). Captures (@name) are returned per match.", - }, - "path": map[string]interface{}{ - "type": "string", - "description": "Project directory (default: session working directory).", - }, - "language": map[string]interface{}{ - "type": "string", - "enum": []string{"go", "python", "typescript", "tsx"}, - "description": "Restrict to one language (default: all supported).", - }, - "limit": map[string]interface{}{ - "type": "integer", - "minimum": 1, - "maximum": 200, - "description": "Maximum matches total (default 30).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (CodeMatchTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "pattern": {Type: "string", Description: "Tree-sitter query pattern (S-expression). Captures (@name) are returned per match."}, + "path": {Type: "string", Description: "Project directory (default: session working directory)."}, + "language": {Type: "string", Enum: []interface{}{"go", "python", "typescript", "tsx"}, Description: "Restrict to one language (default: all supported)."}, + "limit": {Type: "integer", Minimum: 1, Maximum: 200, Description: "Maximum matches total (default 30)."}, }, - "required": []string{"pattern"}, + Required: []string{"pattern"}, } } +func (CodeMatchTool) Parameters() map[string]interface{} { + return codeMatchSchema.ToJSONSchema() +} + +// codeMatchSchema is the single source of truth for CodeMatch's input schema. +var codeMatchSchema = CodeMatchTool{}.Schema() + // codeMatchHit is one structural match. type codeMatchHit struct { File string `json:"file"` @@ -73,14 +75,9 @@ var codeMatchExtLang = map[string]string{ } func (CodeMatchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - Pattern string `json:"pattern"` - Path string `json:"path"` - Language string `json:"language"` - Limit int `json:"limit"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[CodeMatchInput]("CodeMatch", input) + if err != nil { + return "", err } params.Pattern = strings.TrimSpace(params.Pattern) if params.Pattern == "" { diff --git a/internal/tool/code_search.go b/internal/tool/code_search.go index 90a8510c..20406467 100644 --- a/internal/tool/code_search.go +++ b/internal/tool/code_search.go @@ -10,6 +10,14 @@ import ( // CodeSearchTool searches the codebase semantically. type CodeSearchTool struct{} +// CodeSearchInput is the typed input for CodeSearchTool. +type CodeSearchInput struct { + Query string `json:"query"` + Limit int `json:"limit"` + Language string `json:"language"` + Refresh bool `json:"refresh"` +} + func (CodeSearchTool) Name() string { return "CodeSearch" } func (CodeSearchTool) RiskLevel() string { return "low" } @@ -19,40 +27,32 @@ func (CodeSearchTool) Description() string { return `Semantic code search over the local code index. Use this instead of Grep when you need to find implementations by meaning, not exact text. Start with limit=5; if results look relevant, use offset to paginate. Set refresh=true to update the index first.` } -func (CodeSearchTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "The semantic search query describing what you are looking for.", - }, - "limit": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of results to return (default 5).", - }, - "language": map[string]interface{}{ - "type": "string", - "description": "Optional language filter (e.g. go, python, typescript).", - }, - "refresh": map[string]interface{}{ - "type": "boolean", - "description": "If true, refresh the code index before searching.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (CodeSearchTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "query": {Type: "string", Description: "The semantic search query describing what you are looking for."}, + "limit": {Type: "integer", Description: "Maximum number of results to return (default 5)."}, + "language": {Type: "string", Description: "Optional language filter (e.g. go, python, typescript)."}, + "refresh": {Type: "boolean", Description: "If true, refresh the code index before searching."}, }, - "required": []string{"query"}, + Required: []string{"query"}, } } +func (CodeSearchTool) Parameters() map[string]interface{} { + return codeSearchSchema.ToJSONSchema() +} + +// codeSearchSchema is the single source of truth for CodeSearch's input schema. +var codeSearchSchema = CodeSearchTool{}.Schema() + func (CodeSearchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - Query string `json:"query"` - Limit int `json:"limit"` - Language string `json:"language"` - Refresh bool `json:"refresh"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[CodeSearchInput]("CodeSearch", input) + if err != nil { + return "", err } if params.Query == "" { diff --git a/internal/tool/diagnostics.go b/internal/tool/diagnostics.go index b2dc9cdd..1dcbd670 100644 --- a/internal/tool/diagnostics.go +++ b/internal/tool/diagnostics.go @@ -12,6 +12,12 @@ import ( // DiagnosticsTool runs lint/type/compile diagnostics for a file or project. type DiagnosticsTool struct{} +// DiagnosticsInput is the typed input for DiagnosticsTool. +type DiagnosticsInput struct { + Path string `json:"path"` + Scope string `json:"scope"` +} + func (DiagnosticsTool) Name() string { return "Diagnostics" } func (DiagnosticsTool) Aliases() []string { return []string{"diagnostics", "lint"} } func (DiagnosticsTool) Description() string { @@ -23,31 +29,30 @@ func (DiagnosticsTool) Description() string { Run this after making code changes to catch errors before committing.` } -func (DiagnosticsTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "File or directory path to diagnose", - }, - "scope": map[string]interface{}{ - "type": "string", - "description": "Scope of diagnostics: 'file' or 'project' (default: file)", - "enum": []string{"file", "project"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (DiagnosticsTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "path": {Type: "string", Description: "File or directory path to diagnose"}, + "scope": {Type: "string", Enum: []interface{}{"file", "project"}, Description: "Scope of diagnostics: 'file' or 'project' (default: file)"}, }, - "required": []string{"path"}, + Required: []string{"path"}, } } +func (DiagnosticsTool) Parameters() map[string]interface{} { + return diagnosticsSchema.ToJSONSchema() +} + +// diagnosticsSchema is the single source of truth for Diagnostics' input schema. +var diagnosticsSchema = DiagnosticsTool{}.Schema() + func (DiagnosticsTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var args struct { - Path string `json:"path"` - Scope string `json:"scope"` - } - if err := json.Unmarshal(input, &args); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + args, err := DecodeInput[DiagnosticsInput]("Diagnostics", input) + if err != nil { + return "", err } if args.Path == "" { return "", fmt.Errorf("path is required") diff --git a/internal/tool/outline.go b/internal/tool/outline.go index 00c91c18..3c3a5d2d 100644 --- a/internal/tool/outline.go +++ b/internal/tool/outline.go @@ -15,6 +15,12 @@ import ( // reading the full content. Returns a compact outline with line numbers. type OutlineTool struct{} +// OutlineInput is the typed input for OutlineTool. +type OutlineInput struct { + FilePath string `json:"file_path"` + FilePaths []string `json:"file_paths,omitempty"` +} + func (OutlineTool) Name() string { return "Outline" } func (OutlineTool) RiskLevel() string { return "low" } func (OutlineTool) Aliases() []string { return []string{"outline"} } @@ -22,31 +28,30 @@ func (OutlineTool) Description() string { return "Extract function/type/class signatures from one or more files. Returns a compact outline with line numbers — much cheaper than reading the full file." } -func (OutlineTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "file_path": map[string]interface{}{ - "type": "string", - "description": "Path to a single file, relative to the project root (e.g. 'src/main.go')", - }, - "file_paths": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - "description": "Paths to multiple files. Use instead of file_path to outline several files in one call (max 20).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (OutlineTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "file_path": {Type: "string", Description: "Path to a single file, relative to the project root (e.g. 'src/main.go')"}, + "file_paths": {Type: "array", Items: &SchemaProperty{Type: "string"}, Description: "Paths to multiple files. Use instead of file_path to outline several files in one call (max 20)."}, }, } } +func (OutlineTool) Parameters() map[string]interface{} { + return outlineSchema.ToJSONSchema() +} + +// outlineSchema is the single source of truth for Outline's input schema. +var outlineSchema = OutlineTool{}.Schema() + const outlineMaxFiles = 20 func (OutlineTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var in struct { - FilePath string `json:"file_path"` - FilePaths []string `json:"file_paths,omitempty"` - } - if err := json.Unmarshal(input, &in); err != nil { + in, err := DecodeInput[OutlineInput]("Outline", input) + if err != nil { return "", err } diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index 10ad8f11..6b288191 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -279,3 +279,96 @@ func TestGitHistorySchemaProvider(t *testing.T) { t.Fatalf("required = %v, want [action]", GitHistoryTool{}.Parameters()["required"]) } } + +func TestBatchExecSchemaProvider(t *testing.T) { + var _ SchemaProvider = BatchExecTool{} + props := schemaProps(t, BatchExecTool{}.Parameters()) + action, ok := props["action"].(map[string]interface{}) + if !ok || action["type"] != "string" { + t.Fatalf("action prop = %v, want string type", props["action"]) + } + enum, ok := action["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "submit" || enum[2] != "wait" { + t.Fatalf("action enum = %v, want [submit poll wait]", action["enum"]) + } + prompts, ok := props["prompts"].(map[string]interface{}) + if !ok || prompts["type"] != "array" { + t.Fatalf("prompts prop = %v, want array type", props["prompts"]) + } + if prompts["items"].(map[string]interface{})["type"] != "string" { + t.Fatalf("prompts items = %v, want string", prompts["items"]) + } + req, _ := BatchExecTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", BatchExecTool{}.Parameters()["required"]) + } +} + +func TestDiagnosticsSchemaProvider(t *testing.T) { + var _ SchemaProvider = DiagnosticsTool{} + props := schemaProps(t, DiagnosticsTool{}.Parameters()) + if props["path"].(map[string]interface{})["type"] != "string" { + t.Fatal("path type wrong") + } + enum, ok := props["scope"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 2 || enum[0] != "file" || enum[1] != "project" { + t.Fatalf("scope enum = %v, want [file project]", props["scope"]) + } + req, _ := DiagnosticsTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "path" { + t.Fatalf("required = %v, want [path]", DiagnosticsTool{}.Parameters()["required"]) + } +} + +func TestCodeSearchSchemaProvider(t *testing.T) { + var _ SchemaProvider = CodeSearchTool{} + props := schemaProps(t, CodeSearchTool{}.Parameters()) + for field, want := range map[string]string{"query": "string", "limit": "integer", "language": "string", "refresh": "boolean"} { + if props[field].(map[string]interface{})["type"] != want { + t.Fatalf("%s type = %v, want %s", field, props[field], want) + } + } + req, _ := CodeSearchTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "query" { + t.Fatalf("required = %v, want [query]", CodeSearchTool{}.Parameters()["required"]) + } +} + +func TestCodeMatchSchemaProvider(t *testing.T) { + var _ SchemaProvider = CodeMatchTool{} + props := schemaProps(t, CodeMatchTool{}.Parameters()) + if props["pattern"].(map[string]interface{})["type"] != "string" { + t.Fatal("pattern type wrong") + } + enum, ok := props["language"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 { + t.Fatalf("language enum = %v, want 4 options", props["language"]) + } + limit := props["limit"].(map[string]interface{}) + if limit["minimum"] != 1 || limit["maximum"] != 200 { + t.Fatalf("limit bounds = %v/%v, want 1/200", limit["minimum"], limit["maximum"]) + } + req, _ := CodeMatchTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "pattern" { + t.Fatalf("required = %v, want [pattern]", CodeMatchTool{}.Parameters()["required"]) + } +} + +func TestOutlineSchemaProvider(t *testing.T) { + var _ SchemaProvider = OutlineTool{} + props := schemaProps(t, OutlineTool{}.Parameters()) + if props["file_path"].(map[string]interface{})["type"] != "string" { + t.Fatal("file_path type wrong") + } + fp, ok := props["file_paths"].(map[string]interface{}) + if !ok || fp["type"] != "array" { + t.Fatalf("file_paths prop = %v, want array", props["file_paths"]) + } + if fp["items"].(map[string]interface{})["type"] != "string" { + t.Fatalf("file_paths items = %v, want string", fp["items"]) + } + _, hasRequired := OutlineTool{}.Parameters()["required"] + if hasRequired { + t.Fatalf("Outline has no required fields, got %v", OutlineTool{}.Parameters()["required"]) + } +}