diff --git a/internal/registry/controller/controller.go b/internal/registry/controller/controller.go index 3dbfebc9..0ab13211 100644 --- a/internal/registry/controller/controller.go +++ b/internal/registry/controller/controller.go @@ -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) { diff --git a/pkg/api/v1alpha1/agent.go b/pkg/api/v1alpha1/agent.go index 572c0dde..4f69890f 100644 --- a/pkg/api/v1alpha1/agent.go +++ b/pkg/api/v1alpha1/agent.go @@ -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"` diff --git a/pkg/api/v1alpha1/agent_harness_validate_test.go b/pkg/api/v1alpha1/agent_harness_validate_test.go index 5d6f0b9e..a672f802 100644 --- a/pkg/api/v1alpha1/agent_harness_validate_test.go +++ b/pkg/api/v1alpha1/agent_harness_validate_test.go @@ -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", }, } diff --git a/pkg/api/v1alpha1/agent_validate.go b/pkg/api/v1alpha1/agent_validate.go index cc217bf5..3c56800e 100644 --- a/pkg/api/v1alpha1/agent_validate.go +++ b/pkg/api/v1alpha1/agent_validate.go @@ -3,6 +3,7 @@ package v1alpha1 import ( "context" "fmt" + "strings" "k8s.io/utils/ptr" ) @@ -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) } @@ -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)...) @@ -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)) } return errs diff --git a/pkg/types/fingerprint.go b/pkg/types/fingerprint.go index 33ca63d3..a8aca2c2 100644 --- a/pkg/types/fingerprint.go +++ b/pkg/types/fingerprint.go @@ -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") @@ -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 @@ -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, diff --git a/pkg/types/fingerprint_test.go b/pkg/types/fingerprint_test.go index a888eb12..4a8683f5 100644 --- a/pkg/types/fingerprint_test.go +++ b/pkg/types/fingerprint_test.go @@ -244,8 +244,12 @@ 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{ @@ -253,24 +257,80 @@ func TestDefaultApplyFingerprintIgnoresHarnessCompositionWhenDeploymentDoesNotSe 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) + } + }) } }