Skip to content

Commit 7c7e46d

Browse files
authored
Merge pull request #38 from LAA-Software-Engineering/issue/6-refs-resolver
feat(project,spec): reference extraction, ref index, and resolver (#6)
2 parents 2b1e506 + 9422040 commit 7c7e46d

18 files changed

Lines changed: 548 additions & 3 deletions

File tree

internal/project/doc.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
// Package project loads the root project.yaml, expands spec.imports, and merges
2-
// resources into a spec.ProjectGraph. Reference resolution and validation are separate.
1+
// Package project loads the root project.yaml, expands spec.imports, merges resources
2+
// into a spec.ProjectGraph, and can resolve symbolic references via ResolveReferences.
33
package project

internal/project/errors.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
package project
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
6+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
7+
)
8+
9+
// MissingRefError reports a reference from Referrer to a target kind/name that is not in the graph.
10+
type MissingRefError struct {
11+
Referrer spec.ResourceID
12+
Missing spec.ResourceID
13+
}
14+
15+
func (e *MissingRefError) Error() string {
16+
if e == nil {
17+
return ""
18+
}
19+
return fmt.Sprintf("%s references missing %s", e.Referrer.String(), e.Missing.String())
20+
}
421

522
// DuplicateResourceError is returned when two files define the same kind/name (§9.1).
623
type DuplicateResourceError struct {

internal/project/graph.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package project
2+
3+
import (
4+
"strings"
5+
6+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
7+
)
8+
9+
// RefIndex summarizes symbolic references between resources before resolution (issue #6).
10+
// Names are metadata.name values; edges point to target kinds as implied by the field.
11+
type RefIndex struct {
12+
// AgentName -> tool names from Agent.spec.tools
13+
AgentTools map[string][]string
14+
// AgentName -> policy name when Agent.spec.policy is non-empty
15+
AgentPolicies map[string]string
16+
// WorkflowName -> agent names from steps with agent: set
17+
WorkflowAgents map[string][]string
18+
// WorkflowName -> tool names from steps with uses: tool.<name>...
19+
WorkflowTools map[string][]string
20+
// WorkflowName -> policy name when Workflow.spec.policy is non-empty
21+
WorkflowPolicies map[string]string
22+
}
23+
24+
// BuildRefIndex scans ProjectGraph resources and builds RefIndex lookup tables.
25+
func BuildRefIndex(g *spec.ProjectGraph) *RefIndex {
26+
if g == nil {
27+
return &RefIndex{
28+
AgentTools: map[string][]string{},
29+
AgentPolicies: map[string]string{},
30+
WorkflowAgents: map[string][]string{},
31+
WorkflowTools: map[string][]string{},
32+
WorkflowPolicies: map[string]string{},
33+
}
34+
}
35+
ix := &RefIndex{
36+
AgentTools: make(map[string][]string),
37+
AgentPolicies: make(map[string]string),
38+
WorkflowAgents: make(map[string][]string),
39+
WorkflowTools: make(map[string][]string),
40+
WorkflowPolicies: make(map[string]string),
41+
}
42+
for name, ar := range g.Agents {
43+
if ar == nil {
44+
continue
45+
}
46+
var tools []string
47+
for _, t := range ar.Spec.Tools {
48+
if s := strings.TrimSpace(t); s != "" {
49+
tools = append(tools, s)
50+
}
51+
}
52+
ix.AgentTools[name] = dedupeStrings(tools)
53+
if p := strings.TrimSpace(ar.Spec.Policy); p != "" {
54+
ix.AgentPolicies[name] = p
55+
}
56+
}
57+
for name, wr := range g.Workflows {
58+
if wr == nil {
59+
continue
60+
}
61+
if p := strings.TrimSpace(wr.Spec.Policy); p != "" {
62+
ix.WorkflowPolicies[name] = p
63+
}
64+
var agents, tools []string
65+
for _, st := range wr.Spec.Steps {
66+
if a := strings.TrimSpace(st.Agent); a != "" {
67+
agents = append(agents, a)
68+
}
69+
if u := strings.TrimSpace(st.Uses); u != "" {
70+
if tn, ok := spec.ParseToolUses(u); ok {
71+
tools = append(tools, tn)
72+
}
73+
}
74+
}
75+
ix.WorkflowAgents[name] = dedupeStrings(agents)
76+
ix.WorkflowTools[name] = dedupeStrings(tools)
77+
}
78+
return ix
79+
}
80+
81+
func dedupeStrings(in []string) []string {
82+
if len(in) == 0 {
83+
return nil
84+
}
85+
seen := make(map[string]struct{}, len(in))
86+
var out []string
87+
for _, s := range in {
88+
if _, ok := seen[s]; ok {
89+
continue
90+
}
91+
seen[s] = struct{}{}
92+
out = append(out, s)
93+
}
94+
return out
95+
}

internal/project/resolver.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package project
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
8+
)
9+
10+
// ResolveReferences checks that symbolic references in the merged graph resolve to
11+
// existing resources (§9.1) and applies simple sequential workflow rules (§9.4).
12+
func ResolveReferences(g *spec.ProjectGraph) error {
13+
if g == nil {
14+
return nil
15+
}
16+
ix := BuildRefIndex(g)
17+
18+
for agentName, tools := range ix.AgentTools {
19+
for _, tn := range tools {
20+
if _, ok := g.Tools[tn]; !ok {
21+
return &MissingRefError{
22+
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
23+
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
24+
}
25+
}
26+
}
27+
}
28+
for agentName, pol := range ix.AgentPolicies {
29+
if _, ok := g.Policies[pol]; !ok {
30+
return &MissingRefError{
31+
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
32+
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
33+
}
34+
}
35+
}
36+
37+
for wfName, wr := range g.Workflows {
38+
if wr == nil {
39+
continue
40+
}
41+
if err := validateWorkflowSteps(wfName, &wr.Spec); err != nil {
42+
return err
43+
}
44+
for _, an := range ix.WorkflowAgents[wfName] {
45+
if _, ok := g.Agents[an]; !ok {
46+
return &MissingRefError{
47+
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
48+
Missing: spec.ResourceID{Kind: spec.KindAgent, Name: an},
49+
}
50+
}
51+
}
52+
for _, tn := range ix.WorkflowTools[wfName] {
53+
if _, ok := g.Tools[tn]; !ok {
54+
return &MissingRefError{
55+
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
56+
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
57+
}
58+
}
59+
}
60+
if pol := ix.WorkflowPolicies[wfName]; pol != "" {
61+
if _, ok := g.Policies[pol]; !ok {
62+
return &MissingRefError{
63+
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
64+
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
65+
}
66+
}
67+
}
68+
if err := validateWorkflowStepOrder(wfName, &wr.Spec); err != nil {
69+
return err
70+
}
71+
}
72+
return nil
73+
}
74+
75+
func validateWorkflowSteps(wfName string, w *spec.WorkflowSpec) error {
76+
seenID := make(map[string]struct{})
77+
for _, st := range w.Steps {
78+
sid := strings.TrimSpace(st.ID)
79+
if sid != "" {
80+
if _, dup := seenID[sid]; dup {
81+
return fmt.Errorf("workflow %s: duplicate step id %q", wfName, sid)
82+
}
83+
seenID[sid] = struct{}{}
84+
}
85+
hasA := strings.TrimSpace(st.Agent) != ""
86+
hasU := strings.TrimSpace(st.Uses) != ""
87+
if hasA && hasU {
88+
return fmt.Errorf("workflow %s step %q: cannot set both agent and uses", wfName, sid)
89+
}
90+
if !hasA && !hasU {
91+
return fmt.Errorf("workflow %s step %q: must set exactly one of agent or uses", wfName, sid)
92+
}
93+
if hasU {
94+
u := strings.TrimSpace(st.Uses)
95+
if _, ok := spec.ParseToolUses(u); !ok {
96+
return fmt.Errorf("workflow %s step %q: unsupported uses %q (expected tool.<name>...)", wfName, sid, u)
97+
}
98+
}
99+
}
100+
return nil
101+
}
102+
103+
func validateWorkflowStepOrder(wfName string, w *spec.WorkflowSpec) error {
104+
idToIdx := make(map[string]int)
105+
for i, st := range w.Steps {
106+
id := strings.TrimSpace(st.ID)
107+
if id == "" {
108+
continue
109+
}
110+
idToIdx[id] = i
111+
}
112+
for i, st := range w.Steps {
113+
sid := strings.TrimSpace(st.ID)
114+
for _, sval := range spec.CollectWithStringValues(st.With) {
115+
for _, dep := range spec.InterpolationStepRefs(sval) {
116+
j, ok := idToIdx[dep]
117+
if !ok {
118+
return fmt.Errorf("workflow %s step %q: interpolation references unknown step %q", wfName, sid, dep)
119+
}
120+
if j >= i {
121+
return fmt.Errorf("workflow %s step %q: forward reference to steps.%s (§9.4)", wfName, sid, dep)
122+
}
123+
}
124+
}
125+
}
126+
return nil
127+
}

