Skip to content

Commit e6e6b89

Browse files
authored
Merge pull request #748 from rumpl/feat-api-tool
api to tool call toolset
2 parents f95184d + 2241bb3 commit e6e6b89

7 files changed

Lines changed: 420 additions & 1 deletion

File tree

cagent-schema.json

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@
323323
"filesystem",
324324
"shell",
325325
"todo",
326-
"fetch"
326+
"fetch",
327+
"api"
327328
]
328329
},
329330
"instruction": {
@@ -395,6 +396,10 @@
395396
"items": {
396397
"$ref": "#/definitions/PostEditConfig"
397398
}
399+
},
400+
"api_config": {
401+
"$ref": "#/definitions/ApiConfig",
402+
"description": "API tool configuration"
398403
}
399404
},
400405
"additionalProperties": false,
@@ -443,6 +448,22 @@
443448
]
444449
}
445450
}
451+
},
452+
{
453+
"allOf": [
454+
{
455+
"properties": {
456+
"type": {
457+
"const": "api"
458+
}
459+
}
460+
},
461+
{
462+
"required": [
463+
"api_config"
464+
]
465+
}
466+
]
446467
}
447468
]
448469
},
@@ -528,6 +549,73 @@
528549
"cmd"
529550
],
530551
"additionalProperties": false
552+
},
553+
"ApiConfig": {
554+
"type": "object",
555+
"description": "API tool configuration for making HTTP requests to external APIs",
556+
"properties": {
557+
"name": {
558+
"type": "string",
559+
"description": "Name of the API tool"
560+
},
561+
"instruction": {
562+
"type": "string",
563+
"description": "Instructions for using the API tool"
564+
},
565+
"endpoint": {
566+
"type": "string",
567+
"description": "API endpoint URL",
568+
"format": "uri"
569+
},
570+
"method": {
571+
"type": "string",
572+
"description": "HTTP method",
573+
"enum": [
574+
"GET",
575+
"POST",
576+
"PUT",
577+
"PATCH",
578+
"DELETE"
579+
]
580+
},
581+
"headers": {
582+
"type": "object",
583+
"description": "HTTP headers for the request",
584+
"additionalProperties": {
585+
"type": "string"
586+
}
587+
},
588+
"args": {
589+
"type": "object",
590+
"description": "Arguments schema for the API call",
591+
"additionalProperties": {
592+
"type": "object",
593+
"properties": {
594+
"type": {
595+
"type": "string",
596+
"description": "Argument type"
597+
},
598+
"description": {
599+
"type": "string",
600+
"description": "Argument description"
601+
}
602+
}
603+
}
604+
},
605+
"required": {
606+
"type": "array",
607+
"description": "Required argument names",
608+
"items": {
609+
"type": "string"
610+
}
611+
}
612+
},
613+
"required": [
614+
"name",
615+
"endpoint",
616+
"method"
617+
],
618+
"additionalProperties": false
531619
}
532620
}
533621
}

examples/api-tool.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: "2"
2+
3+
agents:
4+
root:
5+
description: Chess.com daily puzzle agent
6+
instruction: Call the daily-puzzle tool and print the puzzle in ascii with unicode chess pieces. Do not solve the puzzle for them, only provide hints and guidance.
7+
model: google/gemini-2.5-pro
8+
toolsets:
9+
- type: api
10+
api_config:
11+
instruction: Get todos
12+
name: daily-puzzle
13+
endpoint: https://api.chess.com/pub/puzzle
14+
method: GET

pkg/config/v2/types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ type ScriptShellToolConfig struct {
8787
WorkingDir string `json:"working_dir,omitempty"`
8888
}
8989

90+
type APIToolConfig struct {
91+
Instruction string `json:"instruction,omitempty"`
92+
Name string `json:"name,omitempty"`
93+
Required []string `json:"required,omitempty"`
94+
Args map[string]any `json:"args,omitempty"`
95+
Endpoint string `json:"endpoint,omitempty"`
96+
Method string `json:"method,omitempty"`
97+
Headers map[string]string `json:"headers,omitempty"`
98+
}
99+
90100
// PostEditConfig represents a post-edit command configuration
91101
type PostEditConfig struct {
92102
Path string `json:"path"`
@@ -122,6 +132,8 @@ type Toolset struct {
122132
// For the `filesystem` tool - post-edit commands
123133
PostEdit []PostEditConfig `json:"post_edit,omitempty"`
124134

135+
APIConfig APIToolConfig `json:"api_config"`
136+
125137
// For the `fetch` tool
126138
Timeout int `json:"timeout,omitempty"`
127139
}

pkg/js/expand.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,18 @@ func Expand(ctx context.Context, kv map[string]string, env environment.Provider)
3737

3838
return expanded
3939
}
40+
41+
func ExpandString(ctx context.Context, str string, values map[string]string) (string, error) {
42+
vm := goja.New()
43+
44+
for k, v := range values {
45+
_ = vm.Set(k, v)
46+
}
47+
48+
expanded, err := vm.RunString("`" + str + "`")
49+
if err != nil {
50+
return "", err
51+
}
52+
53+
return fmt.Sprintf("%v", expanded.Export()), nil
54+
}

pkg/teamloader/teamloader.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry {
7272
r.Register("filesystem", createFilesystemTool)
7373
r.Register("fetch", createFetchTool)
7474
r.Register("mcp", createMCPTool)
75+
r.Register("api", createAPITool)
7576
return r
7677
}
7778

@@ -159,6 +160,16 @@ func createFilesystemTool(ctx context.Context, toolset latest.Toolset, parentDir
159160
return builtin.NewFilesystemTool([]string{wd}, opts...), nil
160161
}
161162

