Skip to content

Commit 57ab589

Browse files
authored
Merge pull request #1624 from dgageot/schema
Fix schema and add drift test
2 parents 316feab + 7b572ab commit 57ab589

File tree

2 files changed

+244
-0
lines changed

2 files changed

+244
-0
lines changed

cagent-schema.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@
281281
"type": "string"
282282
}
283283
},
284+
"add_description_parameter": {
285+
"type": "boolean",
286+
"description": "Whether to add a 'description' parameter to tool calls, allowing the LLM to provide context about why it is calling a tool"
287+
},
288+
"skills": {
289+
"type": "boolean",
290+
"description": "Enable skills discovery for this agent. When enabled, the agent loads skill definitions from well-known directories (~/.codex/skills, ~/.claude/skills, ~/.agents/skills, .claude/skills, .agents/skills) and includes them in the system prompt."
291+
},
284292
"hooks": {
285293
"$ref": "#/definitions/HooksConfig",
286294
"description": "Lifecycle hooks for executing shell commands at various points in the agent's execution"
@@ -579,6 +587,14 @@
579587
"readme": {
580588
"type": "string",
581589
"description": "README or description"
590+
},
591+
"description": {
592+
"type": "string",
593+
"description": "Description of the agent configuration"
594+
},
595+
"version": {
596+
"type": "string",
597+
"description": "Version of the agent configuration (used for OCI registry publishing)"
582598
}
583599
},
584600
"additionalProperties": false

pkg/config/latest/schema_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package latest
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)