Skip to content

Commit f04193c

Browse files
authored
Merge pull request #3550 from dgageot/feat/config-version-hint
feat(config): hint when a config key requires a newer schema version
2 parents 56c4dfa + 1c77982 commit f04193c

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

pkg/config/config.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path/filepath"
1313
"slices"
14+
"strconv"
1415
"strings"
1516

1617
"github.com/goccy/go-yaml"
@@ -47,7 +48,11 @@ func Load(ctx context.Context, source Source) (*latest.Config, error) {
4748

4849
oldConfig, err := parseCurrentVersion(data, raw.Version)
4950
if err != nil {
50-
return nil, fmt.Errorf("parsing config file\n%s", yaml.FormatError(err, true, true))
51+
msg := yaml.FormatError(err, true, true)
52+
if hint := newerVersionHint(data, raw.Version, err); hint != "" {
53+
msg += "\n" + hint
54+
}
55+
return nil, fmt.Errorf("parsing config file\n%s", msg)
5156
}
5257

5358
config, err := migrateToLatestConfig(oldConfig, data)
@@ -172,6 +177,42 @@ func parseCurrentVersion(data []byte, version string) (any, error) {
172177
return parser(data)
173178
}
174179

180+
// newerVersionHint returns a user-facing hint when a strict-parse failure is
181+
// caused by a key that a newer schema version accepts. It tries the parsers
182+
// for every version above the declared one, in order, and points the user at
183+
// the smallest version that parses the config successfully. Best-effort: a
184+
// newer version may accept the config for unrelated reasons (laxer schema),
185+
// so the original unknown-field error is always shown before the hint.
186+
func newerVersionHint(data []byte, version string, parseErr error) string {
187+
var unknownField *yaml.UnknownFieldError
188+
if !errors.As(parseErr, &unknownField) {
189+
return ""
190+
}
191+
192+
current, err := strconv.Atoi(version)
193+
if err != nil {
194+
return ""
195+
}
196+
197+
parsers, _ := versions()
198+
var newer []int
199+
for v := range parsers {
200+
if n, err := strconv.Atoi(v); err == nil && n > current {
201+
newer = append(newer, n)
202+
}
203+
}
204+
slices.Sort(newer)
205+
206+
for _, n := range newer {
207+
v := strconv.Itoa(n)
208+
if _, err := parsers[v](data); err == nil {
209+
return fmt.Sprintf("hint: this key is supported by config version %s; update the top-level 'version' field (currently %s)", v, version)
210+
}
211+
}
212+
213+
return ""
214+
}
215+
175216
func migrateToLatestConfig(c any, raw []byte) (latest.Config, error) {
176217
var err error
177218

pkg/config/config_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,3 +979,57 @@ func TestProviders_Validation(t *testing.T) {
979979
})
980980
}
981981
}
982+
983+
func TestLoadUnknownKeyFromNewerVersionHint(t *testing.T) {
984+
t.Parallel()
985+
986+
// instruction_file was introduced in version 11.
987+
cfgStr := `version: "10"
988+
agents:
989+
root:
990+
model: openai/gpt-4o
991+
instruction_file: prompt.md
992+
`
993+
_, err := Load(t.Context(), NewBytesSource("test.yaml", []byte(cfgStr)))
994+
require.Error(t, err)
995+
assert.Contains(t, err.Error(), `unknown field "instruction_file"`)
996+
assert.Contains(t, err.Error(), "supported by config version 11")
997+
assert.Contains(t, err.Error(), "currently 10")
998+
}
999+
1000+
func TestLoadUnknownKeyNoNewerVersionNoHint(t *testing.T) {
1001+
t.Parallel()
1002+
1003+
cfgStr := `version: "` + latest.Version + `"
1004+
agents:
1005+
root:
1006+
model: openai/gpt-4o
1007+
instruction: test
1008+
not_a_real_key: true
1009+
`
1010+
_, err := Load(t.Context(), NewBytesSource("test.yaml", []byte(cfgStr)))
1011+
require.Error(t, err)
1012+
assert.Contains(t, err.Error(), `unknown field "not_a_real_key"`)
1013+
assert.NotContains(t, err.Error(), "hint:")
1014+
}
1015+
1016+
func TestLoadNewerVersionHintNumericOrdering(t *testing.T) {
1017+
t.Parallel()
1018+
1019+
// force_handoff was introduced in version 10. Declaring version "2" pins
1020+
// two behaviors: versions compare numerically ("10" > "2" despite
1021+
// lexicographic order) and the smallest accepting version wins (10, not
1022+
// 11 or 12).
1023+
cfgStr := `version: "2"
1024+
agents:
1025+
root:
1026+
model: openai/gpt-4o
1027+
instruction: test
1028+
force_handoff: root
1029+
`
1030+
_, err := Load(t.Context(), NewBytesSource("test.yaml", []byte(cfgStr)))
1031+
require.Error(t, err)
1032+
assert.Contains(t, err.Error(), `unknown field "force_handoff"`)
1033+
assert.Contains(t, err.Error(), "supported by config version 10")
1034+
assert.Contains(t, err.Error(), "currently 2")
1035+
}

0 commit comments

Comments
 (0)