internal/project/resolver_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package project
2+
3+
import (
4+
"errors"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
10+
)
11+
12+
func TestResolveReferences_missingAgent(t *testing.T) {
13+
root := filepath.Join("testdata", "refs_missing_agent")
14+
g, err := LoadProject(root)
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
err = ResolveReferences(g)
19+
var mr *MissingRefError
20+
if !errors.As(err, &mr) {
21+
t.Fatalf("want *MissingRefError, got %T: %v", err, err)
22+
}
23+
if mr.Referrer != (spec.ResourceID{Kind: spec.KindWorkflow, Name: "badwf"}) {
24+
t.Fatalf("Referrer = %v", mr.Referrer)
25+
}
26+
if mr.Missing != (spec.ResourceID{Kind: spec.KindAgent, Name: "ghost"}) {
27+
t.Fatalf("Missing = %v", mr.Missing)
28+
}
29+
}
30+
31+
func TestResolveReferences_unknownTool(t *testing.T) {
32+
root := filepath.Join("testdata", "refs_unknown_tool")
33+
g, err := LoadProject(root)
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
err = ResolveReferences(g)
38+
var mr *MissingRefError
39+
if !errors.As(err, &mr) {
40+
t.Fatalf("want *MissingRefError, got %T: %v", err, err)
41+
}
42+
if mr.Referrer != (spec.ResourceID{Kind: spec.KindWorkflow, Name: "uses-unknown"}) {
43+
t.Fatalf("Referrer = %v", mr.Referrer)
44+
}
45+
if mr.Missing != (spec.ResourceID{Kind: spec.KindTool, Name: "nope"}) {
46+
t.Fatalf("Missing = %v", mr.Missing)
47+
}
48+
}
49+
50+
func TestResolveReferences_forwardRefRejected(t *testing.T) {
51+
root := filepath.Join("testdata", "refs_forward_bad")
52+
g, err := LoadProject(root)
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
err = ResolveReferences(g)
57+
if err == nil {
58+
t.Fatal("expected forward reference error")
59+
}
60+
if !strings.Contains(err.Error(), "forward reference") {
61+
t.Fatalf("expected forward reference in error: %v", err)
62+
}
63+
}
64+
65+
func TestResolveReferences_validInterpolationOrder(t *testing.T) {
66+
root := filepath.Join("testdata", "refs_forward_ok")
67+
g, err := LoadProject(root)
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
if err := ResolveReferences(g); err != nil {
72+
t.Fatal(err)
73+
}
74+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Agent
3+
metadata:
4+
name: helper
5+
spec: {}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: forward-bad
5+
spec:
6+
imports:
7+
- ./agents
8+
- ./workflows
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Workflow
3+
metadata:
4+
name: badwf
5+
spec:
6+
steps:
7+
- id: first
8+
agent: helper
9+
with:
10+
x: ${steps.second.output}
11+
- id: second
12+
agent: helper
13+
with: {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Agent
3+
metadata:
4+
name: helper
5+
spec: {}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: forward-ok
5+
spec:
6+
imports:
7+
- ./agents
8+
- ./workflows

0 commit comments

Comments
 (0)