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
26 changes: 26 additions & 0 deletions internal/spec/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package spec

import "strings"

// projectDefaults holds trimmed Project.spec.defaults values (design doc §7.1).
// Runtime is included for API symmetry with the YAML schema; see NormalizeProjectGraph
// for which fields are applied to MVP resource specs.
type projectDefaults struct {
Runtime string
Model string
Policy string
}

// readProjectDefaults returns trimmed defaults from the merged project graph.
// Nil or missing ProjectSpec.Defaults yields zero values (no defaults applied).
func readProjectDefaults(g *ProjectGraph) projectDefaults {
if g == nil || g.Spec.Defaults == nil {
return projectDefaults{}
}
d := g.Spec.Defaults
return projectDefaults{
Runtime: strings.TrimSpace(d.Runtime),
Model: strings.TrimSpace(d.Model),
Policy: strings.TrimSpace(d.Policy),
}
}
5 changes: 3 additions & 2 deletions internal/spec/doc.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package spec defines resource envelopes, MVP kind structs (§6–§7), and YAML loading.
// Defaults, reference resolution, and semantic validation are out of scope for now.
// Package spec defines resource envelopes, MVP kind structs (§6–§7), YAML loading,
// and project-level default application (§7.1) via NormalizeProjectGraph.
// Reference resolution, environment overrides, and semantic validation are future work.
package spec
70 changes: 70 additions & 0 deletions internal/spec/normalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package spec

import "strings"

// NormalizeProjectGraph applies Project.spec.defaults to resources that omit matching
// fields and performs trivial string canonicalization (trim surrounding ASCII space).
//
// Default application (§7.1 → effective config):
// - Agent.spec.model ← defaults.model when the agent omits model (empty / whitespace-only).
// - Agent.spec.policy ← defaults.policy when the agent omits policy.
// - Workflow.spec.policy ← defaults.policy when the workflow omits policy.
//
// defaults.runtime: MVP Agent and Workflow specs have no runtime field (§7.2, §7.4), so
// this value is not copied onto resources here; a future loader may attach it when a
// target field exists.
//
// Environment overrides are out of scope (issue #4). Mutates graphs in place.
func NormalizeProjectGraph(g *ProjectGraph) {
if g == nil {
return
}
def := readProjectDefaults(g)
_ = def.Runtime // reserved until a spec field consumes it

for _, a := range g.Agents {
if a == nil {
continue
}
normalizeAgentSpec(&a.Spec, def.Model, def.Policy)
}
for _, w := range g.Workflows {
if w == nil {
continue
}
normalizeWorkflowSpec(&w.Spec, def.Policy)
}
}

func normalizeAgentSpec(spec *AgentSpec, defModel, defPolicy string) {
if spec == nil {
return
}
// Model: default when omitted; otherwise trim only.
if defModel != "" && isOmitted(spec.Model) {
spec.Model = defModel
} else {
spec.Model = strings.TrimSpace(spec.Model)
}
// Policy: default when omitted; otherwise trim only.
if defPolicy != "" && isOmitted(spec.Policy) {
spec.Policy = defPolicy
} else {
spec.Policy = strings.TrimSpace(spec.Policy)
}
}

func normalizeWorkflowSpec(spec *WorkflowSpec, defPolicy string) {
if spec == nil {
return
}
if defPolicy != "" && isOmitted(spec.Policy) {
spec.Policy = defPolicy
} else {
spec.Policy = strings.TrimSpace(spec.Policy)
}
}

func isOmitted(s string) bool {
return strings.TrimSpace(s) == ""
}
154 changes: 154 additions & 0 deletions internal/spec/normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package spec

import (
"reflect"
"testing"
)

