diff --git a/STYLE.md b/STYLE.md index 257e0f5803..2554f9f5a0 100644 --- a/STYLE.md +++ b/STYLE.md @@ -278,10 +278,11 @@ All new CRD API surface goes in `v1alpha3`. // +kubebuilder:validation:XValidation:message="only one of stdio or http may be set",rule="[has(self.stdio), has(self.http)].filter(x, x).size() <= 1" ``` -- Use standard K8s types before inventing new ones: `[]metav1.Condition` for - status (with `meta.SetStatusCondition`), `corev1.LocalObjectReference` / - `TypedLocalObjectReference` for references. Start with single-namespace - (local) references; widen later only if needed. +- Use standard K8s types before inventing new ones, such as `[]metav1.Condition` + for status (with `meta.SetStatusCondition`). +- References use kagent-owned types, not `corev1.LocalObjectReference` or + `TypedObjectReference`, because kagent can't evolve a type it doesn't own. + Start with single-namespace (local) references; widen later only if needed. - References **MUST** be structured fields, not opaque `"namespace/name"` strings that callers parse. - Keep the API small: don't add convenience/sugar fields to status that can be diff --git a/docs/architecture/cross-namespace-references.md b/docs/architecture/cross-namespace-references.md index cdd7134629..6892a58b68 100644 --- a/docs/architecture/cross-namespace-references.md +++ b/docs/architecture/cross-namespace-references.md @@ -3,6 +3,9 @@ Agents can use ModelConfigs and RemoteMCPServers in other namespaces. A reference contains `name` and optionally `namespace`; omitting or leaving namespace empty uses the namespace of the reference owner. Names never contain `namespace/name`. +A model or MCP server reference may also set `kind`, and `group` with it. +Omitted values select ModelConfig or RemoteMCPServer; the controller rejects +any other kind with `ResolvedRefs=False` and reason `InvalidKind`. AgentTemplate owns its model and MCP references, including child templates. Inline templates use the Agent namespace. Harness owns memory and summarizer @@ -152,7 +155,20 @@ preserves namespaces when saving references. ## Rollout -Install the regenerated CRDs **before** upgrading the controller or applying +Install the regenerated CRDs **before** you upgrade the controller or apply namespace-qualified manifests. Older schemas can prune the namespace fields. -Existing local manifests retain their JSON shape. Go callers must adopt -`v1alpha3.ObjectReference` and `corev1.TypedObjectReference` for the changed fields. + +Manifests and stored objects from v1.0.x work unchanged and keep their revision +digest. + +- **MCP server `kind`** defaults to `RemoteMCPServer` and is stored, so v1.0.x + controllers and clients can still update the object. +- **Other kinds** on model references need the new controller and CRDs. A v1.0.x + controller ignores them, and a CRD rollback drops them, so the reference selects + the ModelConfig with the same name. Before you roll back to v1.0.x, point those + references back to the default kind. +- **`apiGroup`** stays as a deprecated MCP server field that must be omitted. The + API server drops the `null` that v1.0.x Go clients send. + +In Go, `v1alpha3.ObjectReference` is renamed `ModelConfigReference`, and +`MCPToolBinding.Server` is now an `MCPServerReference`. diff --git a/docs/kubernetes-api.md b/docs/kubernetes-api.md index 42b4393d90..28674abc03 100644 --- a/docs/kubernetes-api.md +++ b/docs/kubernetes-api.md @@ -29,9 +29,15 @@ details out of the public API unless callers need them to express intent. Namespace and name identify an object; UID distinguishes deletion and recreation. Validate names according to the referenced kind, not a universal DNS-label rule. -Use native structured types such as `LocalObjectReference`, -`TypedLocalObjectReference`, and `SecretKeySelector`. Start with same-namespace -references; crossing namespaces requires an explicit authorization policy. +Own reference types instead of reusing `corev1` ones such as +`LocalObjectReference` and `TypedObjectReference`. kagent can't add fields, +defaults, or validation to a type it doesn't own, and native semantics can +differ: `TypedObjectReference` treats an omitted `apiGroup` as the core group. +Model and MCP server references own `group` and `kind` fields that default to the +kagent kind, and the controller rejects other kinds with reason `InvalidKind`. +Existing `corev1` reference fields keep their type until they need to change. +Start with same-namespace references; crossing namespaces requires an explicit +authorization policy. Do not encode references as `"namespace/name"` strings. Keep inline configuration and references in separate, typed fields. If both forms are supported, define @@ -50,8 +56,8 @@ owning boundary. An object reference grants no access by itself. pointer wrappers. Requiredness must be explicit in the generated schema. - Declare defaults and bounds with kubebuilder markers. Explain the meaning of absent, empty, and zero values; defaults must pass validation. -- Prefer native Kubernetes quantities, selectors, references, and conditions - when their semantics match. +- Prefer native Kubernetes quantities, selectors, and conditions when their + semantics match. - Declare list/map semantics, keys, and uniqueness where merge behavior matters. Use inline embedded structs for shared sub-specs. - Interfaces in API packages carry `+kubebuilder:object:generate=false`. diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index 491adcd860..01a3d38ae0 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -46,12 +46,9 @@ type AgentTemplatePromptSource struct { // MCPToolBinding binds tools from an authorized MCP server. type MCPToolBinding struct { - // +kubebuilder:validation:XValidation:rule="self.kind == 'RemoteMCPServer'",message="kind must be RemoteMCPServer" - // +kubebuilder:validation:XValidation:rule="!has(self.apiGroup)",message="apiGroup must be omitted" - // +kubebuilder:validation:XValidation:rule="size(self.name) > 0 && size(self.name) <= 253",message="name must contain between 1 and 253 characters",fieldPath=".name" - // +kubebuilder:validation:XValidation:rule="!has(self.namespace) || size(self.namespace) <= 63",message="namespace must contain at most 63 characters",fieldPath=".namespace" + // Server is the MCP server that provides the tools. // +required - Server corev1.TypedObjectReference `json:"server"` + Server MCPServerReference `json:"server"` // Tools optionally limits which server tools are exposed. An omitted or empty // list exposes every tool. Harnesses that cannot enforce a partial selection // may expose the whole server and report a warning. @@ -176,9 +173,8 @@ type PluginBundle struct { // +kubebuilder:validation:XValidation:rule="!has(self.outputSchemaStrict) || !self.outputSchemaStrict || has(self.outputSchema) || has(self.outputSchemaFrom)",message="outputSchemaStrict requires outputSchema or outputSchemaFrom" type AgentTemplateSpec struct { // ModelConfig is required by managed harnesses and optional for BYO harnesses. - // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="name must not be empty" // +optional - ModelConfig *ObjectReference `json:"modelConfig,omitempty"` + ModelConfig *ModelConfigReference `json:"modelConfig,omitempty"` // +optional Description string `json:"description,omitempty"` // +optional diff --git a/go/api/v1alpha3/common_types.go b/go/api/v1alpha3/common_types.go index fd1d7d6051..6e3e3281bf 100644 --- a/go/api/v1alpha3/common_types.go +++ b/go/api/v1alpha3/common_types.go @@ -17,33 +17,105 @@ limitations under the License. package v1alpha3 import ( + "cmp" "fmt" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ) -// ObjectReference identifies a resource; an omitted namespace means the owner's namespace. -type ObjectReference struct { +// ModelConfigReference identifies a model. +// +kubebuilder:validation:XValidation:rule="!has(self.group) || self.group == 'api.kagent.dev' || (has(self.kind) && self.kind != 'ModelConfig')",message="a group other than api.kagent.dev requires its own kind" +type ModelConfigReference struct { + // Group is the referenced API group. It defaults to api.kagent.dev. + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + // +optional + Group *string `json:"group,omitempty"` + // Kind is the referenced kind. It defaults to ModelConfig. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` + // +optional + Kind *string `json:"kind,omitempty"` + // Name is the referenced model's name. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=253 // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` // +required Name string `json:"name"` + // Namespace is the referenced model's namespace. Empty means the owner's namespace. // +kubebuilder:validation:MaxLength=63 // +kubebuilder:validation:Pattern=`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` // +optional Namespace string `json:"namespace,omitempty"` } -// NamespacedName resolves the reference relative to its owner. -func (r ObjectReference) NamespacedName(ownerNamespace string) types.NamespacedName { - if r.Namespace != "" { - ownerNamespace = r.Namespace - } - return types.NamespacedName{Namespace: ownerNamespace, Name: r.Name} +// GroupKind returns the referenced kind, defaulting to ModelConfig. +func (r ModelConfigReference) GroupKind() schema.GroupKind { + return referenceGroupKind(r.Group, r.Kind, ModelConfigGroupKind) +} + +// Target returns the referenced kind and key, defaulting to the owner's namespace. +func (r ModelConfigReference) Target(owner string) (schema.GroupKind, types.NamespacedName) { + return r.GroupKind(), types.NamespacedName{Namespace: cmp.Or(r.Namespace, owner), Name: r.Name} +} + +// MCPServerReference identifies an MCP server. +// +kubebuilder:validation:XValidation:rule="!has(self.apiGroup)",message="use group instead of apiGroup" +// +kubebuilder:validation:XValidation:rule="!has(self.group) || self.group == 'api.kagent.dev' || (has(self.kind) && self.kind != 'RemoteMCPServer')",message="a group other than api.kagent.dev requires its own kind" +type MCPServerReference struct { + // v1.0.x Go clients, including the kagent CLI, send apiGroup as null. The gRPC + // API and strict field validation reject unknown fields, so the field stays. + + // APIGroup is not supported. + // + // Deprecated: use Group. + // +optional + APIGroup *string `json:"apiGroup,omitempty"` + // Group is the referenced API group. It defaults to api.kagent.dev. + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + // +optional + Group *string `json:"group,omitempty"` + // v1.0.x required kind. The schema default stores it, so a v1.0.x CRD or Go client + // can still update objects written without it. + + // Kind is the referenced kind. It defaults to RemoteMCPServer. + // +kubebuilder:default=RemoteMCPServer + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` + // +optional + Kind *string `json:"kind,omitempty"` + // Name is the referenced server's name. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +required + Name string `json:"name"` + // Namespace is the referenced server's namespace. Empty means the owner's namespace. + // +kubebuilder:validation:MaxLength=63 + // +optional + Namespace string `json:"namespace,omitempty"` +} + +// GroupKind returns the referenced kind, defaulting to RemoteMCPServer. +func (r MCPServerReference) GroupKind() schema.GroupKind { + return referenceGroupKind(r.Group, r.Kind, RemoteMCPServerGroupKind) +} + +// Target returns the referenced kind and key, defaulting to the owner's namespace. +func (r MCPServerReference) Target(owner string) (schema.GroupKind, types.NamespacedName) { + return r.GroupKind(), types.NamespacedName{Namespace: cmp.Or(r.Namespace, owner), Name: r.Name} +} + +// referenceGroupKind fills an omitted group or kind from the field's default. +func referenceGroupKind(group, kind *string, defaultKind schema.GroupKind) schema.GroupKind { + return schema.GroupKind{Group: ptr.Deref(group, defaultKind.Group), Kind: ptr.Deref(kind, defaultKind.Kind)} } // ConfigMapKeyReference identifies a key in a same-namespace ConfigMap. diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index 671669b751..cfa9e5c6f3 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -73,30 +73,82 @@ func TestConfigurationCRDValidation(t *testing.T) { const namespace = "configuration-crd-cel" require.NoError(t, cl.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}})) + for _, scenario := range []struct { + validation string + options []ctrlclient.CreateOption + }{ + {validation: "default"}, + {validation: "strict", options: []ctrlclient.CreateOption{ctrlclient.FieldValidation("Strict")}}, + } { + t.Run("Given references as a v1.0.x Go client encodes them, when they are created with "+scenario.validation+" field validation, assert that they are admitted unchanged", func(t *testing.T) { + // corev1.TypedObjectReference encodes an unset apiGroup as null. + template := jsonObject(t, namespace, `{"apiVersion":"api.kagent.dev/v1alpha3","kind":"AgentTemplate","metadata":{"generateName":"v1-refs-"},"spec":{ + "modelConfig":{"name":"model"}, + "tools":[{"mcp":{"server":{"apiGroup":null,"kind":"RemoteMCPServer","name":"tools","namespace":""}}}]}}`) + require.NoError(t, cl.Create(ctx, template, scenario.options...)) + + storedTemplate := &AgentTemplate{} + require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKeyFromObject(template), storedTemplate)) + require.Equal(t, &ModelConfigReference{Name: "model"}, storedTemplate.Spec.ModelConfig) + require.Equal(t, MCPServerReference{Kind: new("RemoteMCPServer"), Name: "tools"}, storedTemplate.Spec.Tools[0].MCP.Server) + }) + } + t.Run("Given an MCP server reference without kind, when it is created, assert that the stored reference names RemoteMCPServer", func(t *testing.T) { + // v1.0.x required kind, so a v1.0.x CRD or Go client needs it on every stored reference. + for _, ref := range []MCPServerReference{{Name: "tools"}, {Group: new("api.kagent.dev"), Name: "tools"}} { + template := &AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "kindless-mcp-ref-", Namespace: namespace}, Spec: AgentTemplateSpec{ + Tools: []ToolBinding{{MCP: &MCPToolBinding{Server: ref}}}, + }} + require.NoError(t, cl.Create(ctx, template)) + stored := &AgentTemplate{} + require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKeyFromObject(template), stored)) + want := ref + want.Kind = new("RemoteMCPServer") + require.Equal(t, want, stored.Spec.Tools[0].MCP.Server) + } + }) + t.Run("Given an MCP server reference to another group, when it omits kind or names RemoteMCPServer, assert that admission rejects it", func(t *testing.T) { + // The kind default must not admit a group/kind pair that can never resolve. + for _, ref := range []MCPServerReference{{Group: new("example.com"), Name: "tools"}, {Group: new("example.com"), Kind: new("RemoteMCPServer"), Name: "tools"}} { + template := &AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "foreign-group-mcp-ref-", Namespace: namespace}, Spec: AgentTemplateSpec{ + Tools: []ToolBinding{{MCP: &MCPToolBinding{Server: ref}}}, + }} + err := cl.Create(ctx, template) + require.True(t, apierrors.IsInvalid(err), "want Invalid, got %v", err) + require.ErrorContains(t, err, "a group other than api.kagent.dev requires its own kind") + } + }) + t.Run("Given a model reference to another group, when it omits kind or names ModelConfig, assert that admission rejects it", func(t *testing.T) { + for _, ref := range []ModelConfigReference{{Group: new("example.com"), Name: "model"}, {Group: new("example.com"), Kind: new("ModelConfig"), Name: "model"}} { + template := &AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "foreign-group-model-ref-", Namespace: namespace}, Spec: AgentTemplateSpec{ModelConfig: &ref}} + err := cl.Create(ctx, template) + require.True(t, apierrors.IsInvalid(err), "want Invalid, got %v", err) + require.ErrorContains(t, err, "a group other than api.kagent.dev requires its own kind") + } + }) t.Run("model discovery configuration", func(t *testing.T) { testModelDiscoveryValidation(t, cl, namespace) }) t.Run("Actor JWT credentials", func(t *testing.T) { testActorJWTValidation(t, cl, namespace) }) t.Run("cross namespace references survive admission", func(t *testing.T) { - for _, ref := range []ObjectReference{{Name: "model"}, {Name: "model", Namespace: namespace}, {Name: "model", Namespace: "shared"}} { + for _, ref := range []ModelConfigReference{{Name: "model"}, {Name: "model", Namespace: namespace}, {Name: "model", Namespace: "shared"}, {Group: new("api.kagent.dev"), Name: "model"}, {Group: new("example.com"), Kind: new("Model"), Name: "model"}} { template := &AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "shared-", Namespace: namespace}, Spec: AgentTemplateSpec{ ModelConfig: &ref, - Tools: []ToolBinding{{MCP: &MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: "tools", Namespace: new(ref.Namespace)}}}}, + Tools: []ToolBinding{{MCP: &MCPToolBinding{Server: MCPServerReference{Kind: new("RemoteMCPServer"), Name: "tools", Namespace: ref.Namespace}}}}, }} require.NoError(t, cl.Create(ctx, template)) stored := &AgentTemplate{} require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKeyFromObject(template), stored)) require.Equal(t, ref, *stored.Spec.ModelConfig) - require.NotNil(t, stored.Spec.Tools[0].MCP.Server.Namespace) - require.Equal(t, ref.Namespace, *stored.Spec.Tools[0].MCP.Server.Namespace) + require.Equal(t, ref.Namespace, stored.Spec.Tools[0].MCP.Server.Namespace) } - for _, ref := range []ObjectReference{{Name: "shared/model"}, {Name: "model", Namespace: "bad/namespace"}, {Name: "model", Namespace: "Uppercase"}, {Name: "bad..name"}} { + for _, ref := range []ModelConfigReference{{Name: "shared/model"}, {Name: "model", Namespace: "bad/namespace"}, {Name: "model", Namespace: "Uppercase"}, {Name: "bad..name"}} { template := &AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "invalid-ref-", Namespace: namespace}, Spec: AgentTemplateSpec{ModelConfig: &ref}} require.True(t, apierrors.IsInvalid(cl.Create(ctx, template)), "invalid reference: %+v", ref) } - for _, ref := range []corev1.TypedObjectReference{ - {Kind: "RemoteMCPServer", Name: ""}, - {Kind: "RemoteMCPServer", Name: strings.Repeat("a", 254)}, - {Kind: "RemoteMCPServer", Name: "tools", Namespace: new(strings.Repeat("a", 64))}, - {Kind: "RemoteMCPServer", Name: "tools", APIGroup: new("api.kagent.dev")}, + for _, ref := range []MCPServerReference{ + {Kind: new("RemoteMCPServer"), Name: ""}, + {Kind: new("RemoteMCPServer"), Name: strings.Repeat("a", 254)}, + {Kind: new("RemoteMCPServer"), Name: "tools", Namespace: strings.Repeat("a", 64)}, + {APIGroup: new("api.kagent.dev"), Kind: new("RemoteMCPServer"), Name: "tools"}, } { template := &AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "invalid-mcp-ref-", Namespace: namespace}, Spec: AgentTemplateSpec{ Tools: []ToolBinding{{MCP: &MCPToolBinding{Server: ref}}}, @@ -282,13 +334,13 @@ func TestConfigurationCRDValidation(t *testing.T) { object: validHarness(namespace, "harness-empty-memory-model", HarnessSpec{ Kagent: &KagentHarness{Memory: &KagentHarnessMemory{}}, }), - wantReject: "modelConfigRef name must not be empty", + wantReject: "spec.kagent.memory.modelConfigRef.name", }, { name: "valid kagent memory Harness", object: validHarness(namespace, "valid-memory-harness", HarnessSpec{ Kagent: &KagentHarness{Memory: &KagentHarnessMemory{ - ModelConfigRef: ObjectReference{Name: "embedding-model"}, TTLDays: 7, + ModelConfigRef: ModelConfigReference{Name: "embedding-model"}, TTLDays: 7, }}, }), }, @@ -345,7 +397,7 @@ func TestConfigurationCRDValidation(t *testing.T) { name: "AgentTemplate tool rejects two sources", object: validAgentTemplate(namespace, "template-two-tools", []ToolBinding{{ MCP: &MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: "tools"}, + Server: MCPServerReference{Kind: new("RemoteMCPServer"), Name: "tools"}, Tools: []string{"search"}, }, SubAgent: &SubAgentToolBinding{ @@ -372,9 +424,9 @@ func TestConfigurationCRDValidation(t *testing.T) { name: "AgentTemplate rejects empty ModelConfig reference", object: &AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "empty-model-reference", Namespace: namespace}, - Spec: AgentTemplateSpec{ModelConfig: &ObjectReference{}}, + Spec: AgentTemplateSpec{ModelConfig: &ModelConfigReference{}}, }, - wantReject: "name must not be empty", + wantReject: "spec.modelConfig.name", }, { name: "AgentTemplate rejects outputSchemaStrict true without a schema", @@ -394,11 +446,10 @@ func TestConfigurationCRDValidation(t *testing.T) { object: unstructuredStrictTemplate(namespace, "strict-true-schema-from", true, nil, &ConfigMapKeyReference{Name: "schemas", Key: "output.json"}), }, { - name: "AgentTemplate rejects unsupported MCP server kind", - object: validAgentTemplate(namespace, "unsupported-mcp-kind", []ToolBinding{{ - MCP: &MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "Service", Name: "tools"}}, + name: "AgentTemplate admits other MCP server kinds for the controller to reject", + object: validAgentTemplate(namespace, "other-mcp-kind", []ToolBinding{{ + MCP: &MCPToolBinding{Server: MCPServerReference{Group: new("example.com"), Kind: new("Service"), Name: "tools"}}, }}), - wantReject: "kind must be RemoteMCPServer", }, { name: "Harness compaction requires a strategy", @@ -429,16 +480,16 @@ func TestConfigurationCRDValidation(t *testing.T) { { name: "Harness rejects empty summarizer ModelConfig reference", object: compactionHarness(namespace, "compaction-empty-summarizer-model", KagentHarnessCompaction{ - CompactionInterval: new(5), Summarizer: &KagentHarnessSummarizer{ModelConfigRef: &ObjectReference{}}, + CompactionInterval: new(5), Summarizer: &KagentHarnessSummarizer{ModelConfigRef: &ModelConfigReference{}}, }), - wantReject: "name must not be empty", + wantReject: "spec.kagent.compaction.summarizer.modelConfigRef.name", }, { name: "valid Harness compaction", object: compactionHarness(namespace, "compaction-valid", KagentHarnessCompaction{ CompactionInterval: new(5), OverlapSize: new(2), TokenThreshold: new(50000), EventRetentionSize: new(10), Summarizer: &KagentHarnessSummarizer{ - ModelConfigRef: &ObjectReference{Name: "summarizer"}, PromptTemplate: "Summarize.\n\n{conversation_history}", + ModelConfigRef: &ModelConfigReference{Name: "summarizer"}, PromptTemplate: "Summarize.\n\n{conversation_history}", }, }), }, @@ -663,7 +714,7 @@ func validAgentTemplate(namespace, name string, tools []ToolBinding) *AgentTempl return &AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: AgentTemplateSpec{ - ModelConfig: &ObjectReference{Name: "default"}, + ModelConfig: &ModelConfigReference{Name: "default"}, Tools: tools, }, } @@ -686,6 +737,15 @@ func unstructuredStrictTemplate(namespace, name string, strict bool, schema *api return object } +// jsonObject keeps fields the Go types no longer have, so they still reach the API server. +func jsonObject(t *testing.T, namespace, raw string) *unstructured.Unstructured { + t.Helper() + object := &unstructured.Unstructured{} + require.NoError(t, object.UnmarshalJSON([]byte(raw))) + object.SetNamespace(namespace) + return object +} + func compactionHarness(namespace, name string, compaction KagentHarnessCompaction) *Harness { return validHarness(namespace, name, HarnessSpec{Kagent: &KagentHarness{Compaction: &compaction}}) } diff --git a/go/api/v1alpha3/groupversion_info.go b/go/api/v1alpha3/groupversion_info.go index 56ac755721..e767bf34df 100644 --- a/go/api/v1alpha3/groupversion_info.go +++ b/go/api/v1alpha3/groupversion_info.go @@ -39,4 +39,8 @@ var ( // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme + + // The kinds that model and MCP server references resolve. + ModelConfigGroupKind = GroupVersion.WithKind("ModelConfig").GroupKind() + RemoteMCPServerGroupKind = GroupVersion.WithKind("RemoteMCPServer").GroupKind() ) diff --git a/go/api/v1alpha3/harness_types.go b/go/api/v1alpha3/harness_types.go index eb679a71e6..01d3a6b4a3 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -76,9 +76,8 @@ type KagentHarnessCompaction struct { type KagentHarnessSummarizer struct { // ModelConfigRef references the ModelConfig (defaulting to the Harness namespace) that // writes the summaries. Omitted uses the agent's own model. - // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="name must not be empty" // +optional - ModelConfigRef *ObjectReference `json:"modelConfigRef,omitempty"` + ModelConfigRef *ModelConfigReference `json:"modelConfigRef,omitempty"` // PromptTemplate replaces the runtime's default summarization prompt. It // must contain {conversation_history}, which the runtime replaces with the // rendered events. @@ -88,11 +87,10 @@ type KagentHarnessSummarizer struct { } // KagentHarnessMemory configures kagent's long-term memory service. -// +kubebuilder:validation:XValidation:rule="self.modelConfigRef.name.size() > 0",message="modelConfigRef name must not be empty" type KagentHarnessMemory struct { // ModelConfigRef references the embedding ModelConfig (defaulting to the Harness namespace). // +required - ModelConfigRef ObjectReference `json:"modelConfigRef"` + ModelConfigRef ModelConfigReference `json:"modelConfigRef"` // TTLDays controls how many days a stored memory entry remains valid. // +kubebuilder:validation:Minimum=1 diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 5be448f83b..f3a140cc46 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -304,8 +304,8 @@ func (in *AgentTemplateSpec) DeepCopyInto(out *AgentTemplateSpec) { *out = *in if in.ModelConfig != nil { in, out := &in.ModelConfig, &out.ModelConfig - *out = new(ObjectReference) - **out = **in + *out = new(ModelConfigReference) + (*in).DeepCopyInto(*out) } if in.SystemPromptFrom != nil { in, out := &in.SystemPromptFrom, &out.SystemPromptFrom @@ -969,7 +969,7 @@ func (in *KagentHarness) DeepCopyInto(out *KagentHarness) { if in.Memory != nil { in, out := &in.Memory, &out.Memory *out = new(KagentHarnessMemory) - **out = **in + (*in).DeepCopyInto(*out) } if in.Compaction != nil { in, out := &in.Compaction, &out.Compaction @@ -1031,7 +1031,7 @@ func (in *KagentHarnessCompaction) DeepCopy() *KagentHarnessCompaction { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KagentHarnessMemory) DeepCopyInto(out *KagentHarnessMemory) { *out = *in - out.ModelConfigRef = in.ModelConfigRef + in.ModelConfigRef.DeepCopyInto(&out.ModelConfigRef) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KagentHarnessMemory. @@ -1049,8 +1049,8 @@ func (in *KagentHarnessSummarizer) DeepCopyInto(out *KagentHarnessSummarizer) { *out = *in if in.ModelConfigRef != nil { in, out := &in.ModelConfigRef, &out.ModelConfigRef - *out = new(ObjectReference) - **out = **in + *out = new(ModelConfigReference) + (*in).DeepCopyInto(*out) } } @@ -1064,6 +1064,36 @@ func (in *KagentHarnessSummarizer) DeepCopy() *KagentHarnessSummarizer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerReference) DeepCopyInto(out *MCPServerReference) { + *out = *in + if in.APIGroup != nil { + in, out := &in.APIGroup, &out.APIGroup + *out = new(string) + **out = **in + } + if in.Group != nil { + in, out := &in.Group, &out.Group + *out = new(string) + **out = **in + } + if in.Kind != nil { + in, out := &in.Kind, &out.Kind + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerReference. +func (in *MCPServerReference) DeepCopy() *MCPServerReference { + if in == nil { + return nil + } + out := new(MCPServerReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPTool) DeepCopyInto(out *MCPTool) { *out = *in @@ -1199,6 +1229,31 @@ func (in *ModelConfigList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelConfigReference) DeepCopyInto(out *ModelConfigReference) { + *out = *in + if in.Group != nil { + in, out := &in.Group, &out.Group + *out = new(string) + **out = **in + } + if in.Kind != nil { + in, out := &in.Kind, &out.Kind + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelConfigReference. +func (in *ModelConfigReference) DeepCopy() *ModelConfigReference { + if in == nil { + return nil + } + out := new(ModelConfigReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ModelConfigSpec) DeepCopyInto(out *ModelConfigSpec) { *out = *in @@ -1475,21 +1530,6 @@ func (in *ModelProviderConfigStatus) DeepCopy() *ModelProviderConfigStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ObjectReference) DeepCopyInto(out *ObjectReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectReference. -func (in *ObjectReference) DeepCopy() *ObjectReference { - if in == nil { - return nil - } - out := new(ObjectReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OllamaConfig) DeepCopyInto(out *OllamaConfig) { *out = *in diff --git a/go/core/cli/internal/commands/agent_template_test.go b/go/core/cli/internal/commands/agent_template_test.go index 0b7ccffd6f..de6a4f4bef 100644 --- a/go/core/cli/internal/commands/agent_template_test.go +++ b/go/core/cli/internal/commands/agent_template_test.go @@ -210,7 +210,7 @@ func testAgentTemplateMessage(t *testing.T) *apiv1.AgentTemplate { template := &apiv1alpha3.AgentTemplate{ TypeMeta: metav1.TypeMeta{APIVersion: apiv1alpha3.GroupVersion.String(), Kind: agentTemplateKind}, ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "researcher"}, - Spec: apiv1alpha3.AgentTemplateSpec{ModelConfig: &apiv1alpha3.ObjectReference{Name: "default"}}, + Spec: apiv1alpha3.AgentTemplateSpec{ModelConfig: &apiv1alpha3.ModelConfigReference{Name: "default"}}, } resource, err := structuredobject.FromGo(template, apiv1alpha3.GroupVersion.String(), agentTemplateKind, 0) require.NoError(t, err) diff --git a/go/core/internal/controller/collections_test.go b/go/core/internal/controller/collections_test.go index 41aa19b82c..fa53db5cb5 100644 --- a/go/core/internal/controller/collections_test.go +++ b/go/core/internal/controller/collections_test.go @@ -28,7 +28,7 @@ func TestReconciliationCollectionsCompileAndObserveRevision(t *testing.T) { template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "python"}}, Spec: kagentv1alpha3.AgentTemplateSpec{ - ModelConfig: &kagentv1alpha3.ObjectReference{Name: "model"}, + ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: "model"}, SystemPrompt: "help", }, } @@ -139,7 +139,7 @@ func TestReconciliationReportsRuntimeValidationAsUnsupported(t *testing.T) { runtimeHarness.Spec.Substrate.SnapshotPolicy.Location = "snapshots" if harnessType == translator.HarnessTypeKagent { runtimeHarness.Spec.Kagent = &kagentv1alpha3.KagentHarness{} - template.Spec.ModelConfig = &kagentv1alpha3.ObjectReference{Name: "model"} + template.Spec.ModelConfig = &kagentv1alpha3.ModelConfigReference{Name: "model"} } else { runtimeHarness.Spec.BYO = &kagentv1alpha3.BYOHarness{} runtimeHarness.Spec.Workload.Command = []string{"/agent"} @@ -173,6 +173,39 @@ func TestReconciliationReportsRuntimeValidationAsUnsupported(t *testing.T) { } } +func TestReconciliationReportsUnsupportedReferenceKind(t *testing.T) { + t.Run("Given a ModelConfig named model, when the template references kind ModelProviderConfig, assert that ResolvedRefs fails with InvalidKind", func(t *testing.T) { + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + opts := krt.NewOptionsBuilder(stop, "reference-kind", nil) + template := &kagentv1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant"}, Spec: kagentv1alpha3.AgentTemplateSpec{ + SystemPrompt: "help", ModelConfig: &kagentv1alpha3.ModelConfigReference{Kind: new("ModelProviderConfig"), Name: "model"}, + }} + runtimeHarness := harness("team-a", "kagent", nil) + runtimeHarness.Spec.Kagent = &kagentv1alpha3.KagentHarness{} + mock := krttest.NewMock(t, []any{template, runtimeHarness, &kagentv1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model"}, Spec: kagentv1alpha3.ModelConfigSpec{Provider: kagentv1alpha3.ModelProviderOpenAI, Model: "gpt-5"}, + }}) + configMaps, secrets := krttest.GetMockCollection[*corev1.ConfigMap](mock), krttest.GetMockCollection[*corev1.Secret](mock) + _, resolvedModels := newModelConfigReconciliations(krttest.GetMockCollection[*kagentv1alpha3.ModelConfig](mock), configMaps, secrets, opts) + states := newAgentReconciliations( + krt.NewStaticCollection(nil, []*kagentv1alpha3.Agent{testAgent(template, runtimeHarness)}, opts.WithName("Agents")...), + translator.Collections{ + Harnesses: krttest.GetMockCollection[*kagentv1alpha3.Harness](mock), AgentTemplates: krttest.GetMockCollection[*kagentv1alpha3.AgentTemplate](mock), + ResolvedModelConfigs: resolvedModels, ConfigMaps: configMaps, Secrets: secrets, + RemoteMCPServers: krttest.GetMockCollection[*kagentv1alpha3.RemoteMCPServer](mock), WorkerPools: krttest.GetMockCollection[*atev1.WorkerPool](mock), + }, krt.NewStaticCollection[AgentRuntimeObservation](nil, nil, opts.WithName("Observations")...), opts, testRuntimeDefaults(), nil, + ) + waitFor(t, func() bool { return states.GetKey("team-a/assistant") != nil }) + state := states.GetKey("team-a/assistant") + require.Nil(t, state.Target) + require.Equal(t, &ReconciliationFailure{ + Condition: kagentv1alpha3.AgentConditionResolvedRefs, Reason: "InvalidKind", + Message: `resolve model "team-a/model": unsupported kind ModelProviderConfig.api.kagent.dev`, + }, state.CompilationFailure) + }) +} + func TestReconciliationWorkerPoolSandboxClass(t *testing.T) { for _, harnessType := range []translator.HarnessType{ translator.HarnessTypeKagent, translator.HarnessTypeCodex, translator.HarnessTypeClaude, translator.HarnessTypeBYO, @@ -183,7 +216,7 @@ func TestReconciliationWorkerPoolSandboxClass(t *testing.T) { opts := krt.NewOptionsBuilder(stop, "test-sandbox", nil) template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid"}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ObjectReference{Name: "model"}, SystemPrompt: "help"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: "model"}, SystemPrompt: "help"}, } runtimeHarness := harness("team-a", string(harnessType), nil) runtimeHarness.UID = "harness-uid" @@ -341,7 +374,7 @@ func TestClaudeReconciliationCompilesActorTemplate(t *testing.T) { template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "claude"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ObjectReference{Name: "model"}, SystemPrompt: "help"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: "model"}, SystemPrompt: "help"}, } claudeHarness := harness("team-a", "claude", map[string]string{"runtime": "claude"}) claudeHarness.UID = "harness-uid" @@ -396,7 +429,7 @@ func TestCodexReconciliationCompilesActorTemplate(t *testing.T) { responses := kagentv1alpha3.OpenAIAPIFormatResponses template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "codex"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ObjectReference{Name: "model"}, SystemPrompt: "help"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: "model"}, SystemPrompt: "help"}, } codexHarness := harness("team-a", "codex", map[string]string{"runtime": "codex"}) codexHarness.UID = "harness-uid" @@ -451,12 +484,12 @@ func TestReconciliationTracksSharedAgentTemplate(t *testing.T) { opts := krt.NewOptionsBuilder(stop, "test", nil) child := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "child", Labels: map[string]string{"runtime": "python"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ObjectReference{Name: "model"}, SystemPrompt: "before"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: "model"}, SystemPrompt: "before"}, } root := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "root", Labels: map[string]string{"runtime": "python"}}, Spec: kagentv1alpha3.AgentTemplateSpec{ - ModelConfig: &kagentv1alpha3.ObjectReference{Name: "model"}, + ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: "model"}, Tools: []kagentv1alpha3.ToolBinding{{SubAgent: &kagentv1alpha3.SubAgentToolBinding{ Name: "child", Description: "delegate", TemplateRef: &corev1.LocalObjectReference{Name: child.Name}, }}}, diff --git a/go/core/internal/controller/grant_admission_test.go b/go/core/internal/controller/grant_admission_test.go index c4ed5a8d79..eefa81f936 100644 --- a/go/core/internal/controller/grant_admission_test.go +++ b/go/core/internal/controller/grant_admission_test.go @@ -123,7 +123,7 @@ func TestAgentGrantAdmissionReservesTokenPassthroughOrigins(t *testing.T) { const origin = "https://mcp.example.com:443" server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "user"}, Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://mcp.example.com/mcp", TokenPassthrough: new(true)}} template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"lookup"}}}}, + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, Tools: []string{"lookup"}}}}, }} secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "auth"}, Data: map[string][]byte{"token": []byte("value")}} collections, _ := newPreparationTestCollectionsFor(t, "gvisor", template, server, secret) diff --git a/go/core/internal/controller/reconciler.go b/go/core/internal/controller/reconciler.go index 82b5e19a98..d75c60e10e 100644 --- a/go/core/internal/controller/reconciler.go +++ b/go/core/internal/controller/reconciler.go @@ -169,6 +169,8 @@ func compilationFailure(err error) *ReconciliationFailure { var validation *translator.ValidationError var missingPool *translator.WorkerPoolNotFoundError switch { + case errors.Is(err, translator.ErrInvalidKind): + reason = "InvalidKind" case errors.As(err, &validation): // TODO(tim): Separate structural Agent validation from resolved // compatibility failures so invalid sources do not report Accepted=True diff --git a/go/core/internal/controller/reconciler_test.go b/go/core/internal/controller/reconciler_test.go index 5b135b01af..e38ac1837b 100644 --- a/go/core/internal/controller/reconciler_test.go +++ b/go/core/internal/controller/reconciler_test.go @@ -547,10 +547,10 @@ func TestReferencePolicyFailurePreservesLastSuccessfulRevision(t *testing.T) { runner.Spec.Workload.Image = new("example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") agent := &kagentv1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "agent", UID: "agent-uid"}, Spec: kagentv1alpha3.AgentSpec{ HarnessRef: corev1.LocalObjectReference{Name: runner.Name}, - Template: kagentv1alpha3.AgentTemplateSource{Inline: &kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ObjectReference{Name: model.Name, Namespace: model.Namespace}}}, + Template: kagentv1alpha3.AgentTemplateSource{Inline: &kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.ModelConfigReference{Name: model.Name, Namespace: model.Namespace}}}, }} if kind == "RemoteMCPServer" { - agent.Spec.Template.Inline.Tools = []kagentv1alpha3.ToolBinding{{MCP: &kagentv1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: kind, Name: server.Name, Namespace: new(server.Namespace)}}}} + agent.Spec.Template.Inline.Tools = []kagentv1alpha3.ToolBinding{{MCP: &kagentv1alpha3.MCPToolBinding{Server: kagentv1alpha3.MCPServerReference{Kind: new(kind), Name: server.Name, Namespace: server.Namespace}}}} model.Spec.AllowedNamespaces = &kagentv1alpha3.AllowedNamespaces{From: kagentv1alpha3.NamespacesFromAll} } agents := krt.NewStaticCollection(nil, []*kagentv1alpha3.Agent{agent}, opts.WithName("Agents")...) diff --git a/go/core/internal/controller/reference_namespaces_integration_test.go b/go/core/internal/controller/reference_namespaces_integration_test.go index ea28830114..4027ce41b4 100644 --- a/go/core/internal/controller/reference_namespaces_integration_test.go +++ b/go/core/internal/controller/reference_namespaces_integration_test.go @@ -41,7 +41,7 @@ func TestCrossNamespaceReferenceWatches(t *testing.T) { model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "shared"}, Spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, Model: "example", AllowedNamespaces: &v1alpha3.AllowedNamespaces{From: v1alpha3.NamespacesFromAll}}} model, err = client.Kagent().ApiV1alpha3().ModelConfigs("shared").Create(ctx, model, metav1.CreateOptions{}) require.NoError(t, err) - agent := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "team-a"}, Spec: v1alpha3.AgentSpec{HarnessRef: corev1.LocalObjectReference{Name: "runner"}, Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "model", Namespace: "shared"}}}}} + agent := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "team-a"}, Spec: v1alpha3.AgentSpec{HarnessRef: corev1.LocalObjectReference{Name: "runner"}, Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "model", Namespace: "shared"}}}}} _, err = client.Kagent().ApiV1alpha3().Agents("team-a").Create(ctx, agent, metav1.CreateOptions{}) require.NoError(t, err) opts := krt.NewOptionsBuilder(ctx.Done(), "references", nil) diff --git a/go/core/internal/grpcserver/agenttemplate.go b/go/core/internal/grpcserver/agenttemplate.go index 3bc9bed8fe..5f3792c423 100644 --- a/go/core/internal/grpcserver/agenttemplate.go +++ b/go/core/internal/grpcserver/agenttemplate.go @@ -121,8 +121,10 @@ func (s *agentTemplateServer) agentTemplate(template *v1alpha3.AgentTemplate) (* } var modelConfigRef *apiv1.ResourceReference if template.Spec.ModelConfig != nil { - key := template.Spec.ModelConfig.NamespacedName(template.Namespace) - modelConfigRef = &apiv1.ResourceReference{Namespace: key.Namespace, Name: key.Name} + // The response field carries no kind, so it only names ModelConfigs. + if kind, key := template.Spec.ModelConfig.Target(template.Namespace); kind == v1alpha3.ModelConfigGroupKind { + modelConfigRef = &apiv1.ResourceReference{Namespace: key.Namespace, Name: key.Name} + } } return &apiv1.AgentTemplate{ Ref: &apiv1.ResourceReference{Namespace: template.Namespace, Name: template.Name}, diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 2170e6e505..bf75e4dfd2 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -83,7 +83,7 @@ func testAgentTemplate(namespace, name, modelConfig string) *v1alpha3.AgentTempl return &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: modelConfig}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: modelConfig}, Description: "a template", }, } @@ -222,6 +222,22 @@ func TestAgentTemplateServiceGeneratedClient(t *testing.T) { assertCode(t, err, codes.NotFound) } +func TestAgentTemplateModelConfigRef(t *testing.T) { + t.Run("Given a template whose model has another kind, when a caller gets it, assert that modelConfigRef is unset", func(t *testing.T) { + template := testAgentTemplate("team", "custom-model", "gpt") + template.Spec.ModelConfig.Group, template.Spec.ModelConfig.Kind = new("example.com"), new("Model") + client := apiv1.NewAgentTemplateServiceClient(newTemplateAndHarnessConnection(t, template)) + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "template-user")) + got, err := client.GetAgentTemplate(ctx, &apiv1.GetAgentTemplateRequest{Ref: &apiv1.ResourceReference{Namespace: "team", Name: "custom-model"}}) + if err != nil { + t.Fatalf("GetAgentTemplate() error = %v", err) + } + if ref := got.GetAgentTemplate().GetModelConfigRef(); ref != nil { + t.Fatalf("GetAgentTemplate() modelConfigRef = %+v, want nil", ref) + } + }) +} + func TestHarnessServiceGeneratedClient(t *testing.T) { existing := testHarness("team", "z-existing", "pool-z") client := apiv1.NewHarnessServiceClient(newTemplateAndHarnessConnection(t, existing)) diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index 54ce256187..a5c32532bf 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -123,12 +123,12 @@ func (c *Builder) compileAgent(ctx context.Context, input *translator.AgentInput for _, tool := range input.MCPTools { headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, tool.Server.Namespace, tool.Server.Spec.HeadersFrom) if err != nil { - return nil, fmt.Errorf("resolve %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) + return nil, fmt.Errorf("resolve %s %q: %w", tool.Binding.Server.GroupKind().Kind, tool.Binding.Server.Name, err) } server := tool.Server.DeepCopy() server.Spec.HeadersFrom = nil if err := c.addRemoteMCPServer(cfg, server, tool.Binding.Tools, tool.Binding.RequireApproval, headers); err != nil { - return nil, fmt.Errorf("compile %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) + return nil, fmt.Errorf("compile %s %q: %w", tool.Binding.Server.GroupKind().Kind, tool.Binding.Server.Name, err) } modelRuntime.Environment = append(modelRuntime.Environment, credentialEnv...) } diff --git a/go/core/internal/translator/adkconfig/builder_test.go b/go/core/internal/translator/adkconfig/builder_test.go index fe193e61db..d23fb7a958 100644 --- a/go/core/internal/translator/adkconfig/builder_test.go +++ b/go/core/internal/translator/adkconfig/builder_test.go @@ -73,7 +73,7 @@ func TestBuildCompaction(t *testing.T) { agentModel := resolvedModel(t, collections, "agent") template := &translator.TemplateConfiguration{ Name: "assistant", Namespace: "test", Source: &metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "agent"}}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "agent"}}, } harness := func(compaction *v1alpha3.KagentHarnessCompaction) *translator.HarnessConfiguration { return &translator.HarnessConfiguration{ @@ -98,7 +98,7 @@ func TestBuildCompaction(t *testing.T) { result, err := apply(harness(&v1alpha3.KagentHarnessCompaction{ CompactionInterval: new(5), OverlapSize: new(2), TokenThreshold: new(50000), EventRetentionSize: new(10), Summarizer: &v1alpha3.KagentHarnessSummarizer{ - ModelConfigRef: &v1alpha3.ObjectReference{Name: "agent"}, PromptTemplate: "Summarize.\n\n{conversation_history}", + ModelConfigRef: &v1alpha3.ModelConfigReference{Name: "agent"}, PromptTemplate: "Summarize.\n\n{conversation_history}", }, })) require.NoError(t, err) @@ -133,7 +133,7 @@ func TestBuildCompaction(t *testing.T) { t.Run("dedicated summarizer model", func(t *testing.T) { result, err := apply(harness(&v1alpha3.KagentHarnessCompaction{ CompactionInterval: new(2), - Summarizer: &v1alpha3.KagentHarnessSummarizer{ModelConfigRef: &v1alpha3.ObjectReference{Name: "summarizer"}}, + Summarizer: &v1alpha3.KagentHarnessSummarizer{ModelConfigRef: &v1alpha3.ModelConfigReference{Name: "summarizer"}}, })) require.NoError(t, err) summarizer := result.Config.ContextConfig.Compaction.SummarizerModel.GetOpenai() @@ -155,7 +155,7 @@ func TestBuildCompaction(t *testing.T) { t.Run("missing summarizer model", func(t *testing.T) { _, err := apply(harness(&v1alpha3.KagentHarnessCompaction{ CompactionInterval: new(2), - Summarizer: &v1alpha3.KagentHarnessSummarizer{ModelConfigRef: &v1alpha3.ObjectReference{Name: "missing"}}, + Summarizer: &v1alpha3.KagentHarnessSummarizer{ModelConfigRef: &v1alpha3.ModelConfigReference{Name: "missing"}}, })) require.ErrorContains(t, err, `summarizer ModelConfig "missing"`) }) @@ -381,7 +381,7 @@ func TestBuildMemory(t *testing.T) { harness := &translator.HarnessConfiguration{ Namespace: "test", Spec: v1alpha3.HarnessSpec{Kagent: &v1alpha3.KagentHarness{Memory: &v1alpha3.KagentHarnessMemory{ - ModelConfigRef: v1alpha3.ObjectReference{Name: tc.modelName}, TTLDays: 7, + ModelConfigRef: v1alpha3.ModelConfigReference{Name: tc.modelName}, TTLDays: 7, }}}, } result, err := NewBuilder(krt.TestingDummyContext{}, collections).Build(t.Context(), &translator.HarnessInput{Harness: harness, Root: root}) diff --git a/go/core/internal/translator/adkconfig/runtime.go b/go/core/internal/translator/adkconfig/runtime.go index 776daf6bf3..4c1468d056 100644 --- a/go/core/internal/translator/adkconfig/runtime.go +++ b/go/core/internal/translator/adkconfig/runtime.go @@ -5,7 +5,6 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" adkoutputschema "github.com/kagent-dev/kagent/go/adk/pkg/outputschema" @@ -23,7 +22,7 @@ type modelResult struct { } // buildModel translates a standalone ModelConfig without building an agent. -func (c *Builder) buildModel(namespace string, ref v1alpha3.ObjectReference) (*modelResult, error) { +func (c *Builder) buildModel(namespace string, ref v1alpha3.ModelConfigReference) (*modelResult, error) { resolved, err := translator.ResolveModelReference(c.ctx, c.collections, namespace, ref) if err != nil { return nil, err @@ -70,7 +69,7 @@ func (c *Builder) applyCompaction(result *Result, harness *translator.HarnessCon } if summarizer := spec.Summarizer; summarizer != nil { compaction.PromptTemplate = summarizer.PromptTemplate - if ref := summarizer.ModelConfigRef; ref != nil && !isAgentModel(template, ref.NamespacedName(harness.Namespace)) { + if ref := summarizer.ModelConfigRef; ref != nil && !isAgentModel(template, harness.Namespace, *ref) { model, err := c.buildModel(harness.Namespace, *ref) if err != nil { return fmt.Errorf("resolve summarizer ModelConfig %q: %w", ref.Name, err) @@ -85,8 +84,13 @@ func (c *Builder) applyCompaction(result *Result, harness *translator.HarnessCon return nil } -func isAgentModel(template *translator.TemplateConfiguration, key types.NamespacedName) bool { - return template.Spec.ModelConfig != nil && template.Spec.ModelConfig.NamespacedName(template.Namespace) == key +func isAgentModel(template *translator.TemplateConfiguration, namespace string, ref v1alpha3.ModelConfigReference) bool { + if template.Spec.ModelConfig == nil { + return false + } + kind, key := ref.Target(namespace) + agentKind, agentKey := template.Spec.ModelConfig.Target(template.Namespace) + return kind == agentKind && key == agentKey } func applyOutputSchema(config *configv1.ADKAgentConfig, output *translator.ResolvedOutputSchema) error { diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index be76f70b0e..7e386bb4f5 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -282,7 +282,7 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { }}, } input.Root.Template.Spec.Tools = []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, Tools: []string{"get_time", "echo", "add_numbers"}, }}} input.Root.MCPTools = []translator.ResolvedMCPTool{{Binding: *input.Root.Template.Spec.Tools[0].MCP.DeepCopy(), Server: server}} @@ -336,7 +336,7 @@ func TestCompileWholeServerMCPSelectionWarnings(t *testing.T) { {Name: "one"}, {Name: "two"}, }}, } - binding := v1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}} + binding := v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}} input.Root.MCPTools = []translator.ResolvedMCPTool{{Binding: binding, Server: server}} revision, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(context.Background(), input) if err != nil { @@ -423,7 +423,7 @@ func TestCompileLocalSharedAgent(t *testing.T) { Template: &translator.TemplateConfiguration{ Name: "specialist-template", Namespace: "test", Source: &metav1.ObjectMeta{Name: "specialist-template", Namespace: "test", UID: "child-template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "child-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "child-model"}, Description: "template description", SystemPrompt: "specialize", }, }, @@ -495,7 +495,7 @@ func TestCompileRejectsUnsupportedLocalAgentConfiguration(t *testing.T) { Agent: &translator.AgentInput{ Template: &translator.TemplateConfiguration{ Name: "child", Namespace: "test", Source: &metav1.ObjectMeta{Name: "child", Namespace: "test"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "child-model"}}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "child-model"}}, }, ResolvedModelConfig: &translator.ResolvedModelConfig{Config: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test"}, Spec: childSpec}}, Instruction: "specialize", @@ -518,7 +518,7 @@ func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[ Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, }} template := &translator.TemplateConfiguration{Name: "assistant", Namespace: "test", Source: &metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", + ModelConfig: &v1alpha3.ModelConfigReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", }} model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "test", UID: "model-uid"}, Spec: modelSpec} secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test", UID: "secret-uid"}, Data: secretData} @@ -608,7 +608,7 @@ func TestCompiledTelemetryFitsTheActorEnvironmentBudget(t *testing.T) { Status: v1alpha3.RemoteMCPServerStatus{ObservedGeneration: 1, DiscoveredTools: []*v1alpha3.MCPTool{{Name: "echo"}}}, } input.Root.Template.Spec.Tools = []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"echo"}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, Tools: []string{"echo"}, }}} input.Root.MCPTools = []translator.ResolvedMCPTool{{Binding: *input.Root.Template.Spec.Tools[0].MCP.DeepCopy(), Server: server}} diff --git a/go/core/internal/translator/codex/compiler_test.go b/go/core/internal/translator/codex/compiler_test.go index 1871cc2345..78b532b6ca 100644 --- a/go/core/internal/translator/codex/compiler_test.go +++ b/go/core/internal/translator/codex/compiler_test.go @@ -177,7 +177,7 @@ func TestCompileMCPAndSharedAgent(t *testing.T) { childModel := model childModel.Model = "gpt-child" input.Root.Shared = []translator.AgentInputBinding{{Name: "reviewer", Description: "Reviews", Agent: &translator.AgentInput{ - Template: &translator.TemplateConfiguration{Name: "child", Namespace: "test", Source: &metav1.ObjectMeta{Name: "child", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "child-model"}}}, + Template: &translator.TemplateConfiguration{Name: "child", Namespace: "test", Source: &metav1.ObjectMeta{Name: "child", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "child-model"}}}, ResolvedModelConfig: &translator.ResolvedModelConfig{ Config: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test"}, Spec: childModel}, }, @@ -258,7 +258,7 @@ func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[ Codex: &v1alpha3.CodexHarness{}, Workload: v1alpha3.HarnessWorkload{Image: new("example.com/codex@sha256:" + strings.Repeat("a", 64))}, Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, }} - template := &translator.TemplateConfiguration{Name: "assistant", Namespace: "test", Source: &metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "model"}, Description: "assistant"}} + template := &translator.TemplateConfiguration{Name: "assistant", Namespace: "test", Source: &metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "model"}, Description: "assistant"}} model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "test", UID: "model"}, Spec: modelSpec} secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test", UID: "secret"}, Data: secretData} mock := krttest.NewMock(t, []any{secret}) diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index e8602b3bc9..dbbc66eb8e 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -9,7 +9,6 @@ import ( "istio.io/istio/pkg/kube/krt" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" @@ -275,18 +274,17 @@ func (c *Compiler) buildInputs(ctx context.Context, tree *resolvedTemplateTree) } continue } - if tool.MCP.Server.Kind != "RemoteMCPServer" { - return nil, NewValidationError("unsupported MCP server kind %q", tool.MCP.Server.Kind) + key, server, err := fetchMCPServer(c.ctx, c.collections, template.Namespace, tool.MCP.Server) + if err != nil { + return nil, err } - key := (v1alpha3.ObjectReference{Name: tool.MCP.Server.Name, Namespace: ptr.Deref(tool.MCP.Server.Namespace, "")}).NamespacedName(template.Namespace) - server := krt.FetchOne(c.ctx, c.collections.RemoteMCPServers, krt.FilterObjectName(key)) if server == nil { - return nil, fmt.Errorf("resolve %s %q: not found", tool.MCP.Server.Kind, tool.MCP.Server.Name) + return nil, fmt.Errorf("resolve %s %q: not found", tool.MCP.Server.GroupKind().Kind, tool.MCP.Server.Name) } - if err := authorizeReference(c.ctx, c.collections, template.Namespace, key, (*server).Spec.AllowedNamespaces); err != nil { + if err := authorizeReference(c.ctx, c.collections, template.Namespace, key, server.Spec.AllowedNamespaces); err != nil { return nil, fmt.Errorf("resolve RemoteMCPServer %q: %w", key, err) } - input.MCPTools = append(input.MCPTools, ResolvedMCPTool{Binding: *tool.MCP.DeepCopy(), Server: *server}) + input.MCPTools = append(input.MCPTools, ResolvedMCPTool{Binding: *tool.MCP.DeepCopy(), Server: server}) toolNames = append(toolNames, tool.MCP.Tools...) } instruction, err := c.resolveAgentTemplatePrompt(ctx, template) diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 6c0393341f..d0aae8bfee 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -67,7 +67,7 @@ func TestCompileAgentPreservesWorkloadOverrides(t *testing.T) { } template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "agent", Namespace: "test"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "help"}, } original := harness.DeepCopy() result, err := compiler(t, modelConfig(), harness).CompileAgent(t.Context(), inlineAgent(harness, template)) @@ -122,7 +122,7 @@ func TestCompileAgentPinsAgentPluginSources(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ Kagent: &v1alpha3.KagentHarness{Memory: &v1alpha3.KagentHarnessMemory{ - ModelConfigRef: v1alpha3.ObjectReference{Name: embeddingModel.Name}, TTLDays: 7, + ModelConfigRef: v1alpha3.ModelConfigReference{Name: embeddingModel.Name}, TTLDays: 7, }}, Workload: v1alpha3.HarnessWorkload{ @@ -137,7 +137,7 @@ func TestCompileAgentPinsAgentPluginSources(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, Skills: []v1alpha3.AgentTemplateSkill{ {Name: "review", Source: v1alpha3.ArtifactSource{ OCI: "ghcr.io/acme/review@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -268,7 +268,7 @@ func TestCompileAgentResolvesWorkerPoolSandboxClass(t *testing.T) { } template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "assistant"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "help"}, } model := modelConfig() model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = "model-auth", "api-key" @@ -366,7 +366,7 @@ func TestCompileAgentStructuredOutput(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ Kagent: &v1alpha3.KagentHarness{ - Memory: &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ObjectReference{Name: "default-model"}, TTLDays: 7}, + Memory: &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ModelConfigReference{Name: "default-model"}, TTLDays: 7}, Compaction: &v1alpha3.KagentHarnessCompaction{CompactionInterval: new(5)}, }, Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, @@ -380,7 +380,7 @@ func TestCompileAgentStructuredOutput(t *testing.T) { child := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "child", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, // A child contract is intentionally not resolved while this template is nested. OutputSchema: &apiextensionsv1.JSON{Raw: []byte(`{"type":"object","oneOf":[]}`)}, }, @@ -388,7 +388,7 @@ func TestCompileAgentStructuredOutput(t *testing.T) { root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, OutputSchemaFrom: &v1alpha3.ConfigMapKeyReference{Name: configMap.Name, Key: "answer.json"}, Tools: []v1alpha3.ToolBinding{{SubAgent: &v1alpha3.SubAgentToolBinding{ Name: "child", Description: "delegate", TemplateRef: &corev1.LocalObjectReference{Name: child.Name}, @@ -433,7 +433,7 @@ func TestCompileAgentOutputSchemaFromStringFormat(t *testing.T) { root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, OutputSchemaFrom: &v1alpha3.ConfigMapKeyReference{Name: "schemas", Key: "ticket.json"}, }, } @@ -508,7 +508,7 @@ func TestCompileAgentOutputSchemaStrict(t *testing.T) { root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, OutputSchema: &apiextensionsv1.JSON{Raw: []byte(tt.schema)}, OutputSchemaStrict: tt.strict, }, @@ -593,7 +593,7 @@ func TestCompilerAcceptsExternalHarnessCompiler(t *testing.T) { Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, }, } - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}}} collections := mockCollections(t, modelConfig(), defaultWorkerPool(), harness) revision, err := translator.NewCompiler(krt.TestingDummyContext{}, collections, map[translator.HarnessType]translator.HarnessCompiler{ @@ -619,7 +619,7 @@ func TestCompilerRejectsStructuredOutputForUnsupportedHarness(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, OutputSchema: &apiextensionsv1.JSON{Raw: []byte(`{"type":"object"}`)}, }, } @@ -640,7 +640,7 @@ func TestCompilerRejectsUnusableModelConfigBeforeHarnessCompiler(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "codex", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{Codex: &v1alpha3.CodexHarness{}}, } - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}}} collections := mockCollections(t, model, harness) _, err := translator.NewCompiler(krt.TestingDummyContext{}, collections, map[translator.HarnessType]translator.HarnessCompiler{ @@ -704,13 +704,13 @@ func TestCompileAgentInjectsCredentialsAtGateway(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "help", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, Tools: []string{"lookup"}, }}, {MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: secondServer.Name}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: secondServer.Name}, Tools: []string{"search"}, }}}, }, @@ -771,9 +771,9 @@ func TestCompileAgentSharedADKConfig(t *testing.T) { child := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "researcher", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, Description: "template description", SystemPrompt: "research carefully", + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, Description: "template description", SystemPrompt: "research carefully", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: "search"}, Tools: []string{"lookup"}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: "search"}, Tools: []string{"lookup"}, }}}, Skills: []v1alpha3.AgentTemplateSkill{{Name: "review", Source: v1alpha3.ArtifactSource{ OCI: "ghcr.io/acme/review@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -783,7 +783,7 @@ func TestCompileAgentSharedADKConfig(t *testing.T) { root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "coordinator", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, SystemPrompt: "coordinate", + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "coordinate", Skills: child.Spec.Skills, Plugins: []v1alpha3.PluginBundle{{Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ URL: "https://github.com/acme/plugin", Commit: "cccccccccccccccccccccccccccccccccccccccc", @@ -851,7 +851,7 @@ func TestCompileAgentRejectsInvalidSharedTrees(t *testing.T) { return v1alpha3.ToolBinding{SubAgent: &v1alpha3.SubAgentToolBinding{Name: name, Description: name, TemplateRef: &corev1.LocalObjectReference{Name: target}}} } template := func(name string, tools ...v1alpha3.ToolBinding) *v1alpha3.AgentTemplate { - return &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, Tools: tools}} + return &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, Tools: tools}} } t.Run("shared DAG", func(t *testing.T) { @@ -959,10 +959,10 @@ func TestCompileAgentRejectsInvalidSources(t *testing.T) { func TestCompileAgentInlineAndReferencedTemplates(t *testing.T) { template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "assistant", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, SystemPrompt: "review code", + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "review code", Tools: []v1alpha3.ToolBinding{{SubAgent: &v1alpha3.SubAgentToolBinding{Name: "reviewer", Description: "delegate review", TemplateRef: &corev1.LocalObjectReference{Name: "child"}}}}, }} - child := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "child"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}, SystemPrompt: "review security"}} + child := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "child"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "review security"}} harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "runtime", UID: "harness-uid"}, Spec: v1alpha3.HarnessSpec{ Kagent: &v1alpha3.KagentHarness{}, Workload: v1alpha3.HarnessWorkload{Image: new("example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, @@ -1024,6 +1024,75 @@ func TestCompileAgentInlineAndReferencedTemplates(t *testing.T) { }) } +func TestCompileAgentResolvesReferenceKinds(t *testing.T) { + fixture := func() (*v1alpha3.Harness, *v1alpha3.AgentTemplate) { + harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "kagent"}, Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{Memory: &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ModelConfigReference{Name: "default-model"}, TTLDays: 7}}, + Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, + }} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "assistant"}, Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}, SystemPrompt: "help", + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Name: "search"}}}}, + }} + return harness, template + } + compile := func(t *testing.T, harness *v1alpha3.Harness, template *v1alpha3.AgentTemplate) (*translator.CompileResult, error) { + t.Helper() + return compiler(t, modelConfig(), remoteMCPServer("search", "https://search.example.com/mcp"), harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + } + digest := func(t *testing.T, harness *v1alpha3.Harness, template *v1alpha3.AgentTemplate) translator.RevisionID { + t.Helper() + result, err := compile(t, harness, template) + require.NoError(t, err) + id, err := result.Digest() + require.NoError(t, err) + return id + } + + t.Run("Given a ModelConfig and a RemoteMCPServer, when references omit kinds, name default kinds, or come from a v1.0.x client, assert that each compiles to the same revision", func(t *testing.T) { + harness, implicit := fixture() + want := digest(t, harness, implicit) + + explicitHarness, explicit := fixture() + explicit.Spec.ModelConfig.Group, explicit.Spec.ModelConfig.Kind = new("api.kagent.dev"), new("ModelConfig") + explicit.Spec.Tools[0].MCP.Server.Group, explicit.Spec.Tools[0].MCP.Server.Kind = new("api.kagent.dev"), new("RemoteMCPServer") + explicitHarness.Spec.Kagent.Memory.ModelConfigRef.Group, explicitHarness.Spec.Kagent.Memory.ModelConfigRef.Kind = new("api.kagent.dev"), new("ModelConfig") + require.Equal(t, want, digest(t, explicitHarness, explicit)) + + // v1.0.x Go clients wrote corev1.TypedObjectReference, which encodes apiGroup as null. + var stored v1alpha3.AgentTemplate + require.NoError(t, json.Unmarshal([]byte(`{"modelConfig":{"name":"default-model"},"systemPrompt":"help","tools":[{"mcp":{"server":{"apiGroup":null,"kind":"RemoteMCPServer","name":"search","namespace":""}}}]}`), &stored.Spec)) + require.Equal(t, want, digest(t, harness, &stored)) + }) + for _, tc := range []struct { + field, want string + mutate func(*v1alpha3.Harness, *v1alpha3.AgentTemplate) + }{ + {"template modelConfig", `resolve model "test/default-model": unsupported kind ModelProviderConfig.api.kagent.dev`, func(_ *v1alpha3.Harness, template *v1alpha3.AgentTemplate) { + template.Spec.ModelConfig.Kind = new("ModelProviderConfig") + }}, + {"MCP server", `resolve MCP server "test/search": unsupported kind RemoteMCPServer.example.com`, func(_ *v1alpha3.Harness, template *v1alpha3.AgentTemplate) { + template.Spec.Tools[0].MCP.Server.Group, template.Spec.Tools[0].MCP.Server.Kind = new("example.com"), new("RemoteMCPServer") + }}, + {"memory modelConfigRef", `resolve model "test/default-model": unsupported kind Model.example.com`, func(harness *v1alpha3.Harness, _ *v1alpha3.AgentTemplate) { + harness.Spec.Kagent.Memory.ModelConfigRef.Group, harness.Spec.Kagent.Memory.ModelConfigRef.Kind = new("example.com"), new("Model") + }}, + {"summarizer modelConfigRef", `resolve model "test/default-model": unsupported kind Model.example.com`, func(harness *v1alpha3.Harness, _ *v1alpha3.AgentTemplate) { + harness.Spec.Kagent.Compaction = &v1alpha3.KagentHarnessCompaction{CompactionInterval: new(5), Summarizer: &v1alpha3.KagentHarnessSummarizer{ + ModelConfigRef: &v1alpha3.ModelConfigReference{Group: new("example.com"), Kind: new("Model"), Name: "default-model"}, + }} + }}, + } { + t.Run("Given a resolvable Agent, when the "+tc.field+" names an unsupported kind, assert that compilation fails with ErrInvalidKind", func(t *testing.T) { + harness, template := fixture() + tc.mutate(harness, template) + _, err := compile(t, harness, template) + require.ErrorIs(t, err, translator.ErrInvalidKind) + require.ErrorContains(t, err, tc.want) + }) + } +} + // Runtime identity also selects the ADK memory namespace. Reusing configuration // must not merge Agents' memories or change an Agent's namespace when inlined. func TestCompileAgentRuntimeIdentity(t *testing.T) { @@ -1039,7 +1108,7 @@ func TestCompileAgentRuntimeIdentity(t *testing.T) { secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "model-auth"}, Data: map[string][]byte{"api-key": []byte("test-key")}} template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "shared-context"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "help"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "help"}, } harness := &v1alpha3.Harness{ ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "shared-runtime"}, @@ -1050,7 +1119,7 @@ func TestCompileAgentRuntimeIdentity(t *testing.T) { } switch kind { case translator.HarnessTypeKagent: - harness.Spec.Kagent = &v1alpha3.KagentHarness{Memory: &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ObjectReference{Name: model.Name}}} + harness.Spec.Kagent = &v1alpha3.KagentHarness{Memory: &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ModelConfigReference{Name: model.Name}}} case translator.HarnessTypeCodex: harness.Spec.Codex = &v1alpha3.CodexHarness{} model.Spec.OpenAI = &v1alpha3.OpenAIConfig{APIFormat: new(v1alpha3.OpenAIAPIFormatResponses)} diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 85aefee373..005fd58259 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -144,7 +144,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, explicit = append(explicit, ref.Name) } passthrough = append(passthrough, passthroughClient{ - client: fmt.Sprintf("%s %q", tool.Binding.Server.Kind, tool.Server.Name), endpoint: tool.Server.Spec.URL, + client: fmt.Sprintf("%s %q", tool.Binding.Server.GroupKind().Kind, tool.Server.Name), endpoint: tool.Server.Spec.URL, header: "authorization", prefix: "Bearer ", explicit: explicit, }) } else { diff --git a/go/core/internal/translator/cross_namespace_test.go b/go/core/internal/translator/cross_namespace_test.go index e09728de7b..9b23a16d7d 100644 --- a/go/core/internal/translator/cross_namespace_test.go +++ b/go/core/internal/translator/cross_namespace_test.go @@ -44,8 +44,8 @@ func TestCrossNamespaceModelsAndMCP(t *testing.T) { }, }} template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name, Namespace: "shared"}, SystemPrompt: "help", - Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: "tools", Namespace: new("shared")}}}}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name, Namespace: "shared"}, SystemPrompt: "help", + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: "tools", Namespace: "shared"}}}}, }} secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "shared"}, Data: map[string][]byte{"token": []byte("shared-credential")}} headers := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "headers", Namespace: "shared"}, Data: map[string]string{"tenant": "shared-tenant"}} @@ -92,7 +92,7 @@ func TestModelReferencePolicy(t *testing.T) { model.Spec.AllowedNamespaces = test.policy inputs := mockCollections(t, model) inputs.Namespaces = krt.NewStaticCollection(nil, []translator.NamespaceObservation{{Name: test.source, Namespace: test.namespace}}) - result, err := translator.ResolveModelReference(krt.TestingDummyContext{}, inputs, test.source, v1alpha3.ObjectReference{Name: model.Name, Namespace: test.target}) + result, err := translator.ResolveModelReference(krt.TestingDummyContext{}, inputs, test.source, v1alpha3.ModelConfigReference{Name: model.Name, Namespace: test.target}) if test.allowed { require.NoError(t, err) require.Equal(t, test.target, result.Config.Namespace) @@ -116,13 +116,13 @@ func TestAuxiliaryModelReferencesUseHarnessNamespaceAndPolicy(t *testing.T) { Workload: v1alpha3.HarnessWorkload{Image: new("example.com/runtime@sha256:" + strings.Repeat("a", 64))}, Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, }} - ref := v1alpha3.ObjectReference{Name: shared.Name, Namespace: shared.Namespace} + ref := v1alpha3.ModelConfigReference{Name: shared.Name, Namespace: shared.Namespace} if auxiliary == "memory" { harness.Spec.Kagent.Memory = &v1alpha3.KagentHarnessMemory{ModelConfigRef: ref} } else { harness.Spec.Kagent.Compaction = &v1alpha3.KagentHarnessCompaction{CompactionInterval: new(5), Summarizer: &v1alpha3.KagentHarnessSummarizer{ModelConfigRef: &ref}} } - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: root.Name}}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: root.Name}}} result, err := compiler(t, root, shared, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) require.NoError(t, err) require.Contains(t, string(result.ConfigJSON), "shared-model", "same-name auxiliary model must not alias the root") @@ -134,6 +134,33 @@ func TestAuxiliaryModelReferencesUseHarnessNamespaceAndPolicy(t *testing.T) { } } +func TestAgentNeedsNamespaceFollowsReferenceKinds(t *testing.T) { + selector := &v1alpha3.AllowedNamespaces{From: v1alpha3.NamespacesFromSelector, Selector: &metav1.LabelSelector{}} + model := modelConfig() + model.Spec.AllowedNamespaces = selector + server := remoteMCPServer("search", "https://search.example.com/mcp") + server.Spec.AllowedNamespaces = selector + for _, tc := range []struct { + name string + spec v1alpha3.AgentTemplateSpec + want bool + }{ + {"model reference omits the kind", v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}}, true}, + {"model reference names another kind", v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Group: new("example.com"), Kind: new("Model"), Name: model.Name}}, false}, + {"MCP server reference omits the kind", v1alpha3.AgentTemplateSpec{Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Name: server.Name}}}}}, true}, + {"MCP server reference names another kind", v1alpha3.AgentTemplateSpec{Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("Service"), Name: server.Name}}}}}, false}, + } { + outcome := "the Agent needs its namespace labels" + if !tc.want { + outcome = "the selector is ignored" + } + t.Run("Given targets with selector policies, when the "+tc.name+", assert that "+outcome, func(t *testing.T) { + agent := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "assistant"}, Spec: v1alpha3.AgentSpec{Template: v1alpha3.AgentTemplateSource{Inline: &tc.spec}}} + require.Equal(t, tc.want, translator.AgentNeedsNamespace(krt.TestingDummyContext{}, mockCollections(t, model, server), agent)) + }) + } +} + func TestCrossNamespaceMCPNames(t *testing.T) { a := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "same.tools", Namespace: "team-a"}} b := a.DeepCopy() @@ -178,8 +205,8 @@ func TestReferencePolicyMatrix(t *testing.T) { Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, }} template := &v1alpha3.AgentTemplate{Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name, Namespace: model.Namespace}, - Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name, Namespace: new(server.Namespace)}}}}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name, Namespace: model.Namespace}, + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name, Namespace: server.Namespace}}}}, }} objects := []any{model, server, harness} if test.labels != nil || test.observeError != "" { @@ -221,8 +248,8 @@ func TestRepeatedMCPBindings(t *testing.T) { server := remoteMCPServer("files", "https://tools.example.com/mcp") server.Namespace = "shared" server.Spec.AllowedNamespaces = &v1alpha3.AllowedNamespaces{From: v1alpha3.NamespacesFromAll} - ref := corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name, Namespace: new(server.Namespace)} - template := &v1alpha3.AgentTemplate{Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, Tools: []v1alpha3.ToolBinding{ + ref := v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name, Namespace: server.Namespace} + template := &v1alpha3.AgentTemplate{Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, Tools: []v1alpha3.ToolBinding{ {MCP: &v1alpha3.MCPToolBinding{Server: ref, Tools: []string{"read_file"}}}, {MCP: &v1alpha3.MCPToolBinding{Server: ref, Tools: []string{"write_file"}, RequireApproval: true}}, }}} diff --git a/go/core/internal/translator/egress_test.go b/go/core/internal/translator/egress_test.go index 177bbb0f2e..a4e08f3635 100644 --- a/go/core/internal/translator/egress_test.go +++ b/go/core/internal/translator/egress_test.go @@ -88,7 +88,7 @@ func TestAgentEgressPoliciesDoNotChangeRuntimeAcrossHarnesses(t *testing.T) { model.Spec.Provider, model.Spec.Model = v1alpha3.ModelProviderAnthropic, "claude-sonnet-4-5" model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = secret.Name, "token" } - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "helper"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "help"}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "helper"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "help"}} agent := inlineAgent(harness, template) agent.Spec.EgressPolicyRefs = []corev1.LocalObjectReference{{Name: policy.Name}} compile := func() *translator.CompileResult { diff --git a/go/core/internal/translator/foundry_test.go b/go/core/internal/translator/foundry_test.go index ded07faa01..dc4d069cdb 100644 --- a/go/core/internal/translator/foundry_test.go +++ b/go/core/internal/translator/foundry_test.go @@ -39,7 +39,7 @@ func TestCompileFoundryEndpoint(t *testing.T) { } template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "agent", Namespace: "test"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "help"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "help"}, } objects := []any{model, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: model.Spec.APIKeySecret, Namespace: model.Namespace}, @@ -55,7 +55,7 @@ func TestCompileFoundryEndpoint(t *testing.T) { }}} objects = append(objects, child, modelConfig()) case "memory": - harness.Spec.Kagent.Memory = &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ObjectReference{Name: model.Name}} + harness.Spec.Kagent.Memory = &v1alpha3.KagentHarnessMemory{ModelConfigRef: v1alpha3.ModelConfigReference{Name: model.Name}} template.Spec.ModelConfig.Name = "default-model" objects = append(objects, modelConfig()) case "BYO": diff --git a/go/core/internal/translator/image_compilation_test.go b/go/core/internal/translator/image_compilation_test.go index a29d011a19..17b5f9b33c 100644 --- a/go/core/internal/translator/image_compilation_test.go +++ b/go/core/internal/translator/image_compilation_test.go @@ -25,7 +25,7 @@ func TestCompileDefaultImageRevision(t *testing.T) { t.Run(variant.name, func(t *testing.T) { harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "runtime", Namespace: "test"}, Spec: *variant.spec.DeepCopy()} harness.Spec.Substrate.WorkerPoolRef.Name = "default" - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: "default-model"}}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: "default-model"}}} agent := inlineAgent(harness, template) model := modelConfig() if variant.name == "codex" { diff --git a/go/core/internal/translator/reference_policies.go b/go/core/internal/translator/reference_policies.go index e20a1e1553..3b5ecc2852 100644 --- a/go/core/internal/translator/reference_policies.go +++ b/go/core/internal/translator/reference_policies.go @@ -3,7 +3,6 @@ package translator import ( "istio.io/istio/pkg/kube/krt" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" "github.com/kagent-dev/kagent/go/api/v1alpha3" ) @@ -14,13 +13,11 @@ import ( // while compilation fails. The visited set also bounds invalid template cycles. func agentReferencePolicies(ctx krt.HandlerContext, inputs Collections, agent *v1alpha3.Agent) []*v1alpha3.AllowedNamespaces { var policies []*v1alpha3.AllowedNamespaces - addModel := func(ref *v1alpha3.ObjectReference) { + addModel := func(ref *v1alpha3.ModelConfigReference) { if ref == nil { return } - key := ref.NamespacedName(agent.Namespace) - model := krt.FetchOne(ctx, inputs.ResolvedModelConfigs, krt.FilterObjectName(key)) - if model != nil { + if _, model, _ := fetchModel(ctx, inputs, agent.Namespace, *ref); model != nil { policies = append(policies, model.Config.Spec.AllowedNamespaces) } } @@ -48,11 +45,9 @@ func agentReferencePolicies(ctx krt.HandlerContext, inputs Collections, agent *v visitTemplate = func(spec *v1alpha3.AgentTemplateSpec) { addModel(spec.ModelConfig) for _, tool := range spec.Tools { - if tool.MCP != nil && tool.MCP.Server.Kind == "RemoteMCPServer" { - key := (v1alpha3.ObjectReference{Name: tool.MCP.Server.Name, Namespace: ptr.Deref(tool.MCP.Server.Namespace, "")}).NamespacedName(agent.Namespace) - server := krt.FetchOne(ctx, inputs.RemoteMCPServers, krt.FilterObjectName(key)) - if server != nil { - policies = append(policies, (*server).Spec.AllowedNamespaces) + if tool.MCP != nil { + if _, server, _ := fetchMCPServer(ctx, inputs, agent.Namespace, tool.MCP.Server); server != nil { + policies = append(policies, server.Spec.AllowedNamespaces) } } if tool.SubAgent != nil && tool.SubAgent.TemplateRef != nil { diff --git a/go/core/internal/translator/references.go b/go/core/internal/translator/references.go index fe29df518e..fe77c5b66d 100644 --- a/go/core/internal/translator/references.go +++ b/go/core/internal/translator/references.go @@ -8,6 +8,7 @@ import ( "istio.io/istio/pkg/kube/krt" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "github.com/kagent-dev/kagent/go/api/v1alpha3" ) @@ -45,9 +46,11 @@ func authorizeReference(ctx krt.HandlerContext, collections Collections, source // ResolveModelReference authorizes a model before exposing dependent configuration // errors. Target validity remains independent of any particular consumer. -func ResolveModelReference(ctx krt.HandlerContext, collections Collections, source string, ref v1alpha3.ObjectReference) (*ResolvedModelConfig, error) { - key := ref.NamespacedName(source) - model := krt.FetchOne(ctx, collections.ResolvedModelConfigs, krt.FilterObjectName(key)) +func ResolveModelReference(ctx krt.HandlerContext, collections Collections, source string, ref v1alpha3.ModelConfigReference) (*ResolvedModelConfig, error) { + key, model, err := fetchModel(ctx, collections, source, ref) + if err != nil { + return nil, err + } if model == nil { return nil, fmt.Errorf("resolve ModelConfig %q: not found", key) } @@ -63,6 +66,26 @@ func ResolveModelReference(ctx krt.HandlerContext, collections Collections, sour return model, nil } +// fetchModel and fetchMCPServer hold the one kind switch per reference type. +// A nil object means not found; other kinds fail with ErrInvalidKind. +func fetchModel(ctx krt.HandlerContext, collections Collections, source string, ref v1alpha3.ModelConfigReference) (types.NamespacedName, *ResolvedModelConfig, error) { + switch kind, key := ref.Target(source); kind { + case v1alpha3.ModelConfigGroupKind: + return key, krt.FetchOne(ctx, collections.ResolvedModelConfigs, krt.FilterObjectName(key)), nil + default: + return key, nil, fmt.Errorf("resolve model %q: %w %s", key, ErrInvalidKind, kind) + } +} + +func fetchMCPServer(ctx krt.HandlerContext, collections Collections, source string, ref v1alpha3.MCPServerReference) (types.NamespacedName, *v1alpha3.RemoteMCPServer, error) { + switch kind, key := ref.Target(source); kind { + case v1alpha3.RemoteMCPServerGroupKind: + return key, ptr.Deref(krt.FetchOne(ctx, collections.RemoteMCPServers, krt.FilterObjectName(key)), nil), nil + default: + return key, nil, fmt.Errorf("resolve MCP server %q: %w %s", key, ErrInvalidKind, kind) + } +} + // SameModelCredentials distinguishes target-local Secrets even when specs match. func SameModelCredentials(a, b *v1alpha3.ModelConfig) bool { return a.Spec.APIKeySecret == b.Spec.APIKeySecret && a.Spec.APIKeySecretKey == b.Spec.APIKeySecretKey && (a.Spec.APIKeySecret == "" || a.Namespace == b.Namespace) diff --git a/go/core/internal/translator/skills_test.go b/go/core/internal/translator/skills_test.go index 0682920047..c2a7001ee4 100644 --- a/go/core/internal/translator/skills_test.go +++ b/go/core/internal/translator/skills_test.go @@ -43,7 +43,7 @@ func TestCompileSkillImageVolumes(t *testing.T) { } secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test"}, Data: map[string][]byte{"api-key": []byte("test")}} template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "skills", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "help", + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "help", Skills: []v1alpha3.AgentTemplateSkill{{Name: "review", Source: v1alpha3.ArtifactSource{OCI: image, Path: "skills/review"}}}, Plugins: []v1alpha3.PluginBundle{{Source: v1alpha3.ArtifactSource{OCI: image, Path: "plugin"}, Skills: []string{"deploy"}}}, }} diff --git a/go/core/internal/translator/validation.go b/go/core/internal/translator/validation.go index 2e0b00854f..64fcd2571c 100644 --- a/go/core/internal/translator/validation.go +++ b/go/core/internal/translator/validation.go @@ -1,11 +1,15 @@ package translator import ( + "errors" "fmt" "k8s.io/apimachinery/pkg/types" ) +// ErrInvalidKind marks a reference to a kind its field cannot resolve. +var ErrInvalidKind = errors.New("unsupported kind") + // WorkerPoolNotFoundError identifies an unresolved Harness capacity reference. type WorkerPoolNotFoundError struct { WorkerPool types.NamespacedName diff --git a/go/core/test/e2e/actor_jwt_test.go b/go/core/test/e2e/actor_jwt_test.go index a88806f1a8..11d08dd2ba 100644 --- a/go/core/test/e2e/actor_jwt_test.go +++ b/go/core/test/e2e/actor_jwt_test.go @@ -33,7 +33,7 @@ func TestModelActorJWTInjection(t *testing.T) { model.Spec.ActorJWT = &v1alpha3.ActorJWT{Audiences: []string{"model-e2e"}, ExpirationSeconds: new(int64(600))} require.NoError(t, kube.Patch(t.Context(), model, ctrlclient.MergeFrom(base))) template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "actor-jwt-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Reply briefly.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Reply briefly.", }} createAndWaitInteractionTemplate(t, harness, kube, template) fixture := newInteractionFixtureForTemplate(t, harness, target, template.Name) diff --git a/go/core/test/e2e/agent_identity_test.go b/go/core/test/e2e/agent_identity_test.go index 3ab4df5200..9599801d5c 100644 --- a/go/core/test/e2e/agent_identity_test.go +++ b/go/core/test/e2e/agent_identity_test.go @@ -26,7 +26,7 @@ func TestAgentIdentityLookupWithGatewayJWT(t *testing.T) { model.Spec.ActorJWT = &v1alpha3.ActorJWT{Audiences: []string{"model-e2e", "kagent-agent-info"}} require.NoError(t, kube.Patch(t.Context(), model, ctrlclient.MergeFrom(base))) template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "identity-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Reply briefly.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Reply briefly.", }} createAndWaitInteractionTemplate(t, harness, kube, template) agent := &v1alpha3.Agent{} diff --git a/go/core/test/e2e/ask_user_test.go b/go/core/test/e2e/ask_user_test.go index 8f414d19df..4ece2d3f56 100644 --- a/go/core/test/e2e/ask_user_test.go +++ b/go/core/test/e2e/ask_user_test.go @@ -32,7 +32,7 @@ func TestSessionAskUserDisabled(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "ask-user-disabled-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Reply briefly.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Reply briefly.", }, } createAndWaitInteractionTemplate(t, harness, kube, template) diff --git a/go/core/test/e2e/bootstrap_test.go b/go/core/test/e2e/bootstrap_test.go index 93de0ac451..32ee2e94ea 100644 --- a/go/core/test/e2e/bootstrap_test.go +++ b/go/core/test/e2e/bootstrap_test.go @@ -33,7 +33,7 @@ func TestLargeConfigurationBootstrap(t *testing.T) { model := harness.createModel(t, kube, reachableModelURL(t, recorder.URL), nil) const marker = "bootstrap-large-prompt-end" prompt := strings.Repeat("Helpful context. ", 8000) + marker - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "bootstrap-large-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: prompt, Description: strings.Repeat("description ", 4000)}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "bootstrap-large-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: prompt, Description: strings.Repeat("description ", 4000)}} createAndWaitInteractionTemplate(t, harness, kube, template) fixture := newInteractionFixtureForTemplate(t, harness, target, template.Name) _, _, task := fixture.send(t, "What is 2+2?") @@ -133,9 +133,9 @@ func largeSharedADKTemplate(t *testing.T, kube ctrlclient.Client, harness testHa t.Helper() rootModel := harness.createModel(t, kube, modelURL, map[string]string{"X-Kagent-E2E-Agent": "root"}) childModel := harness.createModel(t, kube, modelURL, map[string]string{"X-Kagent-E2E-Agent": "child"}) - root := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "bootstrap-large-shared-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: rootModel.Name}, SystemPrompt: "Delegate every request to the specialist.", Description: strings.Repeat("d", 40<<10)}} + root := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "bootstrap-large-shared-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: rootModel.Name}, SystemPrompt: "Delegate every request to the specialist.", Description: strings.Repeat("d", 40<<10)}} for i := range children { - child := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "bootstrap-large-child-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: childModel.Name}, Description: "Shared specialist", SystemPrompt: strings.Repeat("c", 56<<10) + "large-shared-child-instruction-end"}} + child := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "bootstrap-large-child-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: childModel.Name}, Description: "Shared specialist", SystemPrompt: strings.Repeat("c", 56<<10) + "large-shared-child-instruction-end"}} createSharedTemplate(t, kube, child) name := "specialist" if i > 0 { diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go index 0c539d8c02..bc408e149d 100644 --- a/go/core/test/e2e/claude_interaction_test.go +++ b/go/core/test/e2e/claude_interaction_test.go @@ -110,7 +110,7 @@ func createClaudeTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, des Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: modelConfig}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: modelConfig}, Description: description, SystemPrompt: "Reply concisely and follow the requested output format exactly.", }, } @@ -126,7 +126,7 @@ func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, Description: "Claude local specialist", SystemPrompt: childPrompt, }, @@ -138,7 +138,7 @@ func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, Description: "Claude local-subagent E2E fixture", SystemPrompt: "Always delegate the request to the specialist subagent, then return its answer.", Tools: []v1alpha3.ToolBinding{{SubAgent: &v1alpha3.SubAgentToolBinding{ @@ -222,11 +222,11 @@ func createClaudeMCPTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: modelConfig}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: modelConfig}, Description: "Claude direct whole-server MCP E2E fixture", SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: mcpServer}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: mcpServer}, RequireApproval: requireApproval, }}}, }, diff --git a/go/core/test/e2e/codex_interaction_test.go b/go/core/test/e2e/codex_interaction_test.go index a218c4bd83..017967a13e 100644 --- a/go/core/test/e2e/codex_interaction_test.go +++ b/go/core/test/e2e/codex_interaction_test.go @@ -80,7 +80,7 @@ func createCodexMockTemplate(t *testing.T, baseURL string) string { Labels: map[string]string{"kagent.dev/e2e-runtime": "codex"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, Description: "Codex mockLLM interaction fixture", SystemPrompt: "Reply concisely and follow the requested output format exactly.", }, @@ -153,11 +153,11 @@ func createCodexMCPTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, m Labels: map[string]string{"kagent.dev/e2e-runtime": "codex"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: modelConfig}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: modelConfig}, Description: "Codex direct whole-server MCP E2E fixture", SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: mcpServer}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: mcpServer}, RequireApproval: requireApproval, }}}, }, diff --git a/go/core/test/e2e/compaction_test.go b/go/core/test/e2e/compaction_test.go index 163453f31e..5f9ff6e84f 100644 --- a/go/core/test/e2e/compaction_test.go +++ b/go/core/test/e2e/compaction_test.go @@ -186,7 +186,7 @@ func createCompactionHarness(t *testing.T, kube ctrlclient.Client, summarizerMod } harness.Spec.Kagent.Compaction = policy harness.Spec.Kagent.Compaction.Summarizer = &v1alpha3.KagentHarnessSummarizer{ - ModelConfigRef: &v1alpha3.ObjectReference{Name: summarizerModel}, + ModelConfigRef: &v1alpha3.ModelConfigReference{Name: summarizerModel}, PromptTemplate: compactionSummaryPrompt + "\n\n{conversation_history}", } if err := kube.Create(t.Context(), harness); err != nil { @@ -210,7 +210,7 @@ func createCompactionInteractionTemplate(t *testing.T, kube ctrlclient.Client, h Labels: map[string]string{compactionRuntimeLabel: compactionRuntime, "kagent.dev/harness": harnessName}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: agentModel}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: agentModel}, Description: "Context compaction E2E fixture", SystemPrompt: "Reply briefly.", }, diff --git a/go/core/test/e2e/credentials_test.go b/go/core/test/e2e/credentials_test.go index 7618c1a052..ffbe27d184 100644 --- a/go/core/test/e2e/credentials_test.go +++ b/go/core/test/e2e/credentials_test.go @@ -39,7 +39,7 @@ func TestModelCredentialDelivery(t *testing.T) { require.NoError(t, kube.Patch(t.Context(), model, ctrlclient.MergeFrom(before))) template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "gateway-credentials-", Namespace: "kagent", Labels: harness.labels()}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Reply briefly."}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Reply briefly."}, } createAndWaitInteractionTemplate(t, harness, kube, template) fixture := newInteractionFixtureForTemplate(t, harness, target, template.Name) diff --git a/go/core/test/e2e/cross_namespace_test.go b/go/core/test/e2e/cross_namespace_test.go index 8d685abc5d..30fcedcfc8 100644 --- a/go/core/test/e2e/cross_namespace_test.go +++ b/go/core/test/e2e/cross_namespace_test.go @@ -42,7 +42,7 @@ func TestCrossNamespaceModelCredentialDelivery(t *testing.T) { shared.Spec.AllowedNamespaces = &v1alpha3.AllowedNamespaces{From: v1alpha3.NamespacesFromAll} require.NoError(t, kube.Create(t.Context(), shared)) t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), shared)) }) - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "cross-namespace-model-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: shared.Name, Namespace: namespace}, SystemPrompt: "Reply briefly."}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "cross-namespace-model-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: shared.Name, Namespace: namespace}, SystemPrompt: "Reply briefly."}} createAndWaitInteractionTemplate(t, harness, kube, template) fixture := newInteractionFixtureForTemplate(t, harness, interactionTarget(t), template.Name) _, _, task := fixture.send(t, "What is 2+2?") diff --git a/go/core/test/e2e/default_image_test.go b/go/core/test/e2e/default_image_test.go index d8f835045f..433ea3a173 100644 --- a/go/core/test/e2e/default_image_test.go +++ b/go/core/test/e2e/default_image_test.go @@ -30,7 +30,7 @@ func TestBuiltinHarnessDefaultImages(t *testing.T) { harness.Spec.Workload.Image = nil agent := &v1alpha3.Agent{ ObjectMeta: metav1.ObjectMeta{GenerateName: "default-image-", Namespace: source.Namespace}, - Spec: v1alpha3.AgentSpec{Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Be helpful."}}}, + Spec: v1alpha3.AgentSpec{Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Be helpful."}}}, } require.NoError(t, kube.Create(t.Context(), harness)) t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), harness)) }) @@ -99,7 +99,7 @@ func TestInstalledDefaultHarnesses(t *testing.T) { ObjectMeta: metav1.ObjectMeta{GenerateName: "default-harness-", Namespace: harness.Namespace}, Spec: v1alpha3.AgentSpec{ HarnessRef: corev1.LocalObjectReference{Name: harness.Name}, - Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: scenario.model(t, kube)}, SystemPrompt: "Be helpful."}}, + Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ModelConfigReference{Name: scenario.model(t, kube)}, SystemPrompt: "Be helpful."}}, }, } require.NoError(t, kube.Create(t.Context(), agent)) diff --git a/go/core/test/e2e/egress_credentials_test.go b/go/core/test/e2e/egress_credentials_test.go index 54aa2507a3..e3f3aff468 100644 --- a/go/core/test/e2e/egress_credentials_test.go +++ b/go/core/test/e2e/egress_credentials_test.go @@ -30,7 +30,7 @@ func TestEgressPolicyCredentialDelivery(t *testing.T) { modelURL, model, runs := startEgressSkillMock(t, harness.skillsRoot(), destination, "credentials") config := harness.createModel(t, kube, modelURL, nil) template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "egress-credentials-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: config.Name}, SystemPrompt: "Load the egress skill and run its probe.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: config.Name}, SystemPrompt: "Load the egress skill and run its probe.", Skills: []v1alpha3.AgentTemplateSkill{{Name: "egress", Source: v1alpha3.ArtifactSource{OCI: image, Path: "egress"}}}, }} createAndWaitInteractionTemplate(t, harness, kube, template) diff --git a/go/core/test/e2e/egress_test.go b/go/core/test/e2e/egress_test.go index 07ee2a3309..bcb792054a 100644 --- a/go/core/test/e2e/egress_test.go +++ b/go/core/test/e2e/egress_test.go @@ -93,7 +93,7 @@ func TestSessionEgressPolicyUpdatesLiveActors(t *testing.T) { modelURL, model, runs := startEgressSkillMock(t, harness.skillsRoot(), destination, "live", "checkpoint", "fresh", "resumed", "fork") config := harness.createModel(t, kube, modelURL, nil) template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{GenerateName: "live-egress-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: config.Name}, SystemPrompt: "Load the egress skill and run its probe.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: config.Name}, SystemPrompt: "Load the egress skill and run its probe.", Skills: []v1alpha3.AgentTemplateSkill{{Name: "egress", Source: v1alpha3.ArtifactSource{OCI: image, Path: "egress"}}}, }} createAndWaitInteractionTemplate(t, harness, kube, template) diff --git a/go/core/test/e2e/genai_baggage_test.go b/go/core/test/e2e/genai_baggage_test.go index 8a0236655d..d99b45c05c 100644 --- a/go/core/test/e2e/genai_baggage_test.go +++ b/go/core/test/e2e/genai_baggage_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/baggage" "google.golang.org/grpc/metadata" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -171,11 +170,11 @@ func createGenAIBaggageTemplate(t *testing.T, kube ctrlclient.Client, harnessNam Labels: map[string]string{"kagent.dev/harness": harnessName}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: modelName}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: modelName}, Description: "GenAI baggage E2E fixture", SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, }}}, }, } diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 8a77a0d16c..19a0e54c1c 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -1110,7 +1110,7 @@ func createInteractionTemplate(t *testing.T, harness testHarness, modelURL strin Labels: harness.labels(), }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, Description: "Agent interaction E2E fixture", SystemPrompt: "Reply briefly.", }, @@ -1144,12 +1144,12 @@ func createStructuredOutputInteractionTemplate(t *testing.T, harness testHarness Labels: harness.labels(), }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Use available tools when needed, then return the arithmetic answer and a short explanation.", OutputSchema: &apiextensionsv1.JSON{Raw: []byte(structuredOutputSchema)}, OutputSchemaStrict: true, Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, Tools: []string{"add_numbers"}, }}}, }, @@ -1210,11 +1210,11 @@ func createMCPReferenceTemplate(t *testing.T, harness testHarness, server *v1alp Labels: harness.labels(), }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, Description: "MCP interaction E2E fixture", SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name, Namespace: new(server.Namespace)}, + Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name, Namespace: server.Namespace}, RequireApproval: requireApproval, }}}, }, @@ -1234,7 +1234,7 @@ func createSharedInteractionTemplates(t *testing.T, harness testHarness, modelUR Labels: harness.labels(), }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: childModel.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: childModel.Name}, Description: "Shared specialist", SystemPrompt: "Answer as the shared specialist.", }, @@ -1246,7 +1246,7 @@ func createSharedInteractionTemplates(t *testing.T, harness testHarness, modelUR Labels: harness.labels(), }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: rootModel.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: rootModel.Name}, Description: "Shared agent interaction E2E fixture", SystemPrompt: "Delegate every request to the specialist.", Tools: []v1alpha3.ToolBinding{{SubAgent: &v1alpha3.SubAgentToolBinding{ diff --git a/go/core/test/e2e/mcp_discovery_test.go b/go/core/test/e2e/mcp_discovery_test.go index bd4369a2a3..bae97c77c7 100644 --- a/go/core/test/e2e/mcp_discovery_test.go +++ b/go/core/test/e2e/mcp_discovery_test.go @@ -20,7 +20,6 @@ import ( "github.com/google/uuid" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -312,7 +311,7 @@ func createMCPDiscoveryTemplate(t *testing.T, modelURL string, serverURLs ...str }) template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "mcp-discovery-", Namespace: "kagent", Labels: harness.labels()}, - Spec: v1alpha3.AgentTemplateSpec{Description: "MCP discovery E2E fixture", SystemPrompt: "Use the configured MCP tools.", ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}}, + Spec: v1alpha3.AgentTemplateSpec{Description: "MCP discovery E2E fixture", SystemPrompt: "Use the configured MCP tools.", ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}}, } for _, serverURL := range serverURLs { server := &v1alpha3.RemoteMCPServer{ @@ -325,7 +324,7 @@ func createMCPDiscoveryTemplate(t *testing.T, modelURL string, serverURLs ...str t.Errorf("delete MCP server: %v", err) } }) - template.Spec.Tools = append(template.Spec.Tools, v1alpha3.ToolBinding{MCP: &v1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}}}) + template.Spec.Tools = append(template.Spec.Tools, v1alpha3.ToolBinding{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}}}) } createAndWaitInteractionTemplateForHarness(t, kube, template, harness.name) return template.Name diff --git a/go/core/test/e2e/sandbox_agent_test.go b/go/core/test/e2e/sandbox_agent_test.go index 5309b9d135..9863e9ee2c 100644 --- a/go/core/test/e2e/sandbox_agent_test.go +++ b/go/core/test/e2e/sandbox_agent_test.go @@ -12,7 +12,6 @@ import ( "github.com/kagent-dev/mockllm" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -64,8 +63,8 @@ func TestSandboxAgentMCP(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "scratch-caller-", Namespace: f.template.Namespace}, Spec: v1alpha3.AgentTemplateSpec{ - Description: "Scratch sandbox MCP caller", SystemPrompt: "Use create_sandbox to create the requested workspace.", ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, - Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"create_sandbox"}}}}, + Description: "Scratch sandbox MCP caller", SystemPrompt: "Use create_sandbox to create the requested workspace.", ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: v1alpha3.MCPServerReference{Kind: new("RemoteMCPServer"), Name: server.Name}, Tools: []string{"create_sandbox"}}}}, }, } createAndWaitInteractionTemplateForHarness(t, kube, template, harness.Name) diff --git a/go/core/test/e2e/session_resuming_deletion_test.go b/go/core/test/e2e/session_resuming_deletion_test.go index ae90d5e034..6ebbf00a2a 100644 --- a/go/core/test/e2e/session_resuming_deletion_test.go +++ b/go/core/test/e2e/session_resuming_deletion_test.go @@ -46,7 +46,7 @@ func TestSessionDeletionAfterInterruptedResume(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "interrupted-resume-", Namespace: "kagent"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Reply briefly.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Reply briefly.", }, } createAndWaitInteractionTemplateForHarness(t, kube, template, harness.Name) diff --git a/go/core/test/e2e/skills_test.go b/go/core/test/e2e/skills_test.go index a8431484ee..bbcac6db40 100644 --- a/go/core/test/e2e/skills_test.go +++ b/go/core/test/e2e/skills_test.go @@ -41,7 +41,7 @@ func TestOCISkillsSurviveSuspension(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "oci-skills-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: config.Name}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: config.Name}, SystemPrompt: "Load the requested skills and follow their instructions.", Skills: []v1alpha3.AgentTemplateSkill{{Name: "standalone", Source: v1alpha3.ArtifactSource{OCI: image, Path: "standalone"}}}, Plugins: []v1alpha3.PluginBundle{{Source: v1alpha3.ArtifactSource{OCI: image, Path: "plugin"}, Skills: []string{"selected"}}}, diff --git a/go/core/test/e2e/streaming_test.go b/go/core/test/e2e/streaming_test.go index ab5bbabc5d..26164ed336 100644 --- a/go/core/test/e2e/streaming_test.go +++ b/go/core/test/e2e/streaming_test.go @@ -56,7 +56,7 @@ func TestSessionModelStreamingDisabled(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{GenerateName: "non-streaming-", Namespace: "kagent", Labels: harness.labels()}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "Reply briefly.", + ModelConfig: &v1alpha3.ModelConfigReference{Name: model.Name}, SystemPrompt: "Reply briefly.", }, } createAndWaitInteractionTemplate(t, harness, kube, template) diff --git a/go/core/test/e2e/tracing_test.go b/go/core/test/e2e/tracing_test.go index c16c19cb60..6cf56959f2 100644 --- a/go/core/test/e2e/tracing_test.go +++ b/go/core/test/e2e/tracing_test.go @@ -542,7 +542,7 @@ func createTracingTemplate(t *testing.T, harnessName, runtimeLabel, modelName st Labels: map[string]string{"kagent.dev/e2e-runtime": runtimeLabel}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.ObjectReference{Name: modelName}, + ModelConfig: &v1alpha3.ModelConfigReference{Name: modelName}, Description: "Completed-chat trace flush E2E fixture", SystemPrompt: "Reply concisely and follow the requested output format exactly.", }, diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 4ff456d011..d12b4adbc1 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -115,12 +115,28 @@ spec: description: ModelConfig is required by managed harnesses and optional for BYO harnesses. properties: + group: + description: Group is the referenced API group. It defaults + to api.kagent.dev. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is the referenced kind. It defaults + to ModelConfig. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string name: + description: Name is the referenced model's name. maxLength: 253 minLength: 1 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string namespace: + description: Namespace is the referenced model's namespace. + Empty means the owner's namespace. maxLength: 63 pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string @@ -128,8 +144,10 @@ spec: - name type: object x-kubernetes-validations: - - message: name must not be empty - rule: has(self.name) && self.name != '' + - message: a group other than api.kagent.dev requires its + own kind + rule: '!has(self.group) || self.group == ''api.kagent.dev'' + || (has(self.kind) && self.kind != ''ModelConfig'')' outputSchema: description: |- OutputSchema constrains successful terminal output when this template is @@ -398,47 +416,50 @@ spec: Tools is omitted or empty. type: boolean server: - description: TypedObjectReference contains enough - information to let you locate the typed referenced - object + description: Server is the MCP server that provides + the tools. properties: apiGroup: description: |- - APIGroup is the group for the resource being referenced. - If APIGroup is not specified, the specified Kind must be in the core API group. - For any other third-party types, APIGroup is required. + APIGroup is not supported. + + Deprecated: use Group. + type: string + group: + description: Group is the referenced API group. + It defaults to api.kagent.dev. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string kind: - description: Kind is the type of resource being - referenced + default: RemoteMCPServer + description: Kind is the referenced kind. It + defaults to RemoteMCPServer. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ type: string name: - description: Name is the name of resource being - referenced + description: Name is the referenced server's + name. + maxLength: 253 + minLength: 1 type: string namespace: - description: |- - Namespace is the namespace of resource being referenced - Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. - (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + description: Namespace is the referenced server's + namespace. Empty means the owner's namespace. + maxLength: 63 type: string required: - - kind - name type: object x-kubernetes-validations: - - message: kind must be RemoteMCPServer - rule: self.kind == 'RemoteMCPServer' - - message: apiGroup must be omitted + - message: use group instead of apiGroup rule: '!has(self.apiGroup)' - - fieldPath: .name - message: name must contain between 1 and 253 characters - rule: size(self.name) > 0 && size(self.name) <= - 253 - - fieldPath: .namespace - message: namespace must contain at most 63 characters - rule: '!has(self.namespace) || size(self.namespace) - <= 63' + - message: a group other than api.kagent.dev requires + its own kind + rule: '!has(self.group) || self.group == ''api.kagent.dev'' + || (has(self.kind) && self.kind != ''RemoteMCPServer'')' tools: description: |- Tools optionally limits which server tools are exposed. An omitted or empty diff --git a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml index 3450e33831..4d17c6255f 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml @@ -51,12 +51,27 @@ spec: description: ModelConfig is required by managed harnesses and optional for BYO harnesses. properties: + group: + description: Group is the referenced API group. It defaults to + api.kagent.dev. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is the referenced kind. It defaults to ModelConfig. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string name: + description: Name is the referenced model's name. maxLength: 253 minLength: 1 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string namespace: + description: Namespace is the referenced model's namespace. Empty + means the owner's namespace. maxLength: 63 pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string @@ -64,8 +79,9 @@ spec: - name type: object x-kubernetes-validations: - - message: name must not be empty - rule: has(self.name) && self.name != '' + - message: a group other than api.kagent.dev requires its own kind + rule: '!has(self.group) || self.group == ''api.kagent.dev'' || (has(self.kind) + && self.kind != ''ModelConfig'')' outputSchema: description: |- OutputSchema constrains successful terminal output when this template is @@ -330,43 +346,49 @@ spec: Tools is omitted or empty. type: boolean server: - description: TypedObjectReference contains enough information - to let you locate the typed referenced object + description: Server is the MCP server that provides the + tools. properties: apiGroup: description: |- - APIGroup is the group for the resource being referenced. - If APIGroup is not specified, the specified Kind must be in the core API group. - For any other third-party types, APIGroup is required. + APIGroup is not supported. + + Deprecated: use Group. + type: string + group: + description: Group is the referenced API group. It defaults + to api.kagent.dev. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string kind: - description: Kind is the type of resource being referenced + default: RemoteMCPServer + description: Kind is the referenced kind. It defaults + to RemoteMCPServer. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ type: string name: - description: Name is the name of resource being referenced + description: Name is the referenced server's name. + maxLength: 253 + minLength: 1 type: string namespace: - description: |- - Namespace is the namespace of resource being referenced - Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. - (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + description: Namespace is the referenced server's namespace. + Empty means the owner's namespace. + maxLength: 63 type: string required: - - kind - name type: object x-kubernetes-validations: - - message: kind must be RemoteMCPServer - rule: self.kind == 'RemoteMCPServer' - - message: apiGroup must be omitted + - message: use group instead of apiGroup rule: '!has(self.apiGroup)' - - fieldPath: .name - message: name must contain between 1 and 253 characters - rule: size(self.name) > 0 && size(self.name) <= 253 - - fieldPath: .namespace - message: namespace must contain at most 63 characters - rule: '!has(self.namespace) || size(self.namespace) <= - 63' + - message: a group other than api.kagent.dev requires its + own kind + rule: '!has(self.group) || self.group == ''api.kagent.dev'' + || (has(self.kind) && self.kind != ''RemoteMCPServer'')' tools: description: |- Tools optionally limits which server tools are exposed. An omitted or empty diff --git a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml index cbb6dda874..30a76e53e3 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml @@ -117,12 +117,28 @@ spec: ModelConfigRef references the ModelConfig (defaulting to the Harness namespace) that writes the summaries. Omitted uses the agent's own model. properties: + group: + description: Group is the referenced API group. It + defaults to api.kagent.dev. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is the referenced kind. It defaults + to ModelConfig. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string name: + description: Name is the referenced model's name. maxLength: 253 minLength: 1 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string namespace: + description: Namespace is the referenced model's namespace. + Empty means the owner's namespace. maxLength: 63 pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string @@ -130,8 +146,10 @@ spec: - name type: object x-kubernetes-validations: - - message: name must not be empty - rule: has(self.name) && self.name != '' + - message: a group other than api.kagent.dev requires + its own kind + rule: '!has(self.group) || self.group == ''api.kagent.dev'' + || (has(self.kind) && self.kind != ''ModelConfig'')' promptTemplate: description: |- PromptTemplate replaces the runtime's default summarization prompt. It @@ -167,18 +185,39 @@ spec: description: ModelConfigRef references the embedding ModelConfig (defaulting to the Harness namespace). properties: + group: + description: Group is the referenced API group. It defaults + to api.kagent.dev. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is the referenced kind. It defaults + to ModelConfig. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string name: + description: Name is the referenced model's name. maxLength: 253 minLength: 1 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string namespace: + description: Namespace is the referenced model's namespace. + Empty means the owner's namespace. maxLength: 63 pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string required: - name type: object + x-kubernetes-validations: + - message: a group other than api.kagent.dev requires its + own kind + rule: '!has(self.group) || self.group == ''api.kagent.dev'' + || (has(self.kind) && self.kind != ''ModelConfig'')' ttlDays: description: TTLDays controls how many days a stored memory entry remains valid. @@ -187,9 +226,6 @@ spec: required: - modelConfigRef type: object - x-kubernetes-validations: - - message: modelConfigRef name must not be empty - rule: self.modelConfigRef.name.size() > 0 type: object substrate: description: RuntimeSubstratePolicy contains the Substrate policy diff --git a/ui/src/components/agent-template-form/agentTemplateDraft.ts b/ui/src/components/agent-template-form/agentTemplateDraft.ts index 1440fa4e7f..c090370beb 100644 --- a/ui/src/components/agent-template-form/agentTemplateDraft.ts +++ b/ui/src/components/agent-template-form/agentTemplateDraft.ts @@ -167,7 +167,7 @@ export function specFromDraft( .filter((tool) => tool.serverRef.name.trim() !== "") .map((tool) => ({ mcp: { - // The only kind the CRD's enum allows. + // The form only selects RemoteMCPServers. server: { kind: "RemoteMCPServer" as const, ...normalizedReference(tool.serverRef, draft.namespace) }, ...(tool.tools.length > 0 ? { tools: [...tool.tools] } : {}), // omitempty on the CRD: false is the default, so only true is sent.