Skip to content

Commit b3c0266

Browse files
authored
Merge pull request #88 from LAA-Software-Engineering/feat/spec-defaults-runtime-76
feat(spec): apply defaults.runtime via Agent/Workflow spec.runtime (MVP)
2 parents 487d231 + c95f927 commit b3c0266

8 files changed

Lines changed: 195 additions & 14 deletions

File tree

docs/EXAMPLES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Sections **2–3** mirror what `init` creates. **Section 4** is a separate **`gp
3939

4040
`spec.imports` lists YAML files relative to the project root. `defaults.model` uses the form **`namespace/model_id`**, where **`namespace`** matches a key under `spec.providers.models`.
4141

42+
Optional **`defaults.runtime`** sets where agents and workflows run in MVP: only **`local`** is valid (or omit for implicit local). Resources that omit **`spec.runtime`** inherit this value when the merged project graph is normalized.
43+
4244
```yaml
4345
apiVersion: agentic.dev/v0
4446
kind: Project
@@ -52,6 +54,7 @@ spec:
5254
defaults:
5355
policy: default
5456
model: mock/gpt-4
57+
runtime: local
5558
providers:
5659
models:
5760
mock:

examples/example1/project.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ spec:
1111
defaults:
1212
policy: default
1313
model: openai/gpt-4o-mini
14+
runtime: local
1415
providers:
1516
models:
1617
mock:

internal/spec/defaults.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ package spec
33
import "strings"
44

