Skip to content
Draft
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
6 changes: 5 additions & 1 deletion agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
},
"agents": {
"type": "object",
"description": "Map of agent configurations",
"description": "Map of agent configurations. At least one agent is required.",
"minProperties": 1,
"additionalProperties": {
"$ref": "#/definitions/AgentConfig"
}
Expand Down Expand Up @@ -109,6 +110,9 @@
}
},
"additionalProperties": false,
"required": [
"agents"
],
"definitions": {
"Lifecycle": {
"type": "object",
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ canonical: https://docs.docker.com/ai/docker-agent/configuration/agents/

_Complete reference for defining agents in your YAML configuration._

A configuration must define at least one agent under `agents`.

## Full Schema

<!-- yaml-lint:skip -->
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/overview/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ models:
model: claude-sonnet-4-5
max_tokens: 64000

# 4. Agents — define AI agents with their behavior
# 4. Agents — define AI agents with their behavior (at least one is required)
agents:
root:
model: claude
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ func migrateToLatestConfig(c any, raw []byte) (latest.Config, error) {
}

func validateConfig(cfg *latest.Config) error {
if len(cfg.Agents) == 0 {
return errors.New("at least one agent must be configured (add an entry under 'agents')")
}

if err := validateProviders(cfg); err != nil {
return err
}
Expand Down
9 changes: 7 additions & 2 deletions pkg/config/doc_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,18 @@ func TestDocYAMLSnippetsAreValid(t *testing.T) {
}

// looksLikeFullConfig returns true when the parsed YAML value is a map whose
// keys are all recognized top-level config keys. This avoids schema-validating
// partial snippets that would trivially fail required-field checks.
// keys are all recognized top-level config keys AND it declares "agents".
// The schema requires "agents" at the root, so a snippet lacking it is a
// partial doc example (e.g. a models/permissions fragment), not a full
// config, and shouldn't be schema-validated as one.
func looksLikeFullConfig(v any) bool {
m, ok := v.(map[string]any)
if !ok || len(m) == 0 {
return false
}
if _, hasAgents := m["agents"]; !hasAgents {
return false
}
for k := range m {
if !topLevelConfigKeys[k] {
return false
Expand Down
45 changes: 45 additions & 0 deletions pkg/config/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,51 @@ func TestJsonSchemaWorksForExamples(t *testing.T) {
}
}

// TestJsonSchemaRejectsZeroAgents pins the runtime's "at least one agent"
// invariant (enforced by validateConfig) at the schema level too. Both the
// root-level "required": ["agents"] and the "agents"."minProperties": 1
// constraints are needed: without either one, one of these shapes would
// pass schema validation despite being rejected at runtime.
func TestJsonSchemaRejectsZeroAgents(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
yaml string
}{
{
name: "agents omitted entirely",
yaml: `version: "12"
`,
},
{
name: "agents is an empty map",
yaml: `version: "12"
agents: {}
`,
},
}

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

var rawJSON any
require.NoError(t, yaml.Unmarshal([]byte(tt.yaml), &rawJSON))

result, err := schema.Validate(gojsonschema.NewRawLoader(rawJSON))
require.NoError(t, err)
assert.False(t, result.Valid(), "expected schema to reject config with %s", tt.name)
})
}
}

// 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
14 changes: 14 additions & 0 deletions pkg/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ agents:
assert.Contains(t, err.Error(), "2")
}

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

cfg := `version: "12"
models:
fast:
provider: openai
model: gpt-4o-mini
`
_, err := Load(t.Context(), NewBytesSource("test", []byte(cfg)))
require.Error(t, err)
assert.Contains(t, err.Error(), "at least one agent must be configured")
}

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

Expand Down
42 changes: 42 additions & 0 deletions pkg/server/agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package server

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/config/latest"
)

