From 6da5eea9d23cec3215844d6ad337910f36b2347e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 16 Sep 2026 13:26:29 +0530 Subject: [PATCH] refactor(tool): migrate Browser/ComputerUse/GenerateMedia/MultiEdit/Patch/Transaction/TodoWrite/ToolHealth to typed schemas --- internal/tool/browser.go | 53 ++++++------ internal/tool/computer_use.go | 35 ++++---- internal/tool/media_generation.go | 92 ++++++++------------ internal/tool/multiedit.go | 44 ++++++---- internal/tool/patch.go | 37 +++++--- internal/tool/schema_batch_test.go | 131 +++++++++++++++++++++++++++++ internal/tool/todo.go | 60 +++++++------ internal/tool/tool_health.go | 37 +++++--- internal/tool/transaction.go | 59 +++++++------ internal/tool/transaction_test.go | 8 +- 10 files changed, 360 insertions(+), 196 deletions(-) diff --git a/internal/tool/browser.go b/internal/tool/browser.go index 980ce61f3..2bef43f3b 100644 --- a/internal/tool/browser.go +++ b/internal/tool/browser.go @@ -117,33 +117,36 @@ func (BrowserTool) Description() string { return "Control a headless Chrome browser: navigate to URLs, click and type into elements, extract page text/HTML/title, and take screenshots." } -func (BrowserTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"navigate", "content", "screenshot", "click", "type", "title", "location", "ax_snapshot", "close"}, - "description": "Actions. navigate/content/screenshot/title/location as named. ax_snapshot: compressed accessibility tree with uid handles (query optional); click/type then accept uid from that snapshot instead of a CSS selector. close shuts the browser down.", - }, - "url": map[string]interface{}{"type": "string", "description": "Target URL (http/https) for navigate/screenshot"}, - "selector": map[string]interface{}{"type": "string", "description": "CSS selector for content/click/type and optional navigate wait"}, - "uid": map[string]interface{}{"type": "string", "description": "Element uid from the last ax_snapshot; preferred over selector for click/type"}, - "text": map[string]interface{}{"type": "string", "description": "Text to type (type action)"}, - "clear": map[string]interface{}{"type": "boolean", "description": "Clear the field before typing"}, - "path": map[string]interface{}{"type": "string", "description": "File path to save a screenshot to"}, - "wait_ms": map[string]interface{}{"type": "number", "description": "Milliseconds to wait after navigation (default 800)"}, - "html": map[string]interface{}{"type": "boolean", "description": "Return outer HTML instead of inner text (content action)"}, - "max_chars": map[string]interface{}{ - "type": "number", "description": "Truncate extracted content to this many characters (default 20000)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (BrowserTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"navigate", "content", "screenshot", "click", "type", "title", "location", "ax_snapshot", "close"}, Description: "Actions. navigate/content/screenshot/title/location as named. ax_snapshot: compressed accessibility tree with uid handles (query optional); click/type then accept uid from that snapshot instead of a CSS selector. close shuts the browser down."}, + "url": {Type: "string", Description: "Target URL (http/https) for navigate/screenshot"}, + "selector": {Type: "string", Description: "CSS selector for content/click/type and optional navigate wait"}, + "uid": {Type: "string", Description: "Element uid from the last ax_snapshot; preferred over selector for click/type"}, + "text": {Type: "string", Description: "Text to type (type action)"}, + "clear": {Type: "boolean", Description: "Clear the field before typing"}, + "path": {Type: "string", Description: "File path to save a screenshot to"}, + "wait_ms": {Type: "number", Description: "Milliseconds to wait after navigation (default 800)"}, + "html": {Type: "boolean", Description: "Return outer HTML instead of inner text (content action)"}, + "max_chars": {Type: "number", Description: "Truncate extracted content to this many characters (default 20000)"}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } -// browserParams mirrors the declared JSON schema. -type browserParams struct { +func (BrowserTool) Parameters() map[string]interface{} { + return browserSchema.ToJSONSchema() +} + +// browserSchema is the single source of truth for Browser's input schema. +var browserSchema = BrowserTool{}.Schema() + +// BrowserInput is the typed input for BrowserTool. +type BrowserInput struct { Action string `json:"action"` URL string `json:"url"` Selector string `json:"selector"` @@ -157,8 +160,8 @@ type browserParams struct { } func (BrowserTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p browserParams - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[BrowserInput]("Browser", input) + if err != nil { return "", err } p.Action = strings.ToLower(strings.TrimSpace(p.Action)) diff --git a/internal/tool/computer_use.go b/internal/tool/computer_use.go index 40cde2b4d..4c698d826 100644 --- a/internal/tool/computer_use.go +++ b/internal/tool/computer_use.go @@ -22,28 +22,27 @@ func (ComputerUseTool) Description() string { return "Operate the host desktop (snapshot UI, click, type, scroll, keypress, screenshot) via a pluggable backend. Requires a wired computer backend (see SetComputerBackend)." } -func (ComputerUseTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"snapshot", "click", "type", "scroll", "press", "screenshot"}, - "description": "snapshot: dump the UI; click: click an element/ref; type: enter text; scroll: scroll; press: send key chord; screenshot: capture screen.", - }, - "target": map[string]interface{}{ - "type": "string", - "description": "Element ref (e.g. @e1) or label for click/type/scroll.", - }, - "text": map[string]interface{}{ - "type": "string", - "description": "Text to type or key chord for press.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ComputerUseTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"snapshot", "click", "type", "scroll", "press", "screenshot"}, Description: "snapshot: dump the UI; click: click an element/ref; type: enter text; scroll: scroll; press: send key chord; screenshot: capture screen."}, + "target": {Type: "string", Description: "Element ref (e.g. @e1) or label for click/type/scroll."}, + "text": {Type: "string", Description: "Text to type or key chord for press."}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } +func (ComputerUseTool) Parameters() map[string]interface{} { + return computerUseSchema.ToJSONSchema() +} + +// computerUseSchema is the single source of truth for ComputerUse's input schema. +var computerUseSchema = ComputerUseTool{}.Schema() + // ComputerBackend is the pluggable host-desktop automation backend. type ComputerBackend interface { // Name identifies the backend for provenance. diff --git a/internal/tool/media_generation.go b/internal/tool/media_generation.go index dedc8ff42..f05a515b3 100644 --- a/internal/tool/media_generation.go +++ b/internal/tool/media_generation.go @@ -54,6 +54,18 @@ type MediaOptions struct { DurationSec int `json:"duration_seconds,omitempty"` } +// GenerateMediaInput is the typed input for GenerateMediaTool. +type GenerateMediaInput struct { + Kind string `json:"kind"` + Prompt string `json:"prompt"` + Source string `json:"source"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + Count int `json:"count"` + DurationSec int `json:"duration_seconds"` + OutputPath string `json:"output_path"` +} + // MediaAsset is the persisted, locally-available representation returned to the // model and user. type MediaAsset struct { @@ -96,65 +108,36 @@ func (GenerateMediaTool) Description() string { return "Generate an image or short video from a text prompt (and optionally edit an existing local image or URL). The generated asset is saved locally and its path is returned so you can reference it directly." } -func (GenerateMediaTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "kind": map[string]interface{}{ - "type": "string", - "enum": []string{"image", "video"}, - "description": "The kind of media to generate.", - }, - "prompt": map[string]interface{}{ - "type": "string", - "description": "Text description of the media to generate.", - }, - "source": map[string]interface{}{ - "type": "string", - "description": "Optional local file path or URL used for image editing / image-to-video.", - }, - "aspect_ratio": map[string]interface{}{ - "type": "string", - "description": "Aspect ratio, e.g. 16:9, 1:1, 9:16.", - }, - "resolution": map[string]interface{}{ - "type": "string", - "description": "Resolution: images 1k or 2k; video 480p or 720p.", - }, - "count": map[string]interface{}{ - "type": "integer", - "minimum": 1, - "maximum": 4, - "description": "Number of images to generate (default 1).", - }, - "duration_seconds": map[string]interface{}{ - "type": "integer", - "minimum": 1, - "maximum": 15, - "description": "Video duration in seconds (default 5).", - }, - "output_path": map[string]interface{}{ - "type": "string", - "description": "Optional explicit output directory; defaults to the user state generated-media directory.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (GenerateMediaTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "kind": {Type: "string", Enum: []interface{}{"image", "video"}, Description: "The kind of media to generate."}, + "prompt": {Type: "string", Description: "Text description of the media to generate."}, + "source": {Type: "string", Description: "Optional local file path or URL used for image editing / image-to-video."}, + "aspect_ratio": {Type: "string", Description: "Aspect ratio, e.g. 16:9, 1:1, 9:16."}, + "resolution": {Type: "string", Description: "Resolution: images 1k or 2k; video 480p or 720p."}, + "count": {Type: "integer", Minimum: 1, Maximum: 4, Description: "Number of images to generate (default 1)."}, + "duration_seconds": {Type: "integer", Minimum: 1, Maximum: 15, Description: "Video duration in seconds (default 5)."}, + "output_path": {Type: "string", Description: "Optional explicit output directory; defaults to the user state generated-media directory."}, }, - "required": []string{"kind", "prompt"}, + Required: []string{"kind", "prompt"}, } } +func (GenerateMediaTool) Parameters() map[string]interface{} { + return generateMediaSchema.ToJSONSchema() +} + +// generateMediaSchema is the single source of truth for GenerateMedia's input schema. +var generateMediaSchema = GenerateMediaTool{}.Schema() + func (GenerateMediaTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Kind string `json:"kind"` - Prompt string `json:"prompt"` - Source string `json:"source"` - AspectRatio string `json:"aspect_ratio"` - Resolution string `json:"resolution"` - Count int `json:"count"` - DurationSec int `json:"duration_seconds"` - OutputPath string `json:"output_path"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[GenerateMediaInput]("GenerateMedia", input) + if err != nil { + return "", err } p.Kind = strings.ToLower(strings.TrimSpace(p.Kind)) p.Prompt = strings.TrimSpace(p.Prompt) @@ -203,7 +186,6 @@ func (GenerateMediaTool) Execute(ctx context.Context, input json.RawMessage) (st } var results []MediaResult - var err error switch p.Kind { case "image": results, err = mediaEngine.GenerateImage(ctx, p.Prompt, source, opts) diff --git a/internal/tool/multiedit.go b/internal/tool/multiedit.go index cd24e5903..68b3a32a8 100644 --- a/internal/tool/multiedit.go +++ b/internal/tool/multiedit.go @@ -17,20 +17,22 @@ func (MultiEditTool) Description() string { return "Apply multiple edits to a single file in one call. Each edit replaces an exact string match. Edits are applied sequentially." } -func (MultiEditTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "file_path": map[string]interface{}{"type": "string", "description": "File path to edit"}, - "edits": map[string]interface{}{ - "type": "array", - "description": "Array of edit operations", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "old_string": map[string]interface{}{"type": "string", "description": "Exact string to find"}, - "new_string": map[string]interface{}{"type": "string", "description": "Replacement string"}, - "replace_all": map[string]interface{}{"type": "boolean", "description": "Replace all occurrences (default: first only)"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (MultiEditTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "file_path": {Type: "string", Description: "File path to edit"}, + "edits": { + Type: "array", + Description: "Array of edit operations", + Items: &SchemaProperty{ + Type: "object", + Properties: map[string]SchemaProperty{ + "old_string": {Type: "string", Description: "Exact string to find"}, + "new_string": {Type: "string", Description: "Replacement string"}, + "replace_all": {Type: "boolean", Description: "Replace all occurrences (default: first only)"}, }, }, }, @@ -38,7 +40,15 @@ func (MultiEditTool) Parameters() map[string]interface{} { } } -type multiEditParams struct { +func (MultiEditTool) Parameters() map[string]interface{} { + return multiEditSchema.ToJSONSchema() +} + +// multiEditSchema is the single source of truth for MultiEdit's input schema. +var multiEditSchema = MultiEditTool{}.Schema() + +// MultiEditInput is the typed input for MultiEditTool. +type MultiEditInput struct { FilePath string `json:"file_path"` Edits []struct { OldString string `json:"old_string"` @@ -48,8 +58,8 @@ type multiEditParams struct { } func (MultiEditTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p multiEditParams - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[MultiEditInput]("MultiEdit", input) + if err != nil { return "", err } if p.FilePath == "" { diff --git a/internal/tool/patch.go b/internal/tool/patch.go index bdfef062c..70a70fc84 100644 --- a/internal/tool/patch.go +++ b/internal/tool/patch.go @@ -408,31 +408,40 @@ func levenshteinDistance(a, b string) int { // PatchTool implements the Tool interface for applying structured patches. type PatchTool struct{} +// PatchInput is the typed input for PatchTool. +type PatchInput struct { + Patch string `json:"patch"` +} + func (PatchTool) Name() string { return "Patch" } func (PatchTool) Description() string { return "Apply a structured patch to one or more files. Supports context-anchored hunks for precise modifications." } -func (PatchTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "patch": map[string]interface{}{ - "type": "string", - "description": "Patch content in the *** Begin Patch format", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (PatchTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "patch": {Type: "string", Description: "Patch content in the *** Begin Patch format"}, }, - "required": []interface{}{"patch"}, + Required: []string{"patch"}, } } +func (PatchTool) Parameters() map[string]interface{} { + return patchSchema.ToJSONSchema() +} + +// patchSchema is the single source of truth for Patch's input schema. +var patchSchema = PatchTool{}.Schema() + func (PatchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - Patch string `json:"patch"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[PatchInput]("Patch", input) + if err != nil { + return "", err } if params.Patch == "" { return "", fmt.Errorf("patch content is required") diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index 3f300596d..b54ab4e96 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -665,3 +665,134 @@ func TestPowerShellSchemaProvider(t *testing.T) { t.Fatalf("required = %v, want [command]", PowerShellTool{}.Parameters()["required"]) } } + +func TestBrowserSchemaProvider(t *testing.T) { + var _ SchemaProvider = BrowserTool{} + props := schemaProps(t, BrowserTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 9 || enum[0] != "navigate" || enum[8] != "close" { + t.Fatalf("action enum = %v, want 9 options", props["action"]) + } + if props["clear"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("clear type wrong") + } + if props["wait_ms"].(map[string]interface{})["type"] != "number" { + t.Fatal("wait_ms type wrong") + } + req, _ := BrowserTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", BrowserTool{}.Parameters()["required"]) + } +} + +func TestComputerUseSchemaProvider(t *testing.T) { + var _ SchemaProvider = ComputerUseTool{} + props := schemaProps(t, ComputerUseTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 6 || enum[0] != "snapshot" || enum[5] != "screenshot" { + t.Fatalf("action enum = %v, want 6 options", props["action"]) + } + req, _ := ComputerUseTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", ComputerUseTool{}.Parameters()["required"]) + } +} + +func TestGenerateMediaSchemaProvider(t *testing.T) { + var _ SchemaProvider = GenerateMediaTool{} + props := schemaProps(t, GenerateMediaTool{}.Parameters()) + enum, ok := props["kind"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 2 || enum[0] != "image" || enum[1] != "video" { + t.Fatalf("kind enum = %v, want [image video]", props["kind"]) + } + count := props["count"].(map[string]interface{}) + if count["minimum"] != 1 || count["maximum"] != 4 { + t.Fatalf("count bounds = %v, want min 1 max 4", count) + } + dur := props["duration_seconds"].(map[string]interface{}) + if dur["minimum"] != 1 || dur["maximum"] != 15 { + t.Fatalf("duration_seconds bounds = %v, want min 1 max 15", dur) + } + req, _ := GenerateMediaTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "kind" || req[1] != "prompt" { + t.Fatalf("required = %v, want [kind prompt]", GenerateMediaTool{}.Parameters()["required"]) + } +} + +func TestMultiEditSchemaProvider(t *testing.T) { + var _ SchemaProvider = MultiEditTool{} + props := schemaProps(t, MultiEditTool{}.Parameters()) + edits := props["edits"].(map[string]interface{}) + if edits["type"] != "array" { + t.Fatalf("edits type = %v, want array", edits["type"]) + } + items, ok := edits["items"].(map[string]interface{}) + if !ok || items["type"] != "object" { + t.Fatalf("edits items = %v, want object schema", edits["items"]) + } + itemProps := items["properties"].(map[string]interface{}) + if itemProps["old_string"].(map[string]interface{})["type"] != "string" { + t.Fatal("old_string type wrong") + } + if _, hasReq := items["required"]; hasReq { + t.Fatal("edits items should have no required array") + } +} + +func TestPatchSchemaProvider(t *testing.T) { + var _ SchemaProvider = PatchTool{} + props := schemaProps(t, PatchTool{}.Parameters()) + if props["patch"].(map[string]interface{})["type"] != "string" { + t.Fatal("patch type wrong") + } + req, _ := PatchTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "patch" { + t.Fatalf("required = %v, want [patch]", PatchTool{}.Parameters()["required"]) + } +} + +func TestTransactionSchemaProvider(t *testing.T) { + var _ SchemaProvider = TransactionTool{} + props := schemaProps(t, TransactionTool{}.Parameters()) + ops := props["operations"].(map[string]interface{}) + if ops["type"] != "array" { + t.Fatalf("operations type = %v, want array", ops["type"]) + } + items := ops["items"].(map[string]interface{}) + itemProps := items["properties"].(map[string]interface{}) + if itemProps["type"].(map[string]interface{})["type"] != "string" { + t.Fatal("op type prop wrong") + } + itemReq, ok := items["required"].([]string) + if !ok || len(itemReq) != 2 || itemReq[0] != "type" || itemReq[1] != "path" { + t.Fatalf("items required = %v, want [type path]", items["required"]) + } + req, _ := TransactionTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "operations" { + t.Fatalf("top required = %v, want [operations]", TransactionTool{}.Parameters()["required"]) + } + if props["dry_run"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("dry_run type wrong") + } +} + +func TestTodoWriteSchemaProvider(t *testing.T) { + var _ SchemaProvider = TodoWriteTool{} + props := schemaProps(t, TodoWriteTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "add" || enum[3] != "remove" { + t.Fatalf("action enum = %v, want 4 options", props["action"]) + } + todos := props["todos"].(map[string]interface{}) + if todos["type"] != "array" { + t.Fatalf("todos type = %v, want array", todos["type"]) + } +} + +func TestToolHealthSchemaProvider(t *testing.T) { + var _ SchemaProvider = ToolHealthTool{} + props := schemaProps(t, ToolHealthTool{}.Parameters()) + if props["include_optional"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("include_optional type wrong") + } +} diff --git a/internal/tool/todo.go b/internal/tool/todo.go index 989c98b4a..1c45c9cc0 100644 --- a/internal/tool/todo.go +++ b/internal/tool/todo.go @@ -24,29 +24,39 @@ var ( type TodoWriteTool struct{} +// TodoWriteInput is the typed input for TodoWriteTool. +type TodoWriteInput struct { + Action string `json:"action"` + Task string `json:"task"` + ID int `json:"id"` + Todos []todoInput `json:"todos"` +} + func (TodoWriteTool) Name() string { return "TodoWrite" } func (TodoWriteTool) Aliases() []string { return []string{"todo"} } func (TodoWriteTool) Description() string { return "Manage a task list. Actions: add, complete, list, remove." } -func (TodoWriteTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string", "enum": []string{"add", "complete", "list", "remove"}, "description": "Action to perform"}, - "task": map[string]interface{}{"type": "string", "description": "Task description (for add)"}, - "id": map[string]interface{}{"type": "integer", "description": "Task ID (for complete/remove)"}, - "todos": map[string]interface{}{ - "type": "array", - "description": "Archive-compatible full todo list replacement", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "content": map[string]interface{}{"type": "string"}, - "task": map[string]interface{}{"type": "string"}, - "status": map[string]interface{}{"type": "string"}, - "priority": map[string]interface{}{"type": "string"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TodoWriteTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"add", "complete", "list", "remove"}, Description: "Action to perform"}, + "task": {Type: "string", Description: "Task description (for add)"}, + "id": {Type: "integer", Description: "Task ID (for complete/remove)"}, + "todos": { + Type: "array", + Description: "Archive-compatible full todo list replacement", + Items: &SchemaProperty{ + Type: "object", + Properties: map[string]SchemaProperty{ + "content": {Type: "string"}, + "task": {Type: "string"}, + "status": {Type: "string"}, + "priority": {Type: "string"}, }, }, }, @@ -54,14 +64,16 @@ func (TodoWriteTool) Parameters() map[string]interface{} { } } +func (TodoWriteTool) Parameters() map[string]interface{} { + return todoWriteSchema.ToJSONSchema() +} + +// todoWriteSchema is the single source of truth for TodoWrite's input schema. +var todoWriteSchema = TodoWriteTool{}.Schema() + func (TodoWriteTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - Task string `json:"task"` - ID int `json:"id"` - Todos []todoInput `json:"todos"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[TodoWriteInput]("TodoWrite", input) + if err != nil { return "", err } todoMu.Lock() diff --git a/internal/tool/tool_health.go b/internal/tool/tool_health.go index 8b887b5f2..d68af6a22 100644 --- a/internal/tool/tool_health.go +++ b/internal/tool/tool_health.go @@ -14,6 +14,11 @@ import ( // before attempting a task. type ToolHealthTool struct{} +// ToolHealthInput is the typed input for ToolHealthTool. +type ToolHealthInput struct { + IncludeOptional *bool `json:"include_optional"` +} + func (ToolHealthTool) Name() string { return "ToolHealth" } func (ToolHealthTool) RiskLevel() string { return "low" } func (ToolHealthTool) Aliases() []string { return []string{"tool-health", "tools_health"} } @@ -21,18 +26,24 @@ func (ToolHealthTool) Description() string { return "Inspect Rho's registered/model-visible tools and common runtime prerequisites (git, go, node, Python, Docker, gh, and Chrome) without revealing secrets or changing state." } -func (ToolHealthTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "include_optional": map[string]interface{}{ - "type": "boolean", - "description": "Include lazy-registered tools in the report (default true).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ToolHealthTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "include_optional": {Type: "boolean", Description: "Include lazy-registered tools in the report (default true)."}, }, } } +func (ToolHealthTool) Parameters() map[string]interface{} { + return toolHealthSchema.ToJSONSchema() +} + +// toolHealthSchema is the single source of truth for ToolHealth's input schema. +var toolHealthSchema = ToolHealthTool{}.Schema() + type toolHealthReport struct { Registered []toolHealthEntry `json:"registered_tools"` Visible []string `json:"model_visible_tools"` @@ -51,13 +62,13 @@ type prerequisiteStatus struct { } func (ToolHealthTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - IncludeOptional *bool `json:"include_optional"` - } + var params ToolHealthInput if len(input) > 0 && string(input) != "null" { - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + decoded, err := DecodeInput[ToolHealthInput]("ToolHealth", input) + if err != nil { + return "", err } + params = decoded } includeOptional := true if params.IncludeOptional != nil { diff --git a/internal/tool/transaction.go b/internal/tool/transaction.go index 6706cf51b..6df1b415d 100644 --- a/internal/tool/transaction.go +++ b/internal/tool/transaction.go @@ -405,36 +405,43 @@ func (TransactionTool) Description() string { "Either all operations succeed or all are rolled back." } -func (TransactionTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "operations": map[string]interface{}{ - "type": "array", - "description": "List of file operations to apply atomically", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "type": map[string]interface{}{"type": "string", "enum": []string{"create", "modify", "delete", "rename"}, "description": "Operation type"}, - "path": map[string]interface{}{"type": "string", "description": "Target file path"}, - "old_path": map[string]interface{}{"type": "string", "description": "Source path (for rename)"}, - "content": map[string]interface{}{"type": "string", "description": "File content (for create/modify)"}, - "mode": map[string]interface{}{"type": "integer", "description": "File mode/permissions (optional, default 0644)"}, - "new_content": map[string]interface{}{"type": "string", "description": "New content (alias for content, for modify)"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TransactionTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "operations": { + Type: "array", + Description: "List of file operations to apply atomically", + Items: &SchemaProperty{ + Type: "object", + Properties: map[string]SchemaProperty{ + "type": {Type: "string", Enum: []interface{}{"create", "modify", "delete", "rename"}, Description: "Operation type"}, + "path": {Type: "string", Description: "Target file path"}, + "old_path": {Type: "string", Description: "Source path (for rename)"}, + "content": {Type: "string", Description: "File content (for create/modify)"}, + "mode": {Type: "integer", Description: "File mode/permissions (optional, default 0644)"}, + "new_content": {Type: "string", Description: "New content (alias for content, for modify)"}, }, - "required": []string{"type", "path"}, + Required: []string{"type", "path"}, }, }, - "dry_run": map[string]interface{}{ - "type": "boolean", - "description": "If true, validate and describe operations without applying them", - }, + "dry_run": {Type: "boolean", Description: "If true, validate and describe operations without applying them"}, }, - "required": []string{"operations"}, + Required: []string{"operations"}, } } -type transactionInput struct { +func (TransactionTool) Parameters() map[string]interface{} { + return transactionSchema.ToJSONSchema() +} + +// transactionSchema is the single source of truth for Transaction's input schema. +var transactionSchema = TransactionTool{}.Schema() + +// TransactionInput is the typed input for TransactionTool. +type TransactionInput struct { Operations []struct { Type string `json:"type"` Path string `json:"path"` @@ -447,9 +454,9 @@ type transactionInput struct { } func (TransactionTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p transactionInput - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[TransactionInput]("Transaction", input) + if err != nil { + return "", err } if len(p.Operations) == 0 { return "", fmt.Errorf("at least one operation is required") diff --git a/internal/tool/transaction_test.go b/internal/tool/transaction_test.go index 464a37a32..a152485eb 100644 --- a/internal/tool/transaction_test.go +++ b/internal/tool/transaction_test.go @@ -644,7 +644,7 @@ func TestTransactionTool_Execute(t *testing.T) { createPath := filepath.Join(dir, "new_file.txt") - input := transactionInput{ + input := TransactionInput{ Operations: []struct { Type string `json:"type"` Path string `json:"path"` @@ -686,7 +686,7 @@ func TestTransactionTool_ExecuteDryRun(t *testing.T) { path := filepath.Join(dir, "file.txt") os.WriteFile(path, []byte("original"), 0o644) - input := transactionInput{ + input := TransactionInput{ Operations: []struct { Type string `json:"type"` Path string `json:"path"` @@ -720,7 +720,7 @@ func TestTransactionTool_ExecuteDryRun(t *testing.T) { } func TestTransactionTool_ExecuteEmptyOperations(t *testing.T) { - input := transactionInput{} + input := TransactionInput{} data, _ := json.Marshal(input) ctx := testCtx() @@ -738,7 +738,7 @@ func TestTransactionTool_RejectsCredentialContent(t *testing.T) { dir := t.TempDir() createPath := filepath.Join(dir, "notes.txt") - input := transactionInput{ + input := TransactionInput{ Operations: []struct { Type string `json:"type"` Path string `json:"path"`