func TestNormalizeProjectGraph_agentGetsDefaultModel(t *testing.T) {
g := &ProjectGraph{
Spec: ProjectSpec{
Defaults: &ProjectDefaults{
Model: "openai/gpt-4.1",
Policy: "default",
},
},
Agents: map[string]*AgentResource{
"reviewer": {
APIVersion: APIVersionV0,
Kind: KindAgent,
Metadata: Metadata{Name: "reviewer"},
Spec: AgentSpec{
// model intentionally omitted
Description: "does things",
},
},
},
}

NormalizeProjectGraph(g)

got := g.Agents["reviewer"].Spec.Model
if got != "openai/gpt-4.1" {
t.Fatalf("Model = %q, want default openai/gpt-4.1", got)
}
}

func TestNormalizeProjectGraph_workflowGetsDefaultPolicy(t *testing.T) {
g := &ProjectGraph{
Spec: ProjectSpec{
Defaults: &ProjectDefaults{Policy: "strict"},
},
Workflows: map[string]*WorkflowResource{
"w1": {
Kind: KindWorkflow,
Metadata: Metadata{Name: "w1"},
Spec: WorkflowSpec{Description: "x"},
},
},
}
NormalizeProjectGraph(g)
if got := g.Workflows["w1"].Spec.Policy; got != "strict" {
t.Fatalf("Workflow policy = %q, want strict", got)
}
}

func TestNormalizeProjectGraph_idempotent(t *testing.T) {
g := &ProjectGraph{
Spec: ProjectSpec{
Defaults: &ProjectDefaults{
Model: "openai/gpt-4.1",
Policy: "default",
},
},
Agents: map[string]*AgentResource{
"a": {
Kind: KindAgent,
Metadata: Metadata{Name: "a"},
Spec: AgentSpec{},
},
},
Workflows: map[string]*WorkflowResource{
"w": {
Kind: KindWorkflow,
Metadata: Metadata{Name: "w"},
Spec: WorkflowSpec{},
},
},
}

NormalizeProjectGraph(g)
afterFirst := snapshotGraph(t, g)

NormalizeProjectGraph(g)
afterSecond := snapshotGraph(t, g)

if !reflect.DeepEqual(afterFirst, afterSecond) {
t.Fatalf("second normalize changed state:\nfirst: %#v\nsecond: %#v", afterFirst, afterSecond)
}
}

func TestNormalizeProjectGraph_doesNotOverrideExplicitModel(t *testing.T) {
g := &ProjectGraph{
Spec: ProjectSpec{
Defaults: &ProjectDefaults{Model: "openai/gpt-4.1"},
},
Agents: map[string]*AgentResource{
"a": {
Spec: AgentSpec{Model: "anthropic/claude-sonnet-4"},
},
},
}
NormalizeProjectGraph(g)
if got := g.Agents["a"].Spec.Model; got != "anthropic/claude-sonnet-4" {
t.Fatalf("Model = %q, want explicit value preserved", got)
}
}

func TestNormalizeProjectGraph_trimsModelWhenSet(t *testing.T) {
g := &ProjectGraph{
Spec: ProjectSpec{
Defaults: &ProjectDefaults{Model: "fallback"},
},
Agents: map[string]*AgentResource{
"a": {
Kind: KindAgent,
Spec: AgentSpec{
Model: " custom/model ",
},
},
},
}
NormalizeProjectGraph(g)
if got := g.Agents["a"].Spec.Model; got != "custom/model" {
t.Fatalf("Model = %q, want trimmed custom/model", got)
}
}

// snapshotGraph returns a deep-ish copy of fields we mutate for DeepEqual checks.
func snapshotGraph(t *testing.T, g *ProjectGraph) map[string]any {
t.Helper()
if g == nil {
return nil
}
out := map[string]any{}
if len(g.Agents) > 0 {
am := make(map[string]AgentSpec, len(g.Agents))
for k, v := range g.Agents {
if v != nil {
am[k] = v.Spec
}
}
out["agents"] = am
}
if len(g.Workflows) > 0 {
wm := make(map[string]WorkflowSpec, len(g.Workflows))
for k, v := range g.Workflows {
if v != nil {
wm[k] = v.Spec
}
}
out["workflows"] = wm
}
return out
}
Loading