163+
func createAPITool(ctx context.Context, toolset latest.Toolset, parentDir string, envProvider environment.Provider, runtimeConfig config.RuntimeConfig) (tools.ToolSet, error) {
164+
if toolset.APIConfig.Endpoint == "" {
165+
return nil, fmt.Errorf("api tool requires an endpoint in api_config")
166+
}
167+
168+
toolset.APIConfig.Headers = js.Expand(ctx, toolset.APIConfig.Headers, envProvider)
169+
170+
return builtin.NewAPITool(toolset.APIConfig), nil
171+
}
172+
162173
func createFetchTool(ctx context.Context, toolset latest.Toolset, parentDir string, envProvider environment.Provider, runtimeConfig config.RuntimeConfig) (tools.ToolSet, error) {
163174
var opts []builtin.FetchToolOption
164175
if toolset.Timeout > 0 {

pkg/tools/builtin/api.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package builtin
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"time"
12+
13+
latest "github.com/docker/cagent/pkg/config/v2"
14+
"github.com/docker/cagent/pkg/js"
15+
"github.com/docker/cagent/pkg/tools"
16+
)
17+
18+
const (
19+
ToolNameAPI = "api"
20+
)
21+
22+
type APITool struct {
23+
tools.ElicitationTool
24+
handler *apiHandler
25+
config latest.APIToolConfig
26+
}
27+
28+
var _ tools.ToolSet = (*APITool)(nil)
29+
30+
type apiHandler struct {
31+
config latest.APIToolConfig
32+
}
33+
34+
func (h *apiHandler) CallTool(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) {
35+
client := &http.Client{
36+
Timeout: 30 * time.Second,
37+
}
38+
39+
endpoint := h.config.Endpoint
40+
var reqBody io.Reader = http.NoBody
41+
switch h.config.Method {
42+
case http.MethodGet:
43+
if toolCall.Function.Arguments != "" {
44+
var params map[string]string
45+
46+
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &params); err != nil {
47+
return nil, fmt.Errorf("invalid arguments: %w", err)
48+
}
49+
expanded, err := js.ExpandString(ctx, endpoint, params)
50+
if err != nil {
51+
return nil, fmt.Errorf("failed to expand endpoint: %w", err)
52+
}
53+
endpoint = expanded
54+
}
55+
case http.MethodPost:
56+
var params map[string]any
57+
58+
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &params); err != nil {
59+
return nil, fmt.Errorf("invalid arguments: %w", err)
60+
}
61+
jsonData, err := json.Marshal(params)
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to marshal request body: %v", err)
64+
}
65+
reqBody = bytes.NewReader(jsonData)
66+
}
67+
68+
req, err := http.NewRequestWithContext(ctx, h.config.Method, endpoint, reqBody)
69+
if err != nil {
70+
return nil, fmt.Errorf("failed to create request: %v", err)
71+
}
72+
73+
req.Header.Set("User-Agent", userAgent)
74+
if h.config.Method == http.MethodPost {
75+
req.Header.Set("Content-Type", "application/json")
76+
}
77+
78+
for key, value := range h.config.Headers {
79+
req.Header.Set(key, value)
80+
}
81+
82+
resp, err := client.Do(req)
83+
if err != nil {
84+
return nil, fmt.Errorf("request failed: %v", err)
85+
}
86+
defer resp.Body.Close()
87+
88+
maxSize := int64(1 << 20)
89+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to read response body: %v", err)
92+
}
93+
94+
return &tools.ToolCallResult{Output: string(body)}, nil
95+
}
96+
97+
type APIToolOption func(*APITool)
98+
99+
func NewAPITool(config latest.APIToolConfig) *APITool {
100+
return &APITool{
101+
config: config,
102+
handler: &apiHandler{
103+
config: config,
104+
},
105+
}
106+
}
107+
108+
func (t *APITool) Instructions() string {
109+
return t.config.Instruction
110+
}
111+
112+
func (t *APITool) Tools(context.Context) ([]tools.Tool, error) {
113+
inputSchema, err := tools.SchemaToMap(map[string]any{
114+
"type": "object",
115+
"properties": t.config.Args,
116+
"required": t.config.Required,
117+
})
118+
if err != nil {
119+
return nil, fmt.Errorf("invalid schema: %w", err)
120+
}
121+
122+
parsedURL, err := url.Parse(t.config.Endpoint)
123+
if err != nil {
124+
return nil, fmt.Errorf("invalid URL: %w", err)
125+
}
126+
127+
if parsedURL.Scheme == "" || parsedURL.Host == "" {
128+
return nil, fmt.Errorf("invalid URL: missing scheme or host")
129+
}
130+
131+
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
132+
return nil, fmt.Errorf("only HTTP and HTTPS URLs are supported")
133+
}
134+
135+
return []tools.Tool{
136+
{
137+
Name: t.config.Name,
138+
Category: "api",
139+
Description: t.config.Instruction,
140+
Parameters: inputSchema,
141+
OutputSchema: tools.MustSchemaFor[string](),
142+
Handler: t.handler.CallTool,
143+
Annotations: tools.ToolAnnotations{
144+
ReadOnlyHint: true,
145+
Title: "API URLs",
146+
},
147+
},
148+
}, nil
149+
}
150+
151+
func (t *APITool) Start(context.Context) error {
152+
return nil
153+
}
154+
155+
func (t *APITool) Stop(context.Context) error {
156+
return nil
157+
}

0 commit comments

Comments
 (0)