Skip to content

Commit 7b78465

Browse files
authored
refactor(tool): migrate Download/WebFetch/FileWrite/FileEdit to typed schemas (#322)
Follow the Schema()+Parameters() single-source pattern with wire-shape parity tests (including alias-marker preservation for file_path/old_str/ new_str, which the input validator discovers from descriptions). Execute parses via DecodeInput[T].
1 parent 43a4725 commit 7b78465

5 files changed

Lines changed: 163 additions & 55 deletions

File tree

‎internal/tool/download.go‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,36 @@ func (DownloadTool) Description() string {
2121
return "Download a file from a URL and save it to a local path."
2222
}
2323

24-
func (DownloadTool) Parameters() map[string]interface{} {
25-
return map[string]interface{}{
26-
"type": "object",
27-
"properties": map[string]interface{}{
28-
"url": map[string]interface{}{"type": "string", "description": "URL to download from"},
29-
"destination": map[string]interface{}{"type": "string", "description": "Local file path to save to"},
24+
// DownloadInput is the typed input for DownloadTool.
25+
type DownloadInput struct {
26+
URL string `json:"url"`
27+
Destination string `json:"destination"`
28+
}
29+
30+
// Schema returns the typed input schema. Parameters() delegates to it so the
31+
// two cannot diverge.
32+
func (DownloadTool) Schema() ToolSchema {
33+
return ToolSchema{
34+
Type: "object",
35+
Properties: map[string]SchemaProperty{
36+
"url": {Type: "string", Description: "URL to download from"},
37+
"destination": {Type: "string", Description: "Local file path to save to"},
3038
},
3139
}
3240
}
3341

42+
func (DownloadTool) Parameters() map[string]interface{} {
43+
return downloadSchema.ToJSONSchema()
44+
}
45+
46+
// downloadSchema is the single source of truth for Download's input schema.
47+
var downloadSchema = DownloadTool{}.Schema()
48+
3449
const maxDownloadSize = 50 * 1024 * 1024 // 50MB
3550

3651
func (DownloadTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
37-
var p struct {
38-
URL string `json:"url"`
39-
Destination string `json:"destination"`
40-
}
41-
if err := json.Unmarshal(input, &p); err != nil {
52+
p, err := DecodeInput[DownloadInput]("Download", input)
53+
if err != nil {
4254
return "", err
4355
}
4456
if p.URL == "" || p.Destination == "" {

‎internal/tool/file_edit.go‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,45 @@ func (FileEditTool) Description() string {
1818
return "Edit a file by replacing an exact string match with new content."
1919
}
2020

21-
func (FileEditTool) Parameters() map[string]interface{} {
22-
return map[string]interface{}{
23-
"type": "object",
24-
"properties": map[string]interface{}{
25-
"path": map[string]interface{}{"type": "string", "description": "File path to edit"},
26-
"file_path": map[string]interface{}{"type": "string", "description": "Archive-compatible alias for path"},
27-
"old_str": map[string]interface{}{"type": "string", "description": "Exact string to find and replace"},
28-
"old_string": map[string]interface{}{"type": "string", "description": "Archive-compatible alias for old_str"},
29-
"new_str": map[string]interface{}{"type": "string", "description": "Replacement string"},
30-
"new_string": map[string]interface{}{"type": "string", "description": "Archive-compatible alias for new_str"},
21+
// FileEditInput is the typed input for FileEditTool. NewStr/NewString are
22+
// pointers so an explicit empty replacement is distinguishable from absent.
23+
type FileEditInput struct {
24+
Path string `json:"path"`
25+
FilePath string `json:"file_path"`
26+
OldStr string `json:"old_str"`
27+
OldString string `json:"old_string"`
28+
NewStr *string `json:"new_str"`
29+
NewString *string `json:"new_string"`
30+
}
31+
32+
// Schema returns the typed input schema. Parameters() delegates to it so the
33+
// two cannot diverge. Alias descriptions are preserved verbatim: the input
34+
// validator discovers aliases from them.
35+
func (FileEditTool) Schema() ToolSchema {
36+
return ToolSchema{
37+
Type: "object",
38+
Properties: map[string]SchemaProperty{
39+
"path": {Type: "string", Description: "File path to edit"},
40+
"file_path": {Type: "string", Description: "Archive-compatible alias for path"},
41+
"old_str": {Type: "string", Description: "Exact string to find and replace"},
42+
"old_string": {Type: "string", Description: "Archive-compatible alias for old_str"},
43+
"new_str": {Type: "string", Description: "Replacement string"},
44+
"new_string": {Type: "string", Description: "Archive-compatible alias for new_str"},
3145
},
32-
"required": []string{"path", "old_str"},
46+
Required: []string{"path", "old_str"},
3347
}
3448
}
3549

50+
func (FileEditTool) Parameters() map[string]interface{} {
51+
return fileEditSchema.ToJSONSchema()
52+
}
53+
54+
// fileEditSchema is the single source of truth for FileEdit's input schema.
55+
var fileEditSchema = FileEditTool{}.Schema()
56+
3657
func (FileEditTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
37-
var p struct {
38-
Path string `json:"path"`
39-
FilePath string `json:"file_path"`
40-
OldStr string `json:"old_str"`
41-
OldString string `json:"old_string"`
42-
NewStr *string `json:"new_str"`
43-
NewString *string `json:"new_string"`
44-
}
45-
if err := json.Unmarshal(input, &p); err != nil {
58+
p, err := DecodeInput[FileEditInput]("FileEdit", input)
59+
if err != nil {
4660
return "", err
4761
}
4862
path := p.Path

‎internal/tool/file_write.go‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,38 @@ func (FileWriteTool) Description() string {
1818
return "Create or overwrite a file with the given content."
1919
}
2020

21-
func (FileWriteTool) Parameters() map[string]interface{} {
22-
return map[string]interface{}{
23-
"type": "object",
24-
"properties": map[string]interface{}{
25-
"path": map[string]interface{}{"type": "string", "description": "File path to write"},
26-
"file_path": map[string]interface{}{"type": "string", "description": "Archive-compatible alias for path"},
27-
"content": map[string]interface{}{"type": "string", "description": "File content"},
21+
// FileWriteInput is the typed input for FileWriteTool.
22+
type FileWriteInput struct {
23+
Path string `json:"path"`
24+
FilePath string `json:"file_path"`
25+
Content string `json:"content"`
26+
}
27+
28+
// Schema returns the typed input schema. Parameters() delegates to it so the
29+
// two cannot diverge. Alias descriptions are preserved verbatim: the input
30+
// validator discovers aliases from them.
31+
func (FileWriteTool) Schema() ToolSchema {
32+
return ToolSchema{
33+
Type: "object",
34+
Properties: map[string]SchemaProperty{
35+
"path": {Type: "string", Description: "File path to write"},
36+
"file_path": {Type: "string", Description: "Archive-compatible alias for path"},
37+
"content": {Type: "string", Description: "File content"},
2838
},
29-
"required": []string{"path", "content"},
39+
Required: []string{"path", "content"},
3040
}
3141
}
3242

43+
func (FileWriteTool) Parameters() map[string]interface{} {
44+
return fileWriteSchema.ToJSONSchema()
45+
}
46+
47+
// fileWriteSchema is the single source of truth for FileWrite's input schema.
48+
var fileWriteSchema = FileWriteTool{}.Schema()
49+
3350
func (FileWriteTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
34-
var p struct {
35-
Path string `json:"path"`
36-
FilePath string `json:"file_path"`
37-
Content string `json:"content"`
38-
}
39-
if err := json.Unmarshal(input, &p); err != nil {
51+
p, err := DecodeInput[FileWriteInput]("FileWrite", input)
52+
if err != nil {
4053
return "", err
4154
}
4255
path := p.Path

‎internal/tool/schema_batch_test.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,60 @@ func TestMatchesRange(t *testing.T) {
118118
t.Fatal("no bounds should pass")
119119
}
120120
}
121+
122+
func TestDownloadSchemaProvider(t *testing.T) {
123+
var _ SchemaProvider = DownloadTool{}
124+
props := schemaProps(t, DownloadTool{}.Parameters())
125+
for _, f := range []string{"url", "destination"} {
126+
if props[f].(map[string]interface{})["type"] != "string" {
127+
t.Fatalf("%s type wrong", f)
128+
}
129+
}
130+
_, hasRequired := DownloadTool{}.Parameters()["required"]
131+
if hasRequired {
132+
t.Fatalf("Download has no required fields, got %v", DownloadTool{}.Parameters()["required"])
133+
}
134+
}
135+
136+
func TestWebFetchSchemaProvider(t *testing.T) {
137+
var _ SchemaProvider = WebFetchTool{}
138+
props := schemaProps(t, WebFetchTool{}.Parameters())
139+
if props["url"].(map[string]interface{})["type"] != "string" {
140+
t.Fatal("url type wrong")
141+
}
142+
req, _ := WebFetchTool{}.Parameters()["required"].([]string)
143+
if len(req) != 1 || req[0] != "url" {
144+
t.Fatalf("required = %v, want [url]", WebFetchTool{}.Parameters()["required"])
145+
}
146+
}
147+
148+
func TestFileWriteSchemaProvider(t *testing.T) {
149+
var _ SchemaProvider = FileWriteTool{}
150+
props := schemaProps(t, FileWriteTool{}.Parameters())
151+
// The file_path alias marker must survive: the validator discovers
152+
// aliases from descriptions.
153+
if desc := props["file_path"].(map[string]interface{})["description"]; desc != "Archive-compatible alias for path" {
154+
t.Fatalf("file_path description = %v, alias marker lost", desc)
155+
}
156+
req, _ := FileWriteTool{}.Parameters()["required"].([]string)
157+
if len(req) != 2 || req[0] != "path" || req[1] != "content" {
158+
t.Fatalf("required = %v, want [path content]", FileWriteTool{}.Parameters()["required"])
159+
}
160+
}
161+
162+
func TestFileEditSchemaProvider(t *testing.T) {
163+
var _ SchemaProvider = FileEditTool{}
164+
props := schemaProps(t, FileEditTool{}.Parameters())
165+
for _, f := range []string{"path", "old_str", "new_str"} {
166+
if props[f].(map[string]interface{})["type"] != "string" {
167+
t.Fatalf("%s type wrong", f)
168+
}
169+
}
170+
if desc := props["old_string"].(map[string]interface{})["description"]; desc != "Archive-compatible alias for old_str" {
171+
t.Fatalf("old_string description = %v, alias marker lost", desc)
172+
}
173+
req, _ := FileEditTool{}.Parameters()["required"].([]string)
174+
if len(req) != 2 || req[0] != "path" || req[1] != "old_str" {
175+
t.Fatalf("required = %v, want [path old_str]", FileEditTool{}.Parameters()["required"])
176+
}
177+
}

‎internal/tool/web_fetch.go‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,30 @@ func (WebFetchTool) Description() string {
1919
return "Fetch a URL and return its content as text. HTML is converted to plain text."
2020
}
2121

22-
func (WebFetchTool) Parameters() map[string]interface{} {
23-
return map[string]interface{}{
24-
"type": "object",
25-
"properties": map[string]interface{}{
26-
"url": map[string]interface{}{"type": "string", "description": "URL to fetch"},
22+
// WebFetchInput is the typed input for WebFetchTool.
23+
type WebFetchInput struct {
24+
URL string `json:"url"`
25+
}
26+
27+
// Schema returns the typed input schema. Parameters() delegates to it so the
28+
// two cannot diverge.
29+
func (WebFetchTool) Schema() ToolSchema {
30+
return ToolSchema{
31+
Type: "object",
32+
Properties: map[string]SchemaProperty{
33+
"url": {Type: "string", Description: "URL to fetch"},
2734
},
28-
"required": []string{"url"},
35+
Required: []string{"url"},
2936
}
3037
}
3138

39+
func (WebFetchTool) Parameters() map[string]interface{} {
40+
return webFetchSchema.ToJSONSchema()
41+
}
42+
43+
// webFetchSchema is the single source of truth for WebFetch's input schema.
44+
var webFetchSchema = WebFetchTool{}.Schema()
45+
3246
// Timeout declares WebFetch's execution budget so the engine's dispatch
3347
// deadline matches the tool's own 30s HTTP deadline. Declared budgets win
3448
// over the name-based fallback (DSH tool-declared timeout policy).
@@ -40,10 +54,8 @@ var (
4054
)
4155

4256
func (WebFetchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
43-
var p struct {
44-
URL string `json:"url"`
45-
}
46-
if err := json.Unmarshal(input, &p); err != nil {
57+
p, err := DecodeInput[WebFetchInput]("WebFetch", input)
58+
if err != nil {
4759
return "", err
4860
}
4961
if p.URL == "" {

0 commit comments

Comments
 (0)