Skip to content
Open
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
2 changes: 1 addition & 1 deletion internal/registry/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func (c *DeploymentController) FullReconcile(ctx context.Context) (int, error) {

// HandleEvent maps a source invalidation to Deployment work. Dependency changes
// intentionally use a full Deployment scan for this first controller foundation.
// Agent harness composition refs (Plugins, Skills, and Prompt instructions) and
// Agent composition refs (Plugins, Skills, and Prompt instructions) and
// Model selection are dependency events so changes requeue Deployments that may
// depend on their resolved state.
func (c *DeploymentController) HandleEvent(ctx context.Context, event v1alpha1store.ControlPlaneEvent) (int, error) {
Expand Down
6 changes: 3 additions & 3 deletions pkg/api/v1alpha1/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ type AgentSpec struct {
// Composition — top-level, harness-agnostic references to what the agent
// is assembled from. The selected Deployment harness materializes what it
// supports and drops-with-warning the rest (capability matrix). Plugins,
// Skills, and Instructions require compatibleHarnesses because a prebuilt
// Image cannot consume them by itself. MCPServers flow to harness runtimes
// and remain available to any other runtime that supports MCP. Each ref's
// Skills, and Instructions can also be delivered directly to an
// image Agent. MCPServers flow to harness runtimes and remain available to
// any other runtime that supports MCP. Each ref's
// Kind defaults to the field's resource kind; empty Tag means "resolve
// latest at reference time".
Plugins []ResourceRef `json:"plugins,omitempty" yaml:"plugins,omitempty"`
Expand Down
63 changes: 61 additions & 2 deletions pkg/api/v1alpha1/agent_harness_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,71 @@ func TestAgentHarnessValidate(t *testing.T) {
wantErr: "must be \"Prompt\"",
},
{
name: "composition requires harness compatibility",
name: "image agent with plugins does not require a harness",
spec: AgentSpec{
Plugins: []ResourceRef{{Kind: KindPlugin, Name: "x"}},
Source: &AgentSource{Image: "ghcr.io/org/agent:1.0.0"},
},
wantErr: "require compatibleHarnesses",
},
{
name: "image agent with skills does not require a harness",
spec: AgentSpec{
Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}},
Source: &AgentSource{Image: "ghcr.io/org/agent:1.0.0"},
},
},
{
name: "image agent with instructions does not require a harness",
spec: AgentSpec{
Instructions: &ResourceRef{Kind: KindPrompt, Name: "x"},
Source: &AgentSource{Image: "ghcr.io/org/agent:1.0.0"},
},
},
{
name: "plugins without image or harness remain rejected",
spec: AgentSpec{
Plugins: []ResourceRef{{Kind: KindPlugin, Name: "x"}},
},
wantErr: "plugins require compatibleHarnesses or source image",
},
{
name: "skills without image or harness remain rejected",
spec: AgentSpec{
Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}},
},
wantErr: "skills/instructions require compatibleHarnesses or source image",
},
{
name: "whitespace image does not count as a source image",
spec: AgentSpec{
Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}},
Source: &AgentSource{Image: " "},
},
wantErr: "skills/instructions require compatibleHarnesses or source image",
},
{
name: "image with surrounding whitespace is rejected",
spec: AgentSpec{
Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}},
Source: &AgentSource{Image: " ghcr.io/org/agent:1.0.0 "},
},
wantErr: "spec.source.image",
},
{
name: "repository source does not count for skills: nothing delivers composed files into a repository-built artifact",
spec: AgentSpec{
Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}},
Source: &AgentSource{Repository: &Repository{URL: "https://github.com/org/agent"}},
},
wantErr: "skills/instructions require compatibleHarnesses or source image",
},
{
name: "repository source does not count for plugins",
spec: AgentSpec{
Plugins: []ResourceRef{{Kind: KindPlugin, Name: "x"}},
Source: &AgentSource{Repository: &Repository{URL: "https://github.com/org/agent"}},
},
wantErr: "plugins require compatibleHarnesses or source image",
},
}