// TestAgentsAPIEntry_ZeroAgents pins the docker/docker-agent#3588 handler
// guard directly: agentsAPIEntry must report ok=false (not panic) for a
// config with no agents, exercising the len(cfg.Agents)==0 check regardless
// of whether validateConfig also rejects such a config at load time
// (defense in depth).
func TestAgentsAPIEntry_ZeroAgents(t *testing.T) {
t.Parallel()

cfg := &latest.Config{}

require.NotPanics(t, func() {
_, ok := agentsAPIEntry("empty", cfg)
assert.False(t, ok)
})
}

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

single := &latest.Config{Agents: latest.Agents{{Name: "root", Description: "solo"}}}
agent, ok := agentsAPIEntry("single", single)
require.True(t, ok)
assert.False(t, agent.Multi)
assert.Equal(t, "solo", agent.Description)

multi := &latest.Config{Agents: latest.Agents{{Name: "root", Description: "lead"}, {Name: "helper"}}}
agent, ok = agentsAPIEntry("multi", multi)
require.True(t, ok)
assert.True(t, agent.Multi)
assert.Equal(t, "lead", agent.Description)
}
39 changes: 22 additions & 17 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/docker/docker-agent/pkg/api"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/echolog"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
Expand Down Expand Up @@ -157,31 +158,18 @@ func (s *Server) getAgents(c echo.Context) error {
for k, agentSource := range s.sm.Sources {
slog.Debug("API source", "source", agentSource.Name())

c, err := config.Load(c.Request().Context(), agentSource)
cfg, err := config.Load(c.Request().Context(), agentSource)
if err != nil {
slog.Error("Failed to load config from API source", "key", k, "error", err)
continue
}

desc := c.Agents.First().Description

switch {
case len(c.Agents) > 1:
agents = append(agents, api.Agent{
Name: k,
Multi: true,
Description: desc,
})
case len(c.Agents) == 1:
agents = append(agents, api.Agent{
Name: k,
Multi: false,
Description: desc,
})
default:
agent, ok := agentsAPIEntry(k, cfg)
if !ok {
slog.Warn("No agents found in config from API source", "key", k)
continue
}
agents = append(agents, agent)
}

slices.SortFunc(agents, func(a, b api.Agent) int {
Expand All @@ -191,6 +179,23 @@ func (s *Server) getAgents(c echo.Context) error {
return c.JSON(http.StatusOK, agents)
}

// agentsAPIEntry summarizes a loaded config into the api.Agent listing
// entry for /api/agents. The len(cfg.Agents)==0 check MUST run before any
// access to cfg.Agents (e.g. First()), which panics on an empty slice: this
// guards the handler even though validateConfig already rejects agent-less
// configs at load time (defense in depth against a bypass or future
// regression in that check).
func agentsAPIEntry(name string, cfg *latest.Config) (api.Agent, bool) {
if len(cfg.Agents) == 0 {
return api.Agent{}, false
}
return api.Agent{
Name: name,
Multi: len(cfg.Agents) > 1,
Description: cfg.Agents.First().Description,
}, true
}

func (s *Server) getAgentConfig(c echo.Context) error {
agentID := c.Param("id")

Expand Down
22 changes: 22 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ func TestServer_EmptyList(t *testing.T) {
assert.Equal(t, "[]\n", string(buf)) // We don't want null, but an empty array
}

// TestServer_ZeroAgentSource pins the fix for docker/docker-agent#3588:
// a config source with no agents must never make GET /api/agents panic
// (latest.Agents.First() panics on an empty slice). Today validateConfig
// rejects the agent-less config at load time, so the handler's own
// len(cfg.Agents)==0 guard (agentsAPIEntry) never even gets exercised by
// this path — the request still yields a clean, empty listing rather than
// a panic either way.
func TestServer_ZeroAgentSource(t *testing.T) {
t.Parallel()

ctx := t.Context()
lnPath := startServer(t, ctx, prepareAgentsDir(t, "no_agents.yaml", "pirate.yaml"))

buf := httpGET(t, ctx, lnPath, "/api/agents")

var agents []api.Agent
unmarshal(t, buf, &agents)

require.Len(t, agents, 1)
assert.Contains(t, agents[0].Name, "pirate")
}

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

Expand Down
1 change: 1 addition & 0 deletions pkg/server/testdata/no_agents.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
version: "12"
Loading