55
// projectDefaults holds trimmed Project.spec.defaults values (design doc §7.1).
6-
// Runtime is included for API symmetry with the YAML schema; see NormalizeProjectGraph
7-
// for which fields are applied to MVP resource specs.
6+
// Runtime is copied onto Agent and Workflow specs when they omit spec.runtime (issue #76).
87
type projectDefaults struct {
98
Runtime string
109
Model string

internal/spec/kinds.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type ProjectTracesConfig struct {
4949
type AgentSpec struct {
5050
Description string `yaml:"description,omitempty" json:"description,omitempty"`
5151
Model string `yaml:"model,omitempty" json:"model,omitempty"`
52+
Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"`
5253
Instructions string `yaml:"instructions,omitempty" json:"instructions,omitempty"`
5354
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"`
5455
Policy string `yaml:"policy,omitempty" json:"policy,omitempty"`
@@ -109,6 +110,7 @@ type ToolRetry struct {
109110

110111
type WorkflowSpec struct {
111112
Description string `yaml:"description,omitempty" json:"description,omitempty"`
113+
Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"`
112114
Trigger *WorkflowTrigger `yaml:"trigger,omitempty" json:"trigger,omitempty"`
113115
Input *WorkflowInput `yaml:"input,omitempty" json:"input,omitempty"`
114116
Policy string `yaml:"policy,omitempty" json:"policy,omitempty"`

internal/spec/normalize.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,34 @@ import "strings"
66
// fields and performs trivial string canonicalization (trim surrounding ASCII space).
77
//
88
// Default application (§7.1 → effective config):
9-
// - Agent.spec.model ← defaults.model when the agent omits model (empty / whitespace-only).
10-
// - Agent.spec.policy ← defaults.policy when the agent omits policy.
11-
// - Workflow.spec.policy ← defaults.policy when the workflow omits policy.
12-
//
13-
// defaults.runtime: MVP Agent and Workflow specs have no runtime field (§7.2, §7.4), so
14-
// this value is not copied onto resources here; a future loader may attach it when a
15-
// target field exists.
9+
// - Agent.spec.model ← defaults.model when the agent omits model (empty / whitespace-only).
10+
// - Agent.spec.policy ← defaults.policy when the agent omits policy.
11+
// - Agent.spec.runtime ← defaults.runtime when the agent omits runtime (issue #76).
12+
// - Workflow.spec.policy ← defaults.policy when the workflow omits policy.
13+
// - Workflow.spec.runtime ← defaults.runtime when the workflow omits runtime (issue #76).
1614
//
1715
// Environment overrides are out of scope (issue #4). Mutates graphs in place.
1816
func NormalizeProjectGraph(g *ProjectGraph) {
1917
if g == nil {
2018
return
2119
}
2220
def := readProjectDefaults(g)
23-
_ = def.Runtime // reserved until a spec field consumes it
2421

2522
for _, a := range g.Agents {
2623
if a == nil {
2724
continue
2825
}
29-
normalizeAgentSpec(&a.Spec, def.Model, def.Policy)
26+
normalizeAgentSpec(&a.Spec, def.Model, def.Policy, def.Runtime)
3027
}
3128
for _, w := range g.Workflows {
3229
if w == nil {
3330
continue
3431
}
35-
normalizeWorkflowSpec(&w.Spec, def.Policy)
32+
normalizeWorkflowSpec(&w.Spec, def.Policy, def.Runtime)
3633
}
3734
}
3835

39-
func normalizeAgentSpec(spec *AgentSpec, defModel, defPolicy string) {
36+
func normalizeAgentSpec(spec *AgentSpec, defModel, defPolicy, defRuntime string) {
4037
if spec == nil {
4138
return
4239
}
@@ -52,9 +49,14 @@ func normalizeAgentSpec(spec *AgentSpec, defModel, defPolicy string) {
5249
} else {
5350
spec.Policy = strings.TrimSpace(spec.Policy)
5451
}
52+
if defRuntime != "" && isOmitted(spec.Runtime) {
53+
spec.Runtime = defRuntime
54+
} else {
55+
spec.Runtime = strings.TrimSpace(spec.Runtime)
56+
}
5557
}
5658

57-
func normalizeWorkflowSpec(spec *WorkflowSpec, defPolicy string) {
59+
func normalizeWorkflowSpec(spec *WorkflowSpec, defPolicy, defRuntime string) {
5860
if spec == nil {
5961
return
6062
}
@@ -63,6 +65,11 @@ func normalizeWorkflowSpec(spec *WorkflowSpec, defPolicy string) {
6365
} else {
6466
spec.Policy = strings.TrimSpace(spec.Policy)
6567
}
68+
if defRuntime != "" && isOmitted(spec.Runtime) {
69+
spec.Runtime = defRuntime
70+
} else {
71+
spec.Runtime = strings.TrimSpace(spec.Runtime)
72+
}
6673
}
6774

6875
func isOmitted(s string) bool {

internal/spec/normalize_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,44 @@ func TestNormalizeProjectGraph_agentGetsDefaultModel(t *testing.T) {
3434
}
3535
}
3636

37+
func TestNormalizeProjectGraph_agentGetsDefaultRuntime(t *testing.T) {
38+
g := &ProjectGraph{
39+
Spec: ProjectSpec{
40+
Defaults: &ProjectDefaults{Runtime: "local"},
41+
},
42+
Agents: map[string]*AgentResource{
43+
"a": {
44+
Kind: KindAgent,
45+
Metadata: Metadata{Name: "a"},
46+
Spec: AgentSpec{Model: "mock/x"},
47+
},
48+
},
49+
}
50+
NormalizeProjectGraph(g)
51+
if got := g.Agents["a"].Spec.Runtime; got != "local" {
52+
t.Fatalf("Runtime = %q, want local", got)
53+
}
54+
}
55+
56+
func TestNormalizeProjectGraph_workflowGetsDefaultRuntime(t *testing.T) {
57+
g := &ProjectGraph{
58+
Spec: ProjectSpec{
59+
Defaults: &ProjectDefaults{Runtime: "local"},
60+
},
61+
Workflows: map[string]*WorkflowResource{
62+
"w": {
63+
Kind: KindWorkflow,
64+
Metadata: Metadata{Name: "w"},
65+
Spec: WorkflowSpec{Policy: "p"},
66+
},
67+
},
68+
}
69+
NormalizeProjectGraph(g)
70+
if got := g.Workflows["w"].Spec.Runtime; got != "local" {
71+
t.Fatalf("Runtime = %q, want local", got)
72+
}
73+
}
74+
3775
func TestNormalizeProjectGraph_workflowGetsDefaultPolicy(t *testing.T) {
3876
g := &ProjectGraph{
3977
Spec: ProjectSpec{
@@ -88,6 +126,39 @@ func TestNormalizeProjectGraph_idempotent(t *testing.T) {
88126
}
89127
}
90128

129+
func TestNormalizeProjectGraph_preservesExplicitRuntimeOverDefault(t *testing.T) {
130+
g := &ProjectGraph{
131+
Spec: ProjectSpec{
132+
Defaults: &ProjectDefaults{Runtime: "local"},
133+
},
134+
Agents: map[string]*AgentResource{
135+
"a": {Spec: AgentSpec{Runtime: "edge"}},
136+
},
137+
}
138+
NormalizeProjectGraph(g)
139+
if got := g.Agents["a"].Spec.Runtime; got != "edge" {
140+
t.Fatalf("Runtime = %q, want edge (explicit value must not be replaced by defaults)", got)
141+
}
142+
}
143+
144+
func TestNormalizeProjectGraph_trimsWorkflowRuntimeWhenSet(t *testing.T) {
145+
g := &ProjectGraph{
146+
Spec: ProjectSpec{
147+
Defaults: &ProjectDefaults{Runtime: "local"},
148+
},
149+
Workflows: map[string]*WorkflowResource{
150+
"w": {
151+
Kind: KindWorkflow,
152+
Spec: WorkflowSpec{Runtime: " local "},
153+
},
154+
},
155+
}
156+
NormalizeProjectGraph(g)
157+
if got := g.Workflows["w"].Spec.Runtime; got != "local" {
158+
t.Fatalf("Runtime = %q, want trimmed local", got)
159+
}
160+
}
161+
91162
func TestNormalizeProjectGraph_doesNotOverrideExplicitModel(t *testing.T) {
92163
g := &ProjectGraph{
93164
Spec: ProjectSpec{

internal/spec/validator.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func ValidateProjectGraph(g *ProjectGraph, projectRoot string) error {
2323

2424
var errs []error
2525
errs = append(errs, validateMetadataKeys(g)...)
26+
errs = append(errs, validateMVPRuntimes(g)...)
2627
errs = append(errs, validateToolSpecs(g)...)
2728
errs = append(errs, validatePolicySpecs(g)...)
2829
errs = append(errs, validateAgentSpecs(g)...)
@@ -107,6 +108,34 @@ func metaName(v any) string {
107108
}
108109
}
109110

111+
// validateMVPRuntimes rejects non-local explicit runtimes (design doc §7.1, §16 MVP; issue #76).
112+
// Empty means implicit local; only "local" is allowed when set.
113+
func validateMVPRuntimes(g *ProjectGraph) []error {
114+
var errs []error
115+
if g.Spec.Defaults != nil {
116+
if r := strings.TrimSpace(g.Spec.Defaults.Runtime); r != "" && r != "local" {
117+
errs = append(errs, fmt.Errorf("Project: defaults.runtime %q is not supported in MVP (use \"local\" or omit)", r))
118+
}
119+
}
120+
for name, ar := range g.Agents {
121+
if ar == nil {
122+
continue
123+
}
124+
if r := strings.TrimSpace(ar.Spec.Runtime); r != "" && r != "local" {
125+
errs = append(errs, fmt.Errorf("Agent/%s: spec.runtime %q is not supported in MVP (use \"local\" or omit)", name, r))
126+
}
127+
}
128+
for name, wr := range g.Workflows {
129+
if wr == nil {
130+
continue
131+
}
132+
if r := strings.TrimSpace(wr.Spec.Runtime); r != "" && r != "local" {
133+
errs = append(errs, fmt.Errorf("Workflow/%s: spec.runtime %q is not supported in MVP (use \"local\" or omit)", name, r))
134+
}
135+
}
136+
return errs
137+
}
138+
110139
func validateToolSpecs(g *ProjectGraph) []error {
111140
var errs []error
112141
for name, tr := range g.Tools {

internal/spec/validator_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,72 @@ func TestValidateProjectGraph_duplicateApprovalActions(t *testing.T) {
176176
t.Fatalf("expected duplicate approvals error, got %v", err)
177177
}
178178
}
179+
180+
func TestValidateProjectGraph_defaultsRuntimeUnknown(t *testing.T) {
181+
g := &ProjectGraph{
182+
Spec: ProjectSpec{
183+
Defaults: &ProjectDefaults{Runtime: "k8s"},
184+
},
185+
}
186+
err := ValidateProjectGraph(g, t.TempDir())
187+
if err == nil || !strings.Contains(err.Error(), `defaults.runtime "k8s"`) {
188+
t.Fatalf("expected defaults.runtime error, got %v", err)
189+
}
190+
}
191+
192+
func TestValidateProjectGraph_agentRuntimeUnknown(t *testing.T) {
193+
g := &ProjectGraph{
194+
Agents: map[string]*AgentResource{
195+
"a": {
196+
Kind: KindAgent,
197+
Metadata: Metadata{Name: "a"},
198+
Spec: AgentSpec{Runtime: "remote"},
199+
},
200+
},
201+
}
202+
err := ValidateProjectGraph(g, t.TempDir())
203+
if err == nil || !strings.Contains(err.Error(), `Agent/a: spec.runtime "remote"`) {
204+
t.Fatalf("expected agent runtime error, got %v", err)
205+
}
206+
}
207+
208+
func TestValidateProjectGraph_workflowRuntimeUnknown(t *testing.T) {
209+
g := &ProjectGraph{
210+
Workflows: map[string]*WorkflowResource{
211+
"w": {
212+
Kind: KindWorkflow,
213+
Metadata: Metadata{Name: "w"},
214+
Spec: WorkflowSpec{Runtime: "lambda"},
215+
},
216+
},
217+
}
218+
err := ValidateProjectGraph(g, t.TempDir())
219+
if err == nil || !strings.Contains(err.Error(), `Workflow/w: spec.runtime "lambda"`) {
220+
t.Fatalf("expected workflow runtime error, got %v", err)
221+
}
222+
}
223+
224+
func TestValidateProjectGraph_runtimeLocalAccepted(t *testing.T) {
225+
g := &ProjectGraph{
226+
Spec: ProjectSpec{
227+
Defaults: &ProjectDefaults{Runtime: "local"},
228+
},
229+
Agents: map[string]*AgentResource{
230+
"a": {
231+
Kind: KindAgent,
232+
Metadata: Metadata{Name: "a"},
233+
Spec: AgentSpec{Runtime: "local"},
234+
},
235+
},
236+
Workflows: map[string]*WorkflowResource{
237+
"w": {
238+
Kind: KindWorkflow,
239+
Metadata: Metadata{Name: "w"},
240+
Spec: WorkflowSpec{Runtime: "local"},
241+
},
242+
},
243+
}
244+
if err := ValidateProjectGraph(g, t.TempDir()); err != nil {
245+
t.Fatal(err)
246+
}
247+
}

0 commit comments

Comments
 (0)