Expand Down
23 changes: 16 additions & 7 deletions pkg/api/v1alpha1/agent_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v1alpha1
import (
"context"
"fmt"
"strings"

"k8s.io/utils/ptr"
)
Expand Down Expand Up @@ -66,6 +67,12 @@ func validateAgentSpec(s *AgentSpec) FieldErrors {
errs.Append("spec.title", validateTitle(s.Title))
errs.Append("spec.iconUrl", validateIconURL(s.IconURL))
if s.Source != nil {
if s.Source.Image != strings.TrimSpace(s.Source.Image) {
errs.Append(
"spec.source.image",
fmt.Errorf("%w: must not contain leading or trailing whitespace", ErrInvalidFormat),
)
}
for _, e := range validateRepository(s.Source.Repository) {
errs.Append("spec.source."+e.Path, e.Cause)
}
Expand All @@ -82,8 +89,8 @@ func validateAgentSpec(s *AgentSpec) FieldErrors {

// Composition refs default their Kind IN PLACE — the deploy-time resolver
// does no defaulting, so the persisted ref must carry the kind. MCPServers
// are available to any MCP-capable runtime; plugins/skills/instructions are
// harness composition inputs and are gated below.
// are available to any MCP-capable runtime; plugins/skills/instructions can
// also be delivered to an image Agent and are gated below.
errs = append(errs, validateResourceRefs("spec.mcpServers", s.MCPServers, KindMCPServer)...)
errs = append(errs, validateResourceRefs("spec.plugins", s.Plugins, KindPlugin)...)
errs = append(errs, validateResourceRefs("spec.skills", s.Skills, KindSkill)...)
Expand All @@ -94,11 +101,13 @@ func validateAgentSpec(s *AgentSpec) FieldErrors {
errs = append(errs, validateResourceRefs("spec.instructions", []ResourceRef{*s.Instructions}, KindPrompt)...)
}

// Plugins/skills/instructions only apply to harness-compatible agents — a
// prebuilt Image cannot consume injected files by itself.
if (len(s.Plugins) > 0 || len(s.Skills) > 0 || s.Instructions != nil) &&
len(s.CompatibleHarnesses) == 0 {
errs.Append("spec", fmt.Errorf("%w: plugins/skills/instructions require compatibleHarnesses", ErrInvalidFormat))
hasHarnessCompat := len(s.CompatibleHarnesses) > 0
hasImage := s.Source != nil && strings.TrimSpace(s.Source.Image) != ""
if len(s.Plugins) > 0 && !hasHarnessCompat && !hasImage {
errs.Append("spec", fmt.Errorf("%w: plugins require compatibleHarnesses or source image", ErrInvalidFormat))
}
if (len(s.Skills) > 0 || s.Instructions != nil) && !hasHarnessCompat && !hasImage {
errs.Append("spec", fmt.Errorf("%w: skills/instructions require compatibleHarnesses or source image", ErrInvalidFormat))
}
Comment thread
xytian315 marked this conversation as resolved.

return errs
Expand Down
13 changes: 3 additions & 10 deletions pkg/types/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ func defaultApplyDependencies(ctx context.Context, in ApplyInput) ([]v1alpha1.Ob
}
hasModelRef := modelRef != nil
hasAgentRefs := ok && agent != nil &&
(len(agent.Spec.MCPServers) > 0 || hasHarnessCompositionRefs(in.Deployment, agent))
(len(agent.Spec.MCPServers) > 0 || hasAgentCompositionRefs(agent))
if in.Getter == nil {
if hasModelRef || hasAgentRefs {
return nil, fmt.Errorf("fingerprint: getter required to resolve dependency refs")
Expand All @@ -268,9 +268,6 @@ func defaultApplyDependencies(ctx context.Context, in ApplyInput) ([]v1alpha1.Ob
if err != nil {
return nil, err
}
if !deploymentSelectsHarness(in.Deployment) {
return deps, nil
}
deps, err = appendResolvedRefs(ctx, deps, in.Getter, agent.Metadata.NamespaceOrDefault(), agent.Spec.Plugins, v1alpha1.KindPlugin, "target spec.plugins")
if err != nil {
return nil, err
Expand All @@ -288,15 +285,11 @@ func defaultApplyDependencies(ctx context.Context, in ApplyInput) ([]v1alpha1.Ob
return deps, nil
}

func hasHarnessCompositionRefs(deployment *v1alpha1.Deployment, agent *v1alpha1.Agent) bool {
return deploymentSelectsHarness(deployment) && agent != nil &&
func hasAgentCompositionRefs(agent *v1alpha1.Agent) bool {
return agent != nil &&
(len(agent.Spec.Plugins) > 0 || len(agent.Spec.Skills) > 0 || agent.Spec.Instructions != nil)
}

func deploymentSelectsHarness(deployment *v1alpha1.Deployment) bool {
return deployment != nil && deployment.Spec.Harness != nil
}

func appendResolvedRefs(
ctx context.Context,
deps []v1alpha1.Object,
Expand Down
78 changes: 69 additions & 9 deletions pkg/types/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,33 +244,93 @@ func TestDefaultApplyFingerprintIncludesAgentHarnessCompositionDependencies(t *t
}
}

func TestDefaultApplyFingerprintIgnoresHarnessCompositionWhenDeploymentDoesNotSelectHarness(t *testing.T) {
func TestDefaultApplyFingerprintIncludesImageAgentCompositionDependencies(t *testing.T) {
in := testApplyInput()
in.Deployment.Spec.TargetRef = v1alpha1.ResourceRef{
Kind: v1alpha1.KindAgent,
Name: "assistant",
}
in.Target = &v1alpha1.Agent{
TypeMeta: v1alpha1.TypeMeta{Kind: v1alpha1.KindAgent},
Metadata: v1alpha1.ObjectMeta{
Namespace: "team-a",
Name: "assistant",
},
Spec: v1alpha1.AgentSpec{
CompatibleHarnesses: []v1alpha1.HarnessCompatibility{{Type: "claude-code"}},
Plugins: []v1alpha1.ResourceRef{{Name: "deploy-tools"}},
Skills: []v1alpha1.ResourceRef{{Name: "weather"}},
Instructions: &v1alpha1.ResourceRef{Name: "writer-instructions"},
Source: &v1alpha1.AgentSource{Image: "ghcr.io/example/assistant:1.0.0"},
Plugins: []v1alpha1.ResourceRef{{Name: "deploy-tools"}},
Skills: []v1alpha1.ResourceRef{{Name: "weather"}},
Instructions: &v1alpha1.ResourceRef{Name: "writer-instructions"},
},
}

pluginCommit := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
skillCommit := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
promptText := "Write concise rollout notes."
in.Getter = func(_ context.Context, ref v1alpha1.ResourceRef) (v1alpha1.Object, error) {
t.Fatalf("unexpected dependency resolution without deployment harness selection: %+v", ref)
return nil, nil
switch ref.Kind {
case v1alpha1.KindPlugin:
return testPlugin(ref.Namespace, ref.Name, pluginCommit), nil
case v1alpha1.KindSkill:
return testSkill(ref.Namespace, ref.Name, skillCommit), nil
case v1alpha1.KindPrompt:
return testPrompt(ref.Namespace, ref.Name, promptText), nil
default:
t.Fatalf("unexpected dependency ref: %+v", ref)
return nil, nil
}
}

result, err := DefaultApplyFingerprintResult(context.Background(), in, ApplyFingerprintOptions{AdapterType: "test"})
if err != nil {
t.Fatalf("DefaultApplyFingerprintResult: %v", err)
}
if len(result.Dependencies) != 0 {
t.Fatalf("dependencies = %+v, want none without deployment harness selection", result.Dependencies)
if len(result.Dependencies) != 3 {
t.Fatalf("dependencies = %+v, want Plugin, Skill, and Prompt", result.Dependencies)
}

tests := []struct {
name string
update func()
}{
{
name: "Plugin",
update: func() {
pluginCommit = "cccccccccccccccccccccccccccccccccccccccc"
},
},
{
name: "Skill",
update: func() {
skillCommit = "dddddddddddddddddddddddddddddddddddddddd"
},
},
{
name: "Prompt",
update: func() {
promptText = "Write detailed rollout notes."
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
pluginCommit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
skillCommit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
promptText = "Write concise rollout notes."
test.update()

changed, err := DefaultApplyFingerprintResult(
context.Background(),
in,
ApplyFingerprintOptions{AdapterType: "test"},
)
if err != nil {
t.Fatalf("DefaultApplyFingerprintResult after %s change: %v", test.name, err)
}
if changed.Fingerprint == result.Fingerprint {
t.Fatalf("fingerprint did not change after resolved %s changed", test.name)
}
})
}
}

Expand Down
Loading