From 77efa904d03e7c46c7915586da55506936bd337b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 16 Sep 2026 14:19:34 +0530 Subject: [PATCH] =?UTF-8?q?refactor(tool):=20migrate=20ProjectVerify/Refac?= =?UTF-8?q?tor/Schedule=C3=973/SearchX/SessionQuery/SmartReader=20to=20typ?= =?UTF-8?q?ed=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tool/project_verify.go | 54 +++++------ internal/tool/refactor.go | 127 ++++++++++---------------- internal/tool/schedule.go | 141 ++++++++++++++++------------- internal/tool/schema_batch_test.go | 105 +++++++++++++++++++++ internal/tool/search_x.go | 44 +++++---- internal/tool/session_query.go | 72 +++++++-------- internal/tool/smart_reader.go | 74 +++++++-------- 7 files changed, 353 insertions(+), 264 deletions(-) diff --git a/internal/tool/project_verify.go b/internal/tool/project_verify.go index ea19499f..9acab600 100644 --- a/internal/tool/project_verify.go +++ b/internal/tool/project_verify.go @@ -19,6 +19,13 @@ import ( // to guess a build or test command. type ProjectVerifyTool struct{} +// ProjectVerifyInput is the typed input for ProjectVerifyTool. +type ProjectVerifyInput struct { + Action string `json:"action"` + Path string `json:"path"` + TimeoutSeconds int `json:"timeout_seconds"` +} + func (ProjectVerifyTool) Name() string { return "ProjectVerify" } func (ProjectVerifyTool) RiskLevel() string { return "medium" } func (ProjectVerifyTool) Aliases() []string { return []string{"project-verify", "verify_project"} } @@ -26,30 +33,27 @@ func (ProjectVerifyTool) Description() string { return "Detect the project stack and run bounded build, test, lint, or format checks using fixed argument lists (no shell interpolation). Returns structured results with exit codes and durations." } -func (ProjectVerifyTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"detect", "build", "test", "lint", "format", "all"}, - "description": "Verification action. detect only inspects files; all runs build, test, lint, and format checks that are available.", - }, - "path": map[string]interface{}{ - "type": "string", - "description": "Project directory (default: session working directory).", - }, - "timeout_seconds": map[string]interface{}{ - "type": "integer", - "minimum": 1, - "maximum": 600, - "description": "Per-command timeout (default 120 seconds, max 600).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ProjectVerifyTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"detect", "build", "test", "lint", "format", "all"}, Description: "Verification action. detect only inspects files; all runs build, test, lint, and format checks that are available."}, + "path": {Type: "string", Description: "Project directory (default: session working directory)."}, + "timeout_seconds": {Type: "integer", Minimum: 1, Maximum: 600, Description: "Per-command timeout (default 120 seconds, max 600)."}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } +func (ProjectVerifyTool) Parameters() map[string]interface{} { + return projectVerifySchema.ToJSONSchema() +} + +// projectVerifySchema is the single source of truth for ProjectVerify's input schema. +var projectVerifySchema = ProjectVerifyTool{}.Schema() + type projectStack struct { Root string `json:"root"` Markers []string `json:"markers"` @@ -67,13 +71,9 @@ type verificationResult struct { } func (ProjectVerifyTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - Action string `json:"action"` - Path string `json:"path"` - TimeoutSeconds int `json:"timeout_seconds"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[ProjectVerifyInput]("ProjectVerify", input) + if err != nil { + return "", err } params.Action = strings.ToLower(strings.TrimSpace(params.Action)) if params.Action == "" { diff --git a/internal/tool/refactor.go b/internal/tool/refactor.go index 7ab8bdbe..10c546aa 100644 --- a/internal/tool/refactor.go +++ b/internal/tool/refactor.go @@ -772,95 +772,61 @@ func NewRefactorTool() *RefactorTool { } func (RefactorTool) Name() string { return "Refactor" } + +// RefactorInput is the typed input for RefactorTool. +type RefactorInput struct { + Action string `json:"action"` + File string `json:"file"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + NewName string `json:"new_name"` + OldName string `json:"old_name"` + Line int `json:"line"` + Expr string `json:"expr"` + VarName string `json:"var_name"` + Context string `json:"context"` + TestFunc string `json:"test_func"` + FuncName string `json:"func_name"` +} + func (RefactorTool) Description() string { return "Apply common refactoring patterns (extract function, rename symbol, inline variable, extract variable, add error check, wrap with context, convert to table test, sort imports, remove unused params) without LLM calls." } -func (RefactorTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Refactoring action: extract_function, rename_symbol, inline_variable, extract_variable, add_error_check, wrap_with_context, convert_table_test, sort_imports, remove_unused_params", - "enum": []string{ - "extract_function", - "rename_symbol", - "inline_variable", - "extract_variable", - "add_error_check", - "wrap_with_context", - "convert_table_test", - "sort_imports", - "remove_unused_params", - }, - }, - "file": map[string]interface{}{ - "type": "string", - "description": "Absolute path to the file to refactor", - }, - "start_line": map[string]interface{}{ - "type": "integer", - "description": "Start line (1-based) for extract_function", - }, - "end_line": map[string]interface{}{ - "type": "integer", - "description": "End line (1-based) for extract_function", - }, - "new_name": map[string]interface{}{ - "type": "string", - "description": "New name for extract_function or rename_symbol", - }, - "old_name": map[string]interface{}{ - "type": "string", - "description": "Old symbol name for rename_symbol", - }, - "line": map[string]interface{}{ - "type": "integer", - "description": "Line number for inline_variable, extract_variable, add_error_check, or wrap_with_context", - }, - "expr": map[string]interface{}{ - "type": "string", - "description": "Expression to extract for extract_variable", - }, - "var_name": map[string]interface{}{ - "type": "string", - "description": "Variable name for extract_variable", - }, - "context": map[string]interface{}{ - "type": "string", - "description": "Context message for wrap_with_context", - }, - "test_func": map[string]interface{}{ - "type": "string", - "description": "Test function name for convert_table_test", - }, - "func_name": map[string]interface{}{ - "type": "string", - "description": "Function name for remove_unused_params", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (RefactorTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"extract_function", "rename_symbol", "inline_variable", "extract_variable", "add_error_check", "wrap_with_context", "convert_table_test", "sort_imports", "remove_unused_params"}, Description: "Refactoring action: extract_function, rename_symbol, inline_variable, extract_variable, add_error_check, wrap_with_context, convert_table_test, sort_imports, remove_unused_params"}, + "file": {Type: "string", Description: "Absolute path to the file to refactor"}, + "start_line": {Type: "integer", Description: "Start line (1-based) for extract_function"}, + "end_line": {Type: "integer", Description: "End line (1-based) for extract_function"}, + "new_name": {Type: "string", Description: "New name for extract_function or rename_symbol"}, + "old_name": {Type: "string", Description: "Old symbol name for rename_symbol"}, + "line": {Type: "integer", Description: "Line number for inline_variable, extract_variable, add_error_check, or wrap_with_context"}, + "expr": {Type: "string", Description: "Expression to extract for extract_variable"}, + "var_name": {Type: "string", Description: "Variable name for extract_variable"}, + "context": {Type: "string", Description: "Context message for wrap_with_context"}, + "test_func": {Type: "string", Description: "Test function name for convert_table_test"}, + "func_name": {Type: "string", Description: "Function name for remove_unused_params"}, }, - "required": []string{"action", "file"}, + Required: []string{"action", "file"}, } } +func (RefactorTool) Parameters() map[string]interface{} { + return refactorSchema.ToJSONSchema() +} + +// refactorSchema is the single source of truth for Refactor's input schema. +var refactorSchema = RefactorTool{}.Schema() + func (rt RefactorTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - File string `json:"file"` - StartLine int `json:"start_line"` - EndLine int `json:"end_line"` - NewName string `json:"new_name"` - OldName string `json:"old_name"` - Line int `json:"line"` - Expr string `json:"expr"` - VarName string `json:"var_name"` - Context string `json:"context"` - TestFunc string `json:"test_func"` - FuncName string `json:"func_name"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[RefactorInput]("Refactor", input) + if err != nil { + return "", err } if p.Action == "" { return "", fmt.Errorf("action is required") @@ -874,7 +840,6 @@ func (rt RefactorTool) Execute(ctx context.Context, input json.RawMessage) (stri ref := rt.refactorer var result *RefactoringResult - var err error switch p.Action { case "extract_function": diff --git a/internal/tool/schedule.go b/internal/tool/schedule.go index 74985b87..90e7300b 100644 --- a/internal/tool/schedule.go +++ b/internal/tool/schedule.go @@ -24,47 +24,46 @@ func (ScheduleCreateTool) Description() string { return "Create an in-conversation scheduled reminder or recurring task whose state lives in the session log." } -func (ScheduleCreateTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "prompt": map[string]interface{}{ - "type": "string", - "description": "Reminder or instruction prompt to deliver into the conversation when due", - }, - "duration": map[string]interface{}{ - "type": "string", - "description": "Relative duration from now until due (e.g. '10m', '1h', '30s')", - }, - "at": map[string]interface{}{ - "type": "string", - "description": "Optional absolute due time in RFC3339 format (e.g. '2026-08-20T10:00:00Z')", - }, - "recurring": map[string]interface{}{ - "type": "boolean", - "description": "Whether the reminder should repeat periodically", - }, - "interval": map[string]interface{}{ - "type": "string", - "description": "Repeat interval if recurring (e.g. '15m', '1h', '24h')", - }, +// ScheduleCreateInput is the typed input for ScheduleCreateTool. +type ScheduleCreateInput struct { + Prompt string `json:"prompt"` + Duration string `json:"duration"` + At string `json:"at"` + Recurring bool `json:"recurring"` + Interval string `json:"interval"` +} + +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ScheduleCreateTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "prompt": {Type: "string", Description: "Reminder or instruction prompt to deliver into the conversation when due"}, + "duration": {Type: "string", Description: "Relative duration from now until due (e.g. '10m', '1h', '30s')"}, + "at": {Type: "string", Description: "Optional absolute due time in RFC3339 format (e.g. '2026-08-20T10:00:00Z')"}, + "recurring": {Type: "boolean", Description: "Whether the reminder should repeat periodically"}, + "interval": {Type: "string", Description: "Repeat interval if recurring (e.g. '15m', '1h', '24h')"}, }, - "required": []string{"prompt"}, + Required: []string{"prompt"}, } } +func (ScheduleCreateTool) Parameters() map[string]interface{} { + return scheduleCreateSchema.ToJSONSchema() +} + +// scheduleCreateSchema is the single source of truth for ScheduleCreate's input schema. +var scheduleCreateSchema = ScheduleCreateTool{}.Schema() + func (t ScheduleCreateTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - Prompt string `json:"prompt"` - Duration string `json:"duration"` - At string `json:"at"` - Recurring bool `json:"recurring"` - Interval string `json:"interval"` - } - if len(input) > 0 { - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + var p ScheduleCreateInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[ScheduleCreateInput]("ScheduleCreate", input) + if err != nil { + return "", err } + p = decoded } if strings.TrimSpace(p.Prompt) == "" { @@ -115,19 +114,30 @@ type ScheduleListTool struct { Manager *schedule.Manager } -func (ScheduleListTool) Name() string { return "ScheduleList" } +func (ScheduleListTool) Name() string { return "ScheduleList" } + +// ScheduleListInput is the typed input for ScheduleListTool. It declares an +// empty schema: the tool takes no arguments. +type ScheduleListInput struct{} + func (ScheduleListTool) Aliases() []string { return []string{"schedule_list", "schedule-list"} } func (ScheduleListTool) Description() string { return "List all active in-conversation scheduled reminders and tasks for this session." } +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ScheduleListTool) Schema() ToolSchema { + return ToolSchema{Type: "object"} +} + func (ScheduleListTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - } + return scheduleListSchema.ToJSONSchema() } +// scheduleListSchema is the single source of truth for ScheduleList's input schema. +var scheduleListSchema = ScheduleListTool{}.Schema() + func (t ScheduleListTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { mgr := t.Manager if mgr == nil { @@ -158,39 +168,48 @@ type ScheduleDeleteTool struct { Manager *schedule.Manager } -func (ScheduleDeleteTool) Name() string { return "ScheduleDelete" } +func (ScheduleDeleteTool) Name() string { return "ScheduleDelete" } + +// ScheduleDeleteInput is the typed input for ScheduleDeleteTool. +type ScheduleDeleteInput struct { + ID string `json:"id"` + Reason string `json:"reason"` +} + func (ScheduleDeleteTool) Aliases() []string { return []string{"schedule_delete", "schedule-delete"} } func (ScheduleDeleteTool) Description() string { return "Cancel/delete an in-conversation scheduled reminder by ID." } -func (ScheduleDeleteTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{ - "type": "string", - "description": "Schedule ID to cancel (e.g. 'sched-1234abcd')", - }, - "reason": map[string]interface{}{ - "type": "string", - "description": "Optional cancellation reason", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ScheduleDeleteTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "id": {Type: "string", Description: "Schedule ID to cancel (e.g. 'sched-1234abcd')"}, + "reason": {Type: "string", Description: "Optional cancellation reason"}, }, - "required": []string{"id"}, + Required: []string{"id"}, } } +func (ScheduleDeleteTool) Parameters() map[string]interface{} { + return scheduleDeleteSchema.ToJSONSchema() +} + +// scheduleDeleteSchema is the single source of truth for ScheduleDelete's input schema. +var scheduleDeleteSchema = ScheduleDeleteTool{}.Schema() + func (t ScheduleDeleteTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - ID string `json:"id"` - Reason string `json:"reason"` - } - if len(input) > 0 { - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + var p ScheduleDeleteInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[ScheduleDeleteInput]("ScheduleDelete", input) + if err != nil { + return "", err } + p = decoded } if strings.TrimSpace(p.ID) == "" { diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index 6b9f1db2..9bd7de94 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -889,3 +889,108 @@ func TestAgenticFetchSchemaProvider(t *testing.T) { t.Fatal("query type wrong") } } + +func TestProjectVerifySchemaProvider(t *testing.T) { + var _ SchemaProvider = ProjectVerifyTool{} + props := schemaProps(t, ProjectVerifyTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 6 || enum[0] != "detect" || enum[5] != "all" { + t.Fatalf("action enum = %v, want 6 options", props["action"]) + } + ts := props["timeout_seconds"].(map[string]interface{}) + if ts["minimum"] != 1 || ts["maximum"] != 600 { + t.Fatalf("timeout_seconds bounds = %v, want min 1 max 600", ts) + } + req, _ := ProjectVerifyTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", ProjectVerifyTool{}.Parameters()["required"]) + } +} + +func TestSearchXSchemaProvider(t *testing.T) { + var _ SchemaProvider = SearchXTool{} + props := schemaProps(t, SearchXTool{}.Parameters()) + if props["query"].(map[string]interface{})["type"] != "string" { + t.Fatal("query type wrong") + } + if props["model"].(map[string]interface{})["type"] != "string" { + t.Fatal("model type wrong") + } + req, _ := SearchXTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "query" { + t.Fatalf("required = %v, want [query]", SearchXTool{}.Parameters()["required"]) + } +} + +func TestSessionQuerySchemaProvider(t *testing.T) { + var _ SchemaProvider = SessionQueryTool{} + props := schemaProps(t, SessionQueryTool{}.Parameters()) + if props["query"].(map[string]interface{})["type"] != "string" { + t.Fatal("query type wrong") + } + if props["limit"].(map[string]interface{})["type"] != "integer" { + t.Fatal("limit type wrong") + } + req, _ := SessionQueryTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "query" { + t.Fatalf("required = %v, want [query]", SessionQueryTool{}.Parameters()["required"]) + } +} + +func TestScheduleSchemasProvider(t *testing.T) { + var _ SchemaProvider = ScheduleCreateTool{} + var _ SchemaProvider = ScheduleListTool{} + var _ SchemaProvider = ScheduleDeleteTool{} + cprops := schemaProps(t, ScheduleCreateTool{}.Parameters()) + if cprops["prompt"].(map[string]interface{})["type"] != "string" { + t.Fatal("prompt type wrong") + } + if cprops["recurring"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("recurring type wrong") + } + creq, _ := ScheduleCreateTool{}.Parameters()["required"].([]string) + if len(creq) != 1 || creq[0] != "prompt" { + t.Fatalf("create required = %v, want [prompt]", ScheduleCreateTool{}.Parameters()["required"]) + } + lprops := schemaProps(t, ScheduleListTool{}.Parameters()) + if len(lprops) != 0 { + t.Fatalf("list properties = %v, want empty", lprops) + } + dprops := schemaProps(t, ScheduleDeleteTool{}.Parameters()) + if dprops["id"].(map[string]interface{})["type"] != "string" { + t.Fatal("delete id type wrong") + } + dreq, _ := ScheduleDeleteTool{}.Parameters()["required"].([]string) + if len(dreq) != 1 || dreq[0] != "id" { + t.Fatalf("delete required = %v, want [id]", ScheduleDeleteTool{}.Parameters()["required"]) + } +} + +func TestSmartReaderSchemaProvider(t *testing.T) { + var _ SchemaProvider = SmartReaderTool{} + props := schemaProps(t, SmartReaderTool{}.Parameters()) + enum, ok := props["strategy"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "full" || enum[3] != "relevant" { + t.Fatalf("strategy enum = %v, want 4 options", props["strategy"]) + } + req, _ := SmartReaderTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "path" { + t.Fatalf("required = %v, want [path]", SmartReaderTool{}.Parameters()["required"]) + } +} + +func TestRefactorSchemaProvider(t *testing.T) { + var _ SchemaProvider = RefactorTool{} + props := schemaProps(t, RefactorTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 9 || enum[0] != "extract_function" || enum[8] != "remove_unused_params" { + t.Fatalf("action enum = %v, want 9 options", props["action"]) + } + if props["file"].(map[string]interface{})["type"] != "string" { + t.Fatal("file type wrong") + } + req, _ := RefactorTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "action" || req[1] != "file" { + t.Fatalf("required = %v, want [action file]", RefactorTool{}.Parameters()["required"]) + } +} diff --git a/internal/tool/search_x.go b/internal/tool/search_x.go index 83debf69..d6cc2175 100644 --- a/internal/tool/search_x.go +++ b/internal/tool/search_x.go @@ -19,6 +19,12 @@ import ( // boundary-compliant (no flux/client import), httptest-testable. type SearchXTool struct{} +// SearchXInput is the typed input for SearchXTool. +type SearchXInput struct { + Query string `json:"query"` + Model string `json:"model"` +} + func (SearchXTool) Name() string { return "SearchX" } func (SearchXTool) RiskLevel() string { return "medium" } func (SearchXTool) Aliases() []string { return []string{"search_x", "x_search", "xsearch"} } @@ -26,23 +32,26 @@ func (SearchXTool) Description() string { return "Search X/Twitter for live posts matching a query. Returns a summarized answer incorporating current X results. Requires XAI_API_KEY." } -func (SearchXTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "The X/Twitter search query (topic, hashtag, account, etc.).", - }, - "model": map[string]interface{}{ - "type": "string", - "description": "Model to use (default grok-4.20-non-reasoning).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SearchXTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "query": {Type: "string", Description: "The X/Twitter search query (topic, hashtag, account, etc.)."}, + "model": {Type: "string", Description: "Model to use (default grok-4.20-non-reasoning)."}, }, - "required": []string{"query"}, + Required: []string{"query"}, } } +func (SearchXTool) Parameters() map[string]interface{} { + return searchXSchema.ToJSONSchema() +} + +// searchXSchema is the single source of truth for SearchX's input schema. +var searchXSchema = SearchXTool{}.Schema() + // xAISearchSystemPrompt directs the model to use its built-in X search and // return a concise, cited summary. const xAISearchSystemPrompt = "You are a live X/Twitter search assistant. Use your built-in X search tool to find current posts matching the user's query, then summarize the most relevant results with attribution. If X search is unavailable, say so clearly." @@ -62,12 +71,9 @@ func xAISearchKey() string { func xAISearchModel() string { return "grok-4.20-non-reasoning" } func (SearchXTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Query string `json:"query"` - Model string `json:"model"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[SearchXInput]("SearchX", input) + if err != nil { + return "", err } query := strings.TrimSpace(p.Query) if query == "" { diff --git a/internal/tool/session_query.go b/internal/tool/session_query.go index 5ed1ef7f..60dccc59 100644 --- a/internal/tool/session_query.go +++ b/internal/tool/session_query.go @@ -15,53 +15,53 @@ type SessionQueryTool struct { Service *sessionquery.Service } -func (SessionQueryTool) Name() string { return "SessionQuery" } +func (SessionQueryTool) Name() string { return "SessionQuery" } + +// SessionQueryInput is the typed input for SessionQueryTool. +type SessionQueryInput struct { + Query string `json:"query"` + SessionID string `json:"session_id"` + Workspace string `json:"workspace"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + func (SessionQueryTool) Aliases() []string { return []string{"session_query", "session-query"} } func (SessionQueryTool) Description() string { return "Search conversation history across past and current sessions using SQLite full-text search." } -func (SessionQueryTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "Full-text search query to find relevant past conversations", - }, - "session_id": map[string]interface{}{ - "type": "string", - "description": "Optional specific session ID to filter search within", - }, - "workspace": map[string]interface{}{ - "type": "string", - "description": "Optional workspace directory path to filter sessions within", - }, - "limit": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of search results to return (default 10, max 50)", - }, - "offset": map[string]interface{}{ - "type": "integer", - "description": "Pagination offset for scrolling through results (default 0)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SessionQueryTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "query": {Type: "string", Description: "Full-text search query to find relevant past conversations"}, + "session_id": {Type: "string", Description: "Optional specific session ID to filter search within"}, + "workspace": {Type: "string", Description: "Optional workspace directory path to filter sessions within"}, + "limit": {Type: "integer", Description: "Maximum number of search results to return (default 10, max 50)"}, + "offset": {Type: "integer", Description: "Pagination offset for scrolling through results (default 0)"}, }, - "required": []string{"query"}, + Required: []string{"query"}, } } +func (SessionQueryTool) Parameters() map[string]interface{} { + return sessionQuerySchema.ToJSONSchema() +} + +// sessionQuerySchema is the single source of truth for SessionQuery's input schema. +var sessionQuerySchema = SessionQueryTool{}.Schema() + func (t SessionQueryTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Query string `json:"query"` - SessionID string `json:"session_id"` - Workspace string `json:"workspace"` - Limit int `json:"limit"` - Offset int `json:"offset"` - } - if len(input) > 0 { - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid parameters: %w", err) + var p SessionQueryInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SessionQueryInput]("SessionQuery", input) + if err != nil { + return "", err } + p = decoded } if strings.TrimSpace(p.Query) == "" { diff --git a/internal/tool/smart_reader.go b/internal/tool/smart_reader.go index ef43c97e..f72c6565 100644 --- a/internal/tool/smart_reader.go +++ b/internal/tool/smart_reader.go @@ -753,7 +753,18 @@ func NewSmartReaderTool() *SmartReaderTool { return &SmartReaderTool{reader: NewSmartReader(defaultMaxTokens)} } -func (SmartReaderTool) Name() string { return "SmartRead" } +func (SmartReaderTool) Name() string { return "SmartRead" } + +// SmartReaderInput is the typed input for SmartReaderTool. +type SmartReaderInput struct { + Path string `json:"path"` + Query string `json:"query"` + Strategy string `json:"strategy"` + Budget int `json:"budget"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` +} + func (SmartReaderTool) RiskLevel() string { return "low" } func (SmartReaderTool) Aliases() []string { return []string{"smart_read"} } @@ -761,50 +772,33 @@ func (SmartReaderTool) Description() string { return `Token-aware file reader that intelligently reads files within a token budget. Shows full content for small files, or the most relevant parts (function signatures, keyword matches with context, head/tail) for large files. Use when you need to understand a large file without reading all of it.` } -func (SmartReaderTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "File path to read.", - }, - "query": map[string]interface{}{ - "type": "string", - "description": "Optional query to prioritize relevant sections (keywords that appear in the file).", - }, - "strategy": map[string]interface{}{ - "type": "string", - "enum": []string{"full", "head_tail", "symbols", "relevant"}, - "description": "Reading strategy. Default: relevant (auto-selects head_tail if no query).", - }, - "budget": map[string]interface{}{ - "type": "integer", - "description": "Max token budget for reading (default 8000).", - }, - "start_line": map[string]interface{}{ - "type": "integer", - "description": "Read from this line (1-based). If set, reads a specific range.", - }, - "end_line": map[string]interface{}{ - "type": "integer", - "description": "Read to this line (1-based, inclusive). Used with start_line.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SmartReaderTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "path": {Type: "string", Description: "File path to read."}, + "query": {Type: "string", Description: "Optional query to prioritize relevant sections (keywords that appear in the file)."}, + "strategy": {Type: "string", Enum: []interface{}{"full", "head_tail", "symbols", "relevant"}, Description: "Reading strategy. Default: relevant (auto-selects head_tail if no query)."}, + "budget": {Type: "integer", Description: "Max token budget for reading (default 8000)."}, + "start_line": {Type: "integer", Description: "Read from this line (1-based). If set, reads a specific range."}, + "end_line": {Type: "integer", Description: "Read to this line (1-based, inclusive). Used with start_line."}, }, - "required": []string{"path"}, + Required: []string{"path"}, } } +func (SmartReaderTool) Parameters() map[string]interface{} { + return smartReaderSchema.ToJSONSchema() +} + +// smartReaderSchema is the single source of truth for SmartReader's input schema. +var smartReaderSchema = SmartReaderTool{}.Schema() + func (t *SmartReaderTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Path string `json:"path"` - Query string `json:"query"` - Strategy string `json:"strategy"` - Budget int `json:"budget"` - StartLine int `json:"start_line"` - EndLine int `json:"end_line"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SmartReaderInput]("SmartReader", input) + if err != nil { return "", err } if p.Path == "" {