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
4 changes: 2 additions & 2 deletions internal/project/doc.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Package project loads the root project.yaml, expands spec.imports, and merges
// resources into a spec.ProjectGraph. Reference resolution and validation are separate.
// Package project loads the root project.yaml, expands spec.imports, merges resources
// into a spec.ProjectGraph, and can resolve symbolic references via ResolveReferences.
package project
19 changes: 18 additions & 1 deletion internal/project/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
package project

import "fmt"
import (
"fmt"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// MissingRefError reports a reference from Referrer to a target kind/name that is not in the graph.
type MissingRefError struct {
Referrer spec.ResourceID
Missing spec.ResourceID
}

func (e *MissingRefError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("%s references missing %s", e.Referrer.String(), e.Missing.String())
}

// DuplicateResourceError is returned when two files define the same kind/name (§9.1).
type DuplicateResourceError struct {
Expand Down
95 changes: 95 additions & 0 deletions internal/project/graph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package project

import (
"strings"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// RefIndex summarizes symbolic references between resources before resolution (issue #6).
// Names are metadata.name values; edges point to target kinds as implied by the field.
type RefIndex struct {
// AgentName -> tool names from Agent.spec.tools
AgentTools map[string][]string
// AgentName -> policy name when Agent.spec.policy is non-empty
AgentPolicies map[string]string
// WorkflowName -> agent names from steps with agent: set
WorkflowAgents map[string][]string
// WorkflowName -> tool names from steps with uses: tool.<name>...
WorkflowTools map[string][]string
// WorkflowName -> policy name when Workflow.spec.policy is non-empty
WorkflowPolicies map[string]string
}

// BuildRefIndex scans ProjectGraph resources and builds RefIndex lookup tables.
func BuildRefIndex(g *spec.ProjectGraph) *RefIndex {
if g == nil {
return &RefIndex{
AgentTools: map[string][]string{},
AgentPolicies: map[string]string{},
WorkflowAgents: map[string][]string{},
WorkflowTools: map[string][]string{},
WorkflowPolicies: map[string]string{},
}
}
ix := &RefIndex{
AgentTools: make(map[string][]string),
AgentPolicies: make(map[string]string),
WorkflowAgents: make(map[string][]string),
WorkflowTools: make(map[string][]string),
WorkflowPolicies: make(map[string]string),
}
for name, ar := range g.Agents {
if ar == nil {
continue
}
var tools []string
for _, t := range ar.Spec.Tools {
if s := strings.TrimSpace(t); s != "" {
tools = append(tools, s)
}
}
ix.AgentTools[name] = dedupeStrings(tools)
if p := strings.TrimSpace(ar.Spec.Policy); p != "" {
ix.AgentPolicies[name] = p
}
}
for name, wr := range g.Workflows {
if wr == nil {
continue
}
if p := strings.TrimSpace(wr.Spec.Policy); p != "" {
ix.WorkflowPolicies[name] = p
}
var agents, tools []string
for _, st := range wr.Spec.Steps {
if a := strings.TrimSpace(st.Agent); a != "" {
agents = append(agents, a)
}
if u := strings.TrimSpace(st.Uses); u != "" {
if tn, ok := spec.ParseToolUses(u); ok {
tools = append(tools, tn)
}
}
}
ix.WorkflowAgents[name] = dedupeStrings(agents)
ix.WorkflowTools[name] = dedupeStrings(tools)
}
return ix
}

func dedupeStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
var out []string
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
127 changes: 127 additions & 0 deletions internal/project/resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package project

import (
"fmt"
"strings"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// ResolveReferences checks that symbolic references in the merged graph resolve to
// existing resources (§9.1) and applies simple sequential workflow rules (§9.4).
func ResolveReferences(g *spec.ProjectGraph) error {
if g == nil {
return nil
}
ix := BuildRefIndex(g)

for agentName, tools := range ix.AgentTools {
for _, tn := range tools {
if _, ok := g.Tools[tn]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
}
}
}
}
for agentName, pol := range ix.AgentPolicies {
if _, ok := g.Policies[pol]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
}
}
}

for wfName, wr := range g.Workflows {
if wr == nil {
continue
}
if err := validateWorkflowSteps(wfName, &wr.Spec); err != nil {
return err
}
for _, an := range ix.WorkflowAgents[wfName] {
if _, ok := g.Agents[an]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
Missing: spec.ResourceID{Kind: spec.KindAgent, Name: an},
}
}
}
for _, tn := range ix.WorkflowTools[wfName] {
if _, ok := g.Tools[tn]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
}
}
}
if pol := ix.WorkflowPolicies[wfName]; pol != "" {
if _, ok := g.Policies[pol]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
}
}
}
if err := validateWorkflowStepOrder(wfName, &wr.Spec); err != nil {
return err
}
}
return nil
}

