Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2452,6 +2452,12 @@
"description": "HTTP timeout in seconds (valid for type 'fetch', 'api', and 'openapi'). Defaults to 30 seconds when omitted.",
"minimum": 1
},
"max_output_bytes": {
"type": "integer",
"description": "Maximum OpenAPI text output in bytes (only valid for type 'openapi'). Defaults to 30000 when omitted; 0 disables the text cutoff.",
"minimum": 0,
"default": 30000
},
"escape_html": {
"type": "boolean",
"description": "Restore legacy HTML escaping of <, > and & in multi-URL JSON results (only valid for type 'fetch'). Defaults to false to reduce token usage. Decoded content and single-URL results are unchanged.",
Expand Down Expand Up @@ -2576,6 +2582,21 @@
}
},
"additionalProperties": false,
"if": {
"required": [
"max_output_bytes"
]
},
"then": {
"properties": {
"type": {
"const": "openapi"
}
},
"required": [
"type"
]
},
"anyOf": [
{
"allOf": [
Expand Down
21 changes: 20 additions & 1 deletion docs/tools/openapi/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ When Docker Desktop is running, eligible public destinations use its PAC proxy b

| Property | Type | Required | Description |
| ------------------- | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | string | ✓ | URL of the OpenAPI specification (JSON format). Supports `${env.VAR}` interpolation. |
| `url` | string | ✓ | URL of the OpenAPI specification (JSON or YAML format). Supports `${env.VAR}` interpolation. |
| `headers` | map[string]string | ✗ | Custom HTTP headers sent with every request — both the spec fetch and every generated tool call. Values support `${env.VAR}` and `${headers.NAME}` placeholders (the latter forwards a header from the caller's incoming request when docker agent is exposed as a server). |
| `timeout` | int | ✗ | HTTP client timeout in seconds (default: `30`). Applies to both the spec fetch and the generated tools' requests. |
| `max_output_bytes` | integer | ✗ | Maximum returned response text in bytes. Omit for 30,000; `0` disables this cutoff while retaining the 1 MiB HTTP read cap. |
| `allow_private_ips` | boolean | ✗ | Opt in to dialling **non-public** IP addresses (loopback, RFC1918, link-local — including the cloud-metadata endpoint at `169.254.169.254` — multicast and the unspecified address). Set to `true` only when the spec or its servers legitimately target internal services. By default such addresses are refused at dial time, after DNS resolution, so DNS rebinding cannot bypass the check. |

## How it works
Expand All @@ -75,6 +76,24 @@ When Docker Desktop is running, eligible public destinations use its PAC proxy b
4. Read-only operations (GET, HEAD, OPTIONS) are annotated accordingly.
5. Responses are returned as text; errors include the HTTP status code.

## Returned text size

Generated tools return at most 30,000 bytes of response text by default. Set
`max_output_bytes` to a larger positive limit, or `0` to disable this text cutoff:

```yaml
toolsets:
- type: openapi
url: https://raw.githubusercontent.com/PokeAPI/pokeapi/master/openapi.yml
tools: [pokemon_retrieve]
max_output_bytes: 0
```

The separate **1 MiB HTTP response read cap** still applies. Larger outputs also
remain subject to agent-level `max_tool_result_tokens`, context limits and provider
limits. Fetching more text can increase latency and token cost; a field-filtering
adapter may be preferable when most of a response is irrelevant.

## Limits

- The OpenAPI spec must be **10 MB or less**.
Expand Down
13 changes: 13 additions & 0 deletions examples/openapi-pokemon.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
agents:
root:
model: openai/gpt-4.1-mini
description: Pokédex lookup with complete API responses
instruction: |
Look up species using pokemon_retrieve with id set to their name.
Report types and sum the six stats[].base_stat values.
Use returned facts, not remembered values.
toolsets:
- type: openapi
url: https://raw.githubusercontent.com/PokeAPI/pokeapi/master/openapi.yml
tools: [pokemon_retrieve]
max_output_bytes: 0
99 changes: 99 additions & 0 deletions pkg/config/latest/openapi_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package latest

import (
"encoding/json"
"testing"

"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestToolsetMaxOutputBytesValidation(t *testing.T) {
t.Parallel()

tests := []struct {
name string
toolset Toolset
wantErr string
}{
{name: "omitted", toolset: Toolset{Type: "openapi", URL: "https://api.example.com/spec.yaml"}},
{name: "disabled", toolset: Toolset{Type: "openapi", URL: "https://api.example.com/spec.yaml", MaxOutputBytes: new(0)}},
{name: "positive", toolset: Toolset{Type: "openapi", URL: "https://api.example.com/spec.yaml", MaxOutputBytes: new(1024)}},
{name: "negative", toolset: Toolset{Type: "openapi", URL: "https://api.example.com/spec.yaml", MaxOutputBytes: new(-1)}, wantErr: "max_output_bytes must not be negative"},
{name: "shell zero", toolset: Toolset{Type: "shell", MaxOutputBytes: new(0)}, wantErr: "max_output_bytes can only be used with type 'openapi'"},
{name: "fetch positive", toolset: Toolset{Type: "fetch", MaxOutputBytes: new(1024)}, wantErr: "max_output_bytes can only be used with type 'openapi'"},
{name: "missing type", toolset: Toolset{MaxOutputBytes: new(0)}, wantErr: "max_output_bytes can only be used with type 'openapi'"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

inline := Config{Agents: Agents{{Name: "root", Toolsets: []Toolset{tt.toolset}}}}
named := Config{Toolsets: map[string]Toolset{"api": tt.toolset}}
data, err := yaml.Marshal(tt.toolset)
require.NoError(t, err)
var parsed Toolset
parseErr := yaml.Unmarshal(data, &parsed)

if tt.wantErr != "" {
require.EqualError(t, tt.toolset.validate(), tt.wantErr)
require.ErrorContains(t, inline.Validate(), tt.wantErr)
require.ErrorContains(t, named.Validate(), "toolsets.api: "+tt.wantErr)
require.ErrorContains(t, parseErr, tt.wantErr)
return
}
require.NoError(t, tt.toolset.validate())
require.NoError(t, inline.Validate())
require.NoError(t, named.Validate())
require.NoError(t, parseErr)
assert.Equal(t, tt.toolset.MaxOutputBytes, parsed.MaxOutputBytes)
})
}
}

func TestToolsetMaxOutputBytesRoundTrip(t *testing.T) {
t.Parallel()

tests := []struct {
name string
value *int
}{
{name: "omitted"},
{name: "disabled", value: new(0)},
{name: "positive", value: new(1024)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

toolset := Toolset{Type: "openapi", URL: "https://api.example.com/spec.yaml", MaxOutputBytes: tt.value}

jsonData, err := json.Marshal(toolset)
require.NoError(t, err)
var jsonFields map[string]any
require.NoError(t, json.Unmarshal(jsonData, &jsonFields))
var fromJSON Toolset
require.NoError(t, json.Unmarshal(jsonData, &fromJSON))
assert.Equal(t, tt.value, fromJSON.MaxOutputBytes)

yamlData, err := yaml.Marshal(toolset)
require.NoError(t, err)
var yamlFields map[string]any
require.NoError(t, yaml.Unmarshal(yamlData, &yamlFields))
var fromYAML Toolset
require.NoError(t, yaml.Unmarshal(yamlData, &fromYAML))
assert.Equal(t, tt.value, fromYAML.MaxOutputBytes)

if tt.value == nil {
assert.NotContains(t, jsonFields, "max_output_bytes")
assert.NotContains(t, yamlFields, "max_output_bytes")
return
}
assert.EqualValues(t, *tt.value, jsonFields["max_output_bytes"])
assert.EqualValues(t, *tt.value, yamlFields["max_output_bytes"])
})
}
}
3 changes: 3 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,9 @@ type Toolset struct {
// Defaults to 30 seconds when omitted.
Timeout int `json:"timeout,omitempty"`

// MaxOutputBytes caps OpenAPI text output in bytes; nil defaults to 30000, 0 disables the cutoff.
MaxOutputBytes *int `json:"max_output_bytes,omitempty" yaml:"max_output_bytes,omitempty"`

// EscapeHTML restores legacy HTML escaping in fetch's multi-URL JSON results.
// Defaults to false; single-URL results are unaffected.
EscapeHTML *bool `json:"escape_html,omitempty" yaml:"escape_html,omitempty"`
Expand Down
6 changes: 6 additions & 0 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@ func (t *Toolset) validate() error {
if err := validateNonEmptyEntries("blocked_servers", t.BlockedServers); err != nil {
return err
}
if t.MaxOutputBytes != nil && t.Type != "openapi" {
return errors.New("max_output_bytes can only be used with type 'openapi'")
}
if t.EscapeHTML != nil && t.Type != "fetch" {
return errors.New("escape_html can only be used with type 'fetch'")
}
Expand Down Expand Up @@ -459,6 +462,9 @@ func (t *Toolset) validate() error {
if t.URL == "" {
return errors.New("openapi toolset requires a url to be set")
}
if t.MaxOutputBytes != nil && *t.MaxOutputBytes < 0 {
return errors.New("max_output_bytes must not be negative")
}
case "open_url":
if t.URL == "" {
return errors.New("open_url toolset requires a url to be set")
Expand Down
104 changes: 104 additions & 0 deletions pkg/config/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,110 @@ agents:
}
}

func TestJsonSchemaOpenAPIMaxOutputBytes(t *testing.T) {
t.Parallel()

schemaBytes, err := os.ReadFile(schemaFile)
require.NoError(t, err)
schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(schemaBytes))
require.NoError(t, err)

tests := []struct {
name string
toolType string
value any
valid bool
}{
{name: "omitted", toolType: "openapi", valid: true},
{name: "disabled", toolType: "openapi", value: 0, valid: true},
{name: "positive", toolType: "openapi", value: 1024, valid: true},
{name: "negative", toolType: "openapi", value: -1},
{name: "fractional", toolType: "openapi", value: 1.5},
{name: "string", toolType: "openapi", value: "1024"},
{name: "shell zero", toolType: "shell", value: 0},
{name: "fetch positive", toolType: "fetch", value: 1024},
{name: "missing type", value: 0},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

toolset := map[string]any{}
if tt.toolType != "" {
toolset["type"] = tt.toolType
}
if tt.toolType == "openapi" {
toolset["url"] = "https://api.example.com/spec.yaml"
}
if tt.value != nil {
toolset["max_output_bytes"] = tt.value
}
configs := map[string]any{
"inline": map[string]any{
"agents": map[string]any{"root": map[string]any{"toolsets": []any{toolset}}},
},
"named": map[string]any{
"agents": map[string]any{"root": map[string]any{"use_toolsets": []any{"api"}}},
"toolsets": map[string]any{"api": toolset},
},
}
for name, config := range configs {
data, err := json.Marshal(config)
require.NoError(t, err)
result, err := schema.Validate(gojsonschema.NewBytesLoader(data))
require.NoError(t, err)
assert.Equal(t, tt.valid, result.Valid(), "%s: %v", name, result.Errors())
}
})
}
}

func TestLoadOpenAPIMaxOutputBytes(t *testing.T) {
t.Parallel()

tests := []struct {
name string
version string
field string
value *int
wantErr string
}{
{name: "latest omitted"},
{name: "legacy omitted", version: "15"},
{name: "disabled", field: "max_output_bytes: 0", value: new(0)},
{name: "positive", field: "max_output_bytes: 1024", value: new(1024)},
{name: "negative", field: "max_output_bytes: -1", wantErr: "max_output_bytes must not be negative"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

data := fmt.Appendf(nil, `agents:
root:
model: openai/gpt-4o
toolsets:
- type: openapi
url: https://api.example.com/spec.yaml
%s
`, tt.field)
if tt.version != "" {
data = fmt.Appendf(data, "version: %q\n", tt.version)
}
cfg, err := Load(t.Context(), NewBytesSource("openapi.yaml", data))
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.Len(t, cfg.Agents, 1)
require.Len(t, cfg.Agents[0].Toolsets, 1)
assert.Equal(t, tt.value, cfg.Agents[0].Toolsets[0].MaxOutputBytes)
})
}
}

// TestSchemaMatchesGoTypes verifies that every JSON-tagged field in the Go
// config structs has a corresponding property in agent-schema.json (and
// vice-versa). This prevents the schema from silently drifting out of sync
Expand Down
20 changes: 16 additions & 4 deletions pkg/tools/builtin/openapi/limit.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
package openapi

import (
"fmt"
"unicode/utf8"
)

const maxOutputSize = 30000

func limitOutput(output string) string {
if len(output) > maxOutputSize {
return output[:maxOutputSize] + "\n\n[Output truncated: exceeded 30,000 character limit]"
func limitOutput(output string, limit int) string {
if limit <= 0 || len(output) <= limit {
return output
}
end := limit
for end > 0 && !utf8.RuneStart(output[end]) {
end--
}
if limit == maxOutputSize {
return output[:end] + "\n\n[Output truncated: exceeded 30,000 character limit]"
}
return output
return output[:end] + fmt.Sprintf("\n\n[Output truncated: exceeded %d byte limit]", limit)
}
Loading
Loading