Skip to content

Commit 372bf3f

Browse files
authored
Merge pull request #1635 from dgageot/config-v5
Freeze v4 and bump config version to v5
2 parents e4cbc97 + 080805a commit 372bf3f

11 files changed

Lines changed: 2034 additions & 33 deletions

File tree

cagent-schema.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "http://json-schema.org/draft-07/schema#",
33
"$id": "https://github.com/cagent/cagent/blob/main/cagent-schema.json",
44
"title": "Cagent Configuration",
5-
"description": "Configuration schema for Cagent v4",
5+
"description": "Configuration schema for Cagent v5",
66
"type": "object",
77
"properties": {
88
"version": {
@@ -13,14 +13,16 @@
1313
"1",
1414
"2",
1515
"3",
16-
"4"
16+
"4",
17+
"5"
1718
],
1819
"examples": [
1920
"0",
2021
"1",
2122
"2",
2223
"3",
23-
"4"
24+
"4",
25+
"5"
2426
]
2527
},
2628
"providers": {

pkg/config/latest/types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"github.com/docker/cagent/pkg/config/types"
1212
)
1313

14-
const Version = "4"
14+
const Version = "5"
1515

1616
// Config represents the entire configuration file
1717
type Config struct {

pkg/config/latest/upgrade.go

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,17 @@
11
package latest
22

33
import (
4-
"github.com/goccy/go-yaml"
5-
64
"github.com/docker/cagent/pkg/config/types"
7-
previous "github.com/docker/cagent/pkg/config/v3"
5+
previous "github.com/docker/cagent/pkg/config/v4"
86
)
97

10-
func UpgradeIfNeeded(c any, raw []byte) (any, error) {
8+
func UpgradeIfNeeded(c any, _ []byte) (any, error) {
119
old, ok := c.(previous.Config)
1210
if !ok {
1311
return c, nil
1412
}
1513

16-
// Put the agents on the side
17-
previousAgents := old.Agents
18-
old.Agents = nil
19-
2014
var config Config
2115
types.CloneThroughJSON(old, &config)
22-
23-
// For agents, we have to read in what they order they appear in the raw config
24-
type Original struct {
25-
Agents yaml.MapSlice `yaml:"agents"`
26-
}
27-
28-
var original Original
29-
if err := yaml.Unmarshal(raw, &original); err != nil {
30-
return nil, err
31-
}
32-
33-
for _, agent := range original.Agents {
34-
name := agent.Key.(string)
35-
36-
var agentConfig AgentConfig
37-
types.CloneThroughJSON(previousAgents[name], &agentConfig)
38-
agentConfig.Name = name
39-
40-
config.Agents = append(config.Agents, agentConfig)
41-
}
42-
4316
return config, nil
4417
}

pkg/config/v4/parse.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package v4
2+
3+
import "github.com/goccy/go-yaml"
4+
5+
func Parse(data []byte) (Config, error) {
6+
var cfg Config
7+
err := yaml.UnmarshalWithOptions(data, &cfg, yaml.Strict())
8+
return cfg, err
9+
}

pkg/config/v4/schema_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package v4
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"reflect"
7+
"sort"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// schemaFile is the path to the JSON schema file relative to the repo root.
16+
const schemaFile = "../../../cagent-schema.json"
17+
18+
// jsonSchema mirrors the subset of JSON Schema we need for comparison.
19+
type jsonSchema struct {
20+
Properties map[string]jsonSchema `json:"properties,omitempty"`
21+
Definitions map[string]jsonSchema `json:"definitions,omitempty"`
22+
Ref string `json:"$ref,omitempty"`
23+
Items *jsonSchema `json:"items,omitempty"`
24+
AdditionalProperties any `json:"additionalProperties,omitempty"`
25+
}
26+
27+
// resolveRef follows a $ref like "#/definitions/Foo" and returns the
28+
// referenced schema. When no $ref is present it returns the receiver unchanged.
29+
func (s jsonSchema) resolveRef(root jsonSchema) jsonSchema {
30+
if s.Ref == "" {
31+
return s
32+
}
33+
const prefix = "#/definitions/"
34+
if !strings.HasPrefix(s.Ref, prefix) {
35+
return s
36+
}
37+
name := strings.TrimPrefix(s.Ref, prefix)
38+
if def, ok := root.Definitions[name]; ok {
39+
return def
40+
}
41+
return s
42+
}
43+
44+
// structJSONFields returns the set of JSON property names declared on a Go
45+
// struct type via `json:"<name>,…"` tags. Fields tagged with `json:"-"` are
46+
// excluded. It recurses into anonymous (embedded) struct fields so that
47+
// promoted fields are included.
48+
func structJSONFields(t reflect.Type) map[string]bool {
49+
if t.Kind() == reflect.Ptr {
50+
t = t.Elem()
51+
}
52+
fields := make(map[string]bool)
53+
for i := range t.NumField() {
54+
f := t.Field(i)
55+
56+
// Recurse into anonymous (embedded) structs.
57+
if f.Anonymous {
58+
for k, v := range structJSONFields(f.Type) {
59+
fields[k] = v
60+
}
61+
continue
62+
}
63+
64+
tag := f.Tag.Get("json")
65+
if tag == "" || tag == "-" {
66+
continue
67+
}
68+
name, _, _ := strings.Cut(tag, ",")
69+
if name != "" && name != "-" {
70+
fields[name] = true
71+
}
72+
}
73+
return fields
74+
}
75+
76+
// schemaProperties returns the set of property names from a JSON schema
77+
// definition. It does NOT follow $ref on individual properties – it only
78+
// looks at the top-level "properties" map.
79+
func schemaProperties(def jsonSchema) map[string]bool {
80+
props := make(map[string]bool, len(def.Properties))
81+
for k := range def.Properties {
82+
props[k] = true
83+
}
84+
return props
85+
}
86+
87+
func sortedKeys(m map[string]bool) []string {
88+
keys := make([]string, 0, len(m))
89+
for k := range m {
90+
keys = append(keys, k)
91+
}
92+
sort.Strings(keys)
93+
return keys
94+
}
95+
96+
// TestSchemaMatchesGoTypes verifies that every JSON-tagged field in the Go
97+
// config structs has a corresponding property in cagent-schema.json (and
98+
// vice-versa). This prevents the schema from silently drifting out of sync
99+
// with the Go types.
100+
func TestSchemaMatchesGoTypes(t *testing.T) {
101+
t.Parallel()
102+
103+
data, err := os.ReadFile(schemaFile)
104+
require.NoError(t, err, "failed to read schema file – run this test from the repo root")
105+
106+
var root jsonSchema
107+
require.NoError(t, json.Unmarshal(data, &root))
108+
109+
// mapping maps a JSON Schema definition name (or pseudo-name for inline
110+
// schemas) to the corresponding Go type. For top-level definitions that
111+
// live in the "definitions" section of the schema we use their exact
112+
// name. For schemas inlined inside a parent property we use
113+
// "Parent.property" as the key.
114+
type entry struct {
115+
goType reflect.Type
116+
schemaDef jsonSchema
117+
schemaName string // human-readable name for error messages
118+
}
119+
120+
entries := []entry{
121+
// Top-level Config
122+
{reflect.TypeOf(Config{}), root, "Config (top-level)"},
123+
}
124+
125+
// Definitions that map 1:1 to a Go struct.
126+
definitionMap := map[string]reflect.Type{
127+
"AgentConfig": reflect.TypeOf(AgentConfig{}),
128+
"FallbackConfig": reflect.TypeOf(FallbackConfig{}),
129+
"ModelConfig": reflect.TypeOf(ModelConfig{}),
130+
"Metadata": reflect.TypeOf(Metadata{}),
131+
"ProviderConfig": reflect.TypeOf(ProviderConfig{}),
132+
"Toolset": reflect.TypeOf(Toolset{}),
133+
"Remote": reflect.TypeOf(Remote{}),
134+
"SandboxConfig": reflect.TypeOf(SandboxConfig{}),
135+
"ScriptShellToolConfig": reflect.TypeOf(ScriptShellToolConfig{}),
136+
"PostEditConfig": reflect.TypeOf(PostEditConfig{}),
137+
"PermissionsConfig": reflect.TypeOf(PermissionsConfig{}),
138+
"HooksConfig": reflect.TypeOf(HooksConfig{}),
139+
"HookMatcherConfig": reflect.TypeOf(HookMatcherConfig{}),
140+
"HookDefinition": reflect.TypeOf(HookDefinition{}),
141+
"RoutingRule": reflect.TypeOf(RoutingRule{}),
142+
"ApiConfig": reflect.TypeOf(APIToolConfig{}),
143+
}
144+
145+
for name, goType := range definitionMap {
146+
def, ok := root.Definitions[name]
147+
require.True(t, ok, "schema definition %q not found", name)
148+
entries = append(entries, entry{goType, def, name})
149+
}
150+
151+
// Inline schemas that don't have their own top-level definition but are
152+
// nested inside a parent property.
153+
type inlineEntry struct {
154+
goType reflect.Type
155+
// path navigates from a schema definition to the inline object,
156+
// e.g. []string{"RAGConfig", "results"} → definitions.RAGConfig.properties.results
157+
path []string
158+
name string
159+
}
160+
161+
inlines := []inlineEntry{
162+
{reflect.TypeOf(StructuredOutput{}), []string{"AgentConfig", "structured_output"}, "StructuredOutput (AgentConfig.structured_output)"},
163+
{reflect.TypeOf(RAGConfig{}), []string{"RAGConfig"}, "RAGConfig"},
164+
{reflect.TypeOf(RAGToolConfig{}), []string{"RAGConfig", "tool"}, "RAGToolConfig (RAGConfig.tool)"},
165+
{reflect.TypeOf(RAGResultsConfig{}), []string{"RAGConfig", "results"}, "RAGResultsConfig (RAGConfig.results)"},
166+
{reflect.TypeOf(RAGFusionConfig{}), []string{"RAGConfig", "results", "fusion"}, "RAGFusionConfig (RAGConfig.results.fusion)"},
167+
{reflect.TypeOf(RAGRerankingConfig{}), []string{"RAGConfig", "results", "reranking"}, "RAGRerankingConfig (RAGConfig.results.reranking)"},
168+
{reflect.TypeOf(RAGChunkingConfig{}), []string{"RAGConfig", "strategies", "*", "chunking"}, "RAGChunkingConfig (RAGConfig.strategies[].chunking)"},
169+
}
170+
171+
for _, il := range inlines {
172+
def := navigateSchema(t, root, il.path)
173+
entries = append(entries, entry{il.goType, def, il.name})
174+
}
175+
176+
// Now compare each entry.
177+
for _, e := range entries {
178+
goFields := structJSONFields(e.goType)
179+
schemaProps := schemaProperties(e.schemaDef)
180+
181+
missingInSchema := diff(goFields, schemaProps)
182+
missingInGo := diff(schemaProps, goFields)
183+
184+
assert.Empty(t, sortedKeys(missingInSchema),
185+
"%s: Go struct has JSON fields not present in the schema", e.schemaName)
186+
assert.Empty(t, sortedKeys(missingInGo),
187+
"%s: schema has properties not present in the Go struct", e.schemaName)
188+
}
189+
}
190+
191+
// navigateSchema walks from a top-level definition through nested properties.
192+
// path[0] is the definition name; subsequent elements are property names.
193+
// The special element "*" dereferences an array's "items" schema.
194+
func navigateSchema(t *testing.T, root jsonSchema, path []string) jsonSchema {
195+
t.Helper()
196+
require.NotEmpty(t, path)
197+
198+
cur, ok := root.Definitions[path[0]]
199+
require.True(t, ok, "definition %q not found", path[0])
200+
201+
// Resolve top-level $ref if present.
202+
cur = cur.resolveRef(root)
203+
204+
for _, segment := range path[1:] {
205+
if segment == "*" {
206+
require.NotNil(t, cur.Items, "expected items schema at %v", path)
207+
cur = *cur.Items
208+
cur = cur.resolveRef(root)
209+
continue
210+
}
211+
prop, ok := cur.Properties[segment]
212+
require.True(t, ok, "property %q not found at %v", segment, path)
213+
prop = prop.resolveRef(root)
214+
cur = prop
215+
}
216+
return cur
217+
}
218+
219+
// diff returns keys present in a but not in b.
220+
func diff(a, b map[string]bool) map[string]bool {
221+
d := make(map[string]bool)
222+
for k := range a {
223+
if !b[k] {
224+
d[k] = true
225+
}
226+
}
227+
return d
228+
}

0 commit comments

Comments
 (0)