func validateWorkflowSteps(wfName string, w *spec.WorkflowSpec) error {
seenID := make(map[string]struct{})
for _, st := range w.Steps {
sid := strings.TrimSpace(st.ID)
if sid != "" {
if _, dup := seenID[sid]; dup {
return fmt.Errorf("workflow %s: duplicate step id %q", wfName, sid)
}
seenID[sid] = struct{}{}
}
hasA := strings.TrimSpace(st.Agent) != ""
hasU := strings.TrimSpace(st.Uses) != ""
if hasA && hasU {
return fmt.Errorf("workflow %s step %q: cannot set both agent and uses", wfName, sid)
}
if !hasA && !hasU {
return fmt.Errorf("workflow %s step %q: must set exactly one of agent or uses", wfName, sid)
}
if hasU {
u := strings.TrimSpace(st.Uses)
if _, ok := spec.ParseToolUses(u); !ok {
return fmt.Errorf("workflow %s step %q: unsupported uses %q (expected tool.<name>...)", wfName, sid, u)
}
}
}
return nil
}

func validateWorkflowStepOrder(wfName string, w *spec.WorkflowSpec) error {
idToIdx := make(map[string]int)
for i, st := range w.Steps {
id := strings.TrimSpace(st.ID)
if id == "" {
continue
}
idToIdx[id] = i
}
for i, st := range w.Steps {
sid := strings.TrimSpace(st.ID)
for _, sval := range spec.CollectWithStringValues(st.With) {
for _, dep := range spec.InterpolationStepRefs(sval) {
j, ok := idToIdx[dep]
if !ok {
return fmt.Errorf("workflow %s step %q: interpolation references unknown step %q", wfName, sid, dep)
}
if j >= i {
return fmt.Errorf("workflow %s step %q: forward reference to steps.%s (§9.4)", wfName, sid, dep)
}
}
}
}
return nil
}
74 changes: 74 additions & 0 deletions internal/project/resolver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package project

import (
"errors"
"path/filepath"
"strings"
"testing"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

func TestResolveReferences_missingAgent(t *testing.T) {
root := filepath.Join("testdata", "refs_missing_agent")
g, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
err = ResolveReferences(g)
var mr *MissingRefError
if !errors.As(err, &mr) {
t.Fatalf("want *MissingRefError, got %T: %v", err, err)
}
if mr.Referrer != (spec.ResourceID{Kind: spec.KindWorkflow, Name: "badwf"}) {
t.Fatalf("Referrer = %v", mr.Referrer)
}
if mr.Missing != (spec.ResourceID{Kind: spec.KindAgent, Name: "ghost"}) {
t.Fatalf("Missing = %v", mr.Missing)
}
}

func TestResolveReferences_unknownTool(t *testing.T) {
root := filepath.Join("testdata", "refs_unknown_tool")
g, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
err = ResolveReferences(g)
var mr *MissingRefError
if !errors.As(err, &mr) {
t.Fatalf("want *MissingRefError, got %T: %v", err, err)
}
if mr.Referrer != (spec.ResourceID{Kind: spec.KindWorkflow, Name: "uses-unknown"}) {
t.Fatalf("Referrer = %v", mr.Referrer)
}
if mr.Missing != (spec.ResourceID{Kind: spec.KindTool, Name: "nope"}) {
t.Fatalf("Missing = %v", mr.Missing)
}
}

func TestResolveReferences_forwardRefRejected(t *testing.T) {
root := filepath.Join("testdata", "refs_forward_bad")
g, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
err = ResolveReferences(g)
if err == nil {
t.Fatal("expected forward reference error")
}
if !strings.Contains(err.Error(), "forward reference") {
t.Fatalf("expected forward reference in error: %v", err)
}
}

func TestResolveReferences_validInterpolationOrder(t *testing.T) {
root := filepath.Join("testdata", "refs_forward_ok")
g, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
if err := ResolveReferences(g); err != nil {
t.Fatal(err)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: agentic.dev/v0
kind: Agent
metadata:
name: helper
spec: {}
8 changes: 8 additions & 0 deletions internal/project/testdata/refs_forward_bad/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: agentic.dev/v0
kind: Project
metadata:
name: forward-bad
spec:
imports:
- ./agents
- ./workflows
13 changes: 13 additions & 0 deletions internal/project/testdata/refs_forward_bad/workflows/wf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: agentic.dev/v0
kind: Workflow
metadata:
name: badwf
spec:
steps:
- id: first
agent: helper
with:
x: ${steps.second.output}
- id: second
agent: helper
with: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: agentic.dev/v0
kind: Agent
metadata:
name: helper
spec: {}
8 changes: 8 additions & 0 deletions internal/project/testdata/refs_forward_ok/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: agentic.dev/v0
kind: Project
metadata:
name: forward-ok
spec:
imports:
- ./agents
- ./workflows
13 changes: 13 additions & 0 deletions internal/project/testdata/refs_forward_ok/workflows/wf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: agentic.dev/v0
kind: Workflow
metadata:
name: okwf
spec:
steps:
- id: first
agent: helper
with: {}
- id: second
agent: helper
with:
x: ${steps.first.output}
7 changes: 7 additions & 0 deletions internal/project/testdata/refs_missing_agent/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: agentic.dev/v0
kind: Project
metadata:
name: refs-missing-agent
spec:
imports:
- ./workflows
Loading
Loading