From e7cc8d62993c228c50bed88ce4b66a65eea90073 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Mon, 9 Nov 2026 10:25:24 -0800 Subject: [PATCH 1/4] feat(api): own typed references for models and MCP servers Signed-off-by: Cody Hartsook --- .../cross-namespace-references.md | 22 +++- docs/kubernetes-api.md | 9 +- go/api/v1alpha3/agenttemplate_types.go | 8 +- go/api/v1alpha3/common_types.go | 98 +++++++++++++-- go/api/v1alpha3/configuration_crd_cel_test.go | 92 +++++++++++--- go/api/v1alpha3/groupversion_info.go | 4 + go/api/v1alpha3/harness_types.go | 4 +- go/api/v1alpha3/zz_generated.deepcopy.go | 82 ++++++++---- .../internal/commands/agent_template_test.go | 2 +- .../internal/controller/collections_test.go | 47 +++++-- .../controller/grant_admission_test.go | 2 +- go/core/internal/controller/reconciler.go | 2 + .../internal/controller/reconciler_test.go | 4 +- .../reference_namespaces_integration_test.go | 2 +- go/core/internal/grpcserver/agenttemplate.go | 6 +- .../grpcserver/agenttemplate_harness_test.go | 18 ++- .../internal/translator/adkconfig/builder.go | 4 +- .../translator/adkconfig/builder_test.go | 10 +- .../internal/translator/adkconfig/runtime.go | 14 ++- .../translator/claude/compiler_test.go | 12 +- .../translator/codex/compiler_test.go | 4 +- go/core/internal/translator/compiler.go | 14 +-- go/core/internal/translator/compiler_test.go | 115 +++++++++++++---- go/core/internal/translator/credentials.go | 2 +- .../translator/cross_namespace_test.go | 45 +++++-- go/core/internal/translator/egress_test.go | 2 +- go/core/internal/translator/foundry_test.go | 4 +- .../translator/image_compilation_test.go | 2 +- .../internal/translator/reference_policies.go | 15 +-- go/core/internal/translator/references.go | 29 ++++- go/core/internal/translator/skills_test.go | 2 +- go/core/internal/translator/validation.go | 4 + go/core/test/e2e/actor_jwt_test.go | 2 +- go/core/test/e2e/agent_identity_test.go | 2 +- go/core/test/e2e/ask_user_test.go | 2 +- go/core/test/e2e/bootstrap_test.go | 6 +- go/core/test/e2e/claude_interaction_test.go | 10 +- go/core/test/e2e/codex_interaction_test.go | 6 +- go/core/test/e2e/compaction_test.go | 4 +- go/core/test/e2e/credentials_test.go | 2 +- go/core/test/e2e/cross_namespace_test.go | 2 +- go/core/test/e2e/default_image_test.go | 4 +- go/core/test/e2e/egress_credentials_test.go | 2 +- go/core/test/e2e/egress_test.go | 2 +- go/core/test/e2e/genai_baggage_test.go | 5 +- go/core/test/e2e/interaction_test.go | 14 +-- go/core/test/e2e/mcp_discovery_test.go | 5 +- go/core/test/e2e/reference_kinds_test.go | 47 +++++++ go/core/test/e2e/sandbox_agent_test.go | 5 +- .../e2e/session_resuming_deletion_test.go | 2 +- go/core/test/e2e/skills_test.go | 2 +- go/core/test/e2e/streaming_test.go | 2 +- go/core/test/e2e/tracing_test.go | 2 +- go/core/test/upgrade/references_test.go | 117 ++++++++++++++++++ go/core/test/upgrade/upgrade_test.go | 18 +++ .../templates/api.kagent.dev_agents.yaml | 67 ++++++---- .../api.kagent.dev_agenttemplates.yaml | 61 +++++---- .../templates/api.kagent.dev_harnesses.yaml | 31 +++++ .../agent-template-form/agentTemplateDraft.ts | 2 +- 59 files changed, 858 insertions(+), 246 deletions(-) create mode 100644 go/core/test/e2e/reference_kinds_test.go create mode 100644 go/core/test/upgrade/references_test.go diff --git a/docs/architecture/cross-namespace-references.md b/docs/architecture/cross-namespace-references.md index cdd7134629..e0bf7eeee1 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. + +Go callers can keep the deprecated alias `v1alpha3.ObjectReference`. +`MCPToolBinding.Server` now uses `v1alpha3.MCPServerReference`. diff --git a/docs/kubernetes-api.md b/docs/kubernetes-api.md index 42b4393d90..8d41646197 100644 --- a/docs/kubernetes-api.md +++ b/docs/kubernetes-api.md @@ -30,8 +30,13 @@ 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. +`TypedLocalObjectReference`, and `SecretKeySelector` when their semantics match. +When they do not, own the reference type instead of patching a native one with +CEL. For example, `TypedObjectReference` treats an omitted `apiGroup` as the core +group, so model and MCP server references own `group` and `kind` +fields that default to the kagent kind. The controller rejects other kinds with +reason `InvalidKind`. 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 diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index 491adcd860..29c11ff1a1 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -46,12 +46,8 @@ 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" // +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. @@ -178,7 +174,7 @@ 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..a3ee55f843 100644 --- a/go/api/v1alpha3/common_types.go +++ b/go/api/v1alpha3/common_types.go @@ -17,16 +17,31 @@ 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; an omitted namespace means the owner's namespace. +// +kubebuilder:validation:XValidation:rule="!has(self.group) || has(self.kind)",message="group requires kind" +type ModelConfigReference struct { + // Group is the referenced API group. It defaults to api.kagent.dev and requires kind. + // +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"` // +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])?)*$` @@ -38,12 +53,81 @@ type ObjectReference struct { Namespace string `json:"namespace,omitempty"` } +// 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} +} + +// ObjectReference is the v1.0.x name of ModelConfigReference. +// +// Deprecated: use ModelConfigReference. +type ObjectReference = ModelConfigReference + // 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} +// +// Deprecated: it ignores Kind; use Target. +func (r ModelConfigReference) NamespacedName(owner string) types.NamespacedName { + _, key := r.Target(owner) + return key +} + +// The kind default applies before CEL, so "group requires kind" would always pass. +// The second rule rejects a foreign group with the default kind instead. + +// MCPServerReference identifies an MCP server; an omitted namespace means the owner's namespace. +// +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 encode an unset apiGroup as null. Keeping the field lets + // strict field validation accept their requests; the server drops the null. + + // 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"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +required + Name string `json:"name"` + // +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..392cee7c45 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -73,30 +73,74 @@ 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("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("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"}, {Group: new("api.kagent.dev"), Name: "model"}} { 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}}}, @@ -288,7 +332,7 @@ func TestConfigurationCRDValidation(t *testing.T) { 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 +389,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,7 +416,7 @@ 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", }, @@ -394,11 +438,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,7 +472,7 @@ 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", }, @@ -438,7 +481,7 @@ func TestConfigurationCRDValidation(t *testing.T) { 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 +706,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 +729,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..c09230d354 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -78,7 +78,7 @@ type KagentHarnessSummarizer struct { // 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. @@ -92,7 +92,7 @@ type KagentHarnessSummarizer struct { 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/reference_kinds_test.go b/go/core/test/e2e/reference_kinds_test.go new file mode 100644 index 0000000000..8349e527f5 --- /dev/null +++ b/go/core/test/e2e/reference_kinds_test.go @@ -0,0 +1,47 @@ +package e2e_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +func TestE2EUnsupportedReferenceKinds(t *testing.T) { + interactionTarget(t) + kube := interactionKubeClient(t) + + t.Run("Given a model reference to a kind kagent does not resolve, when an Agent is created, assert that ResolvedRefs reports InvalidKind", func(t *testing.T) { + agent := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{GenerateName: "unsupported-kinds-", Namespace: "kagent"}, Spec: v1alpha3.AgentSpec{ + HarnessRef: corev1.LocalObjectReference{Name: "kagent"}, + Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ + ModelConfig: &v1alpha3.ModelConfigReference{Group: new("example.com"), Kind: new("Model"), Name: "default-model-config"}, + SystemPrompt: "Reply briefly.", + }}, + }} + require.NoError(t, kube.Create(t.Context(), agent)) + t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), agent)) }) + + // Wait for ResolvedRefs to describe this generation, then assert its outcome. + var condition *metav1.Condition + err := wait.PollUntilContextTimeout(t.Context(), time.Second, time.Minute, true, func(ctx context.Context) (bool, error) { + if err := kube.Get(ctx, ctrlclient.ObjectKeyFromObject(agent), agent); err != nil { + return false, err + } + condition = meta.FindStatusCondition(agent.Status.Conditions, v1alpha3.AgentConditionResolvedRefs) + return condition != nil && condition.ObservedGeneration == agent.Generation && condition.Status != metav1.ConditionUnknown, nil + }) + require.NoError(t, err, "last conditions: %+v", agent.Status.Conditions) + require.Equal(t, metav1.ConditionFalse, condition.Status, condition.Message) + require.Equal(t, "InvalidKind", condition.Reason, condition.Message) + require.Empty(t, agent.Status.DesiredRevision) + }) +} 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/go/core/test/upgrade/references_test.go b/go/core/test/upgrade/references_test.go new file mode 100644 index 0000000000..1910912261 --- /dev/null +++ b/go/core/test/upgrade/references_test.go @@ -0,0 +1,117 @@ +package upgrade + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// v1ReferenceManifest uses only the reference shapes the v1.0.x release writes: +// name-only model refs, and an MCP server ref with an explicit kind. +const v1ReferenceManifest = `apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: %[1]s + namespace: %[2]s +spec: + kagent: + compaction: + compactionInterval: 5 + summarizer: + modelConfigRef: + name: %[3]s + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: %[1]s + namespace: %[2]s +spec: + harnessRef: + name: %[1]s + template: + inline: + systemPrompt: Reply briefly. + modelConfig: + name: %[3]s + tools: + - mcp: + server: + kind: RemoteMCPServer + name: %[4]s +` + +// The chart creates both for the release named kagent. +const ( + v1ReferenceModelConfig = "default-model-config" + v1ReferenceMCPServer = "kagent-tool-server" +) + +// seedV1References applies a Harness and Agent named name in the v1.0.x reference shapes. +func seedV1References(t *testing.T, env upgradeEnv, name string) { + t.Helper() + + kubectl(t, env, time.Minute, "get", "modelconfig", v1ReferenceModelConfig, "-n", env.namespace) + kubectl(t, env, time.Minute, "get", "remotemcpserver", v1ReferenceMCPServer, "-n", env.namespace) + manifest := fmt.Sprintf(v1ReferenceManifest, name, env.namespace, v1ReferenceModelConfig, v1ReferenceMCPServer) + path := filepath.Join(t.TempDir(), "ga-references.yaml") + require.NoError(t, os.WriteFile(path, []byte(manifest), 0o600)) + kubectl(t, env, time.Minute, "apply", "-f", path) +} + +// requireV1ReferencesReconciled edits the seeded Agent's prompt, then waits for the +// serving controller to report that generation resolved and ready. +// Every condition carries the generation, so a stale Ready from an earlier revision cannot pass. +func requireV1ReferencesReconciled(t *testing.T, env upgradeEnv, name, phase string) { + t.Helper() + + patch := fmt.Sprintf(`{"spec":{"template":{"inline":{"systemPrompt":"Reply briefly (%s)."}}}}`, phase) + generation := int64(parseInt(t, kubectl(t, env, time.Minute, + "patch", "agent", name, "-n", env.namespace, "--type=merge", "-p", patch, + "-o", "jsonpath={.metadata.generation}", + ), "Agent generation")) + + // Literal condition types and reasons: both the previous and current controllers must write them. + want := []metav1.Condition{ + {Type: "ResolvedRefs", Status: metav1.ConditionTrue, Reason: "Resolved"}, + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "Ready"}, + } + require.EventuallyWithT(t, func(c *assert.CollectT) { + out, err := kubectlOutput(t, env, time.Minute, "get", "agent", name, "-n", env.namespace, "-o", "json") + if !assert.NoError(c, err) { + return + } + var agent struct { + Status struct { + ObservedGeneration int64 `json:"observedGeneration"` + Conditions []metav1.Condition `json:"conditions"` + } `json:"status"` + } + if !assert.NoError(c, json.Unmarshal([]byte(out), &agent)) { + return + } + assert.Equal(c, generation, agent.Status.ObservedGeneration, "status.observedGeneration") + for _, expected := range want { + condition := meta.FindStatusCondition(agent.Status.Conditions, expected.Type) + if !assert.NotNil(c, condition, "condition %s", expected.Type) { + continue + } + assert.Equal(c, generation, condition.ObservedGeneration, "%s observedGeneration", expected.Type) + assert.Equal(c, expected.Status, condition.Status, "%s status (%s: %s)", expected.Type, condition.Reason, condition.Message) + assert.Equal(c, expected.Reason, condition.Reason, "%s reason", expected.Type) + } + }, 5*time.Minute, 5*time.Second, "[%s] Agent %s/%s did not reconcile its v1.0.x reference shapes", phase, env.namespace, name) +} diff --git a/go/core/test/upgrade/upgrade_test.go b/go/core/test/upgrade/upgrade_test.go index 4cfd1c7864..59b43fab27 100644 --- a/go/core/test/upgrade/upgrade_test.go +++ b/go/core/test/upgrade/upgrade_test.go @@ -142,6 +142,12 @@ func TestUpgrade(t *testing.T) { return } + v1References := "upgrade-refs-" + seed[len(seed)-8:] + v1Seeded := t.Run("Given v1.0.x reference shapes, when the previous release reconciles an edit, assert that they resolve", func(t *testing.T) { + seedV1References(t, env, v1References) + requireV1ReferencesReconciled(t, env, v1References, "previous release") + }) + if !t.Run("upgrade to current build", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) defer cancel() @@ -186,6 +192,12 @@ func TestUpgrade(t *testing.T) { return } + if v1Seeded { + t.Run("Given v1.0.x reference shapes from the previous release, when the upgraded controller reconciles an edit, assert that they resolve", func(t *testing.T) { + requireV1ReferencesReconciled(t, env, v1References, "upgraded") + }) + } + t.Run("verify seeded data survived migrations", func(t *testing.T) { pgPostState := pgMigrationState(t, env) require.GreaterOrEqual(t, pgPostState.version, pgBaselineState.version, @@ -251,6 +263,12 @@ func TestUpgrade(t *testing.T) { return } + if v1Seeded { + t.Run("Given v1.0.x reference shapes the upgraded controller reconciled, when the rolled-back controller reconciles an edit, assert that they resolve", func(t *testing.T) { + requireV1ReferencesReconciled(t, env, v1References, "rolled back") + }) + } + if !t.Run("previous release invokes against target schema", func(t *testing.T) { runInvokeE2E(t, env, previousGoDir, "application rollback") }) { diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 4ff456d011..50093f2e6d 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -115,6 +115,19 @@ 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 and requires kind. + 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: maxLength: 253 minLength: 1 @@ -130,6 +143,8 @@ spec: x-kubernetes-validations: - message: name must not be empty rule: has(self.name) && self.name != '' + - message: group requires kind + rule: '!has(self.group) || has(self.kind)' outputSchema: description: |- OutputSchema constrains successful terminal output when this template is @@ -398,47 +413,47 @@ spec: Tools is omitted or empty. type: boolean server: - description: TypedObjectReference contains enough - information to let you locate the typed referenced - object + description: MCPServerReference identifies an MCP + server; an omitted namespace means the owner's + namespace. 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 + 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. + 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..c15e0970f6 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml @@ -51,6 +51,18 @@ 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 and requires kind. + 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: maxLength: 253 minLength: 1 @@ -66,6 +78,8 @@ spec: x-kubernetes-validations: - message: name must not be empty rule: has(self.name) && self.name != '' + - message: group requires kind + rule: '!has(self.group) || has(self.kind)' outputSchema: description: |- OutputSchema constrains successful terminal output when this template is @@ -330,43 +344,46 @@ spec: Tools is omitted or empty. type: boolean server: - description: TypedObjectReference contains enough information - to let you locate the typed referenced object + description: MCPServerReference identifies an MCP server; + an omitted namespace means the owner's namespace. 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 + 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. + 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..4649bb3842 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml @@ -117,6 +117,19 @@ 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 and requires kind. + 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: maxLength: 253 minLength: 1 @@ -132,6 +145,8 @@ spec: x-kubernetes-validations: - message: name must not be empty rule: has(self.name) && self.name != '' + - message: group requires kind + rule: '!has(self.group) || has(self.kind)' promptTemplate: description: |- PromptTemplate replaces the runtime's default summarization prompt. It @@ -167,6 +182,19 @@ 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 and requires kind. + 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: maxLength: 253 minLength: 1 @@ -179,6 +207,9 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: group requires kind + rule: '!has(self.group) || has(self.kind)' ttlDays: description: TTLDays controls how many days a stored memory entry remains valid. 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. From 26d670e942c89adc00c5f5b5fe4fc67746ccf575 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Mon, 9 Nov 2026 11:59:49 -0800 Subject: [PATCH 2/4] fix(test): keep the upgrade test's manifest path free of commas Signed-off-by: Cody Hartsook --- go/core/test/upgrade/references_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/go/core/test/upgrade/references_test.go b/go/core/test/upgrade/references_test.go index 1910912261..159a72a362 100644 --- a/go/core/test/upgrade/references_test.go +++ b/go/core/test/upgrade/references_test.go @@ -67,7 +67,11 @@ func seedV1References(t *testing.T, env upgradeEnv, name string) { kubectl(t, env, time.Minute, "get", "modelconfig", v1ReferenceModelConfig, "-n", env.namespace) kubectl(t, env, time.Minute, "get", "remotemcpserver", v1ReferenceMCPServer, "-n", env.namespace) manifest := fmt.Sprintf(v1ReferenceManifest, name, env.namespace, v1ReferenceModelConfig, v1ReferenceMCPServer) - path := filepath.Join(t.TempDir(), "ga-references.yaml") + // kubectl apply -f splits on commas, and t.TempDir() embeds the subtest name, which has them. + dir, err := os.MkdirTemp("", "upgrade-refs-") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "v1-references.yaml") require.NoError(t, os.WriteFile(path, []byte(manifest), 0o600)) kubectl(t, env, time.Minute, "apply", "-f", path) } From daf10d2e4df5d0249ecdd37be8d845c8c604083d Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Mon, 9 Nov 2026 11:59:49 -0800 Subject: [PATCH 3/4] fix(ui): keep group and kind on references through a save Signed-off-by: Cody Hartsook --- ui/src/api/domain/agentTemplates.ts | 10 ++++++++-- .../agentTemplateDraft.test.ts | 15 ++++++++++++++- .../agent-template-form/agentTemplateDraft.ts | 15 ++++++++++----- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/ui/src/api/domain/agentTemplates.ts b/ui/src/api/domain/agentTemplates.ts index 29257121fc..b0a0bfd4a2 100644 --- a/ui/src/api/domain/agentTemplates.ts +++ b/ui/src/api/domain/agentTemplates.ts @@ -6,8 +6,13 @@ export interface AgentTemplateLocalRef { name: string; } -/** A model or MCP reference, local when namespace is omitted. */ +/** + * A model or MCP reference, local when namespace is omitted. Omitted `group` and + * `kind` mean the default kind; the form keeps them so an edit cannot repoint a reference. + */ export interface ObjectReference { + group?: string; + kind?: string; name: string; namespace?: string; } @@ -31,7 +36,8 @@ export interface ConfigMapKeyRef { * non-empty list limits the binding to those names. */ export interface McpToolBinding { - server: ObjectReference & { kind: "RemoteMCPServer" }; + /** The API server stores `kind: RemoteMCPServer` when it is omitted. */ + server: ObjectReference; tools?: string[]; requireApproval?: boolean; } diff --git a/ui/src/components/agent-template-form/agentTemplateDraft.test.ts b/ui/src/components/agent-template-form/agentTemplateDraft.test.ts index 3b23406ae2..4556806894 100644 --- a/ui/src/components/agent-template-form/agentTemplateDraft.test.ts +++ b/ui/src/components/agent-template-form/agentTemplateDraft.test.ts @@ -333,7 +333,20 @@ describe("cross-namespace references", () => { }; const draft = draftFromSpec(spec, "team-a"); expect(draft.modelConfig).toEqual({ name: "default", namespace: "shared" }); - expect(draft.mcpTools[0].serverRef).toEqual({ name: "tools", namespace: "shared" }); + expect(draft.mcpTools[0].serverRef).toEqual({ kind: "RemoteMCPServer", name: "tools", namespace: "shared" }); + const saved = specFromDraft(draft, spec); + expect(saved.modelConfig).toEqual(spec.modelConfig); + expect(saved.tools).toEqual(spec.tools); + }); + + // Dropping group or kind would point the reference at a same-named default object. + it("keeps the group and kind of model and MCP references through an edit", () => { + const spec = { + modelConfig: { group: "example.com", kind: "Foo", name: "default" }, + tools: [{ mcp: { server: { group: "example.com", kind: "Foo", name: "tools" } } }], + }; + const draft = draftFromSpec(spec, "team-a"); + draft.description = "edited"; const saved = specFromDraft(draft, spec); expect(saved.modelConfig).toEqual(spec.modelConfig); expect(saved.tools).toEqual(spec.tools); diff --git a/ui/src/components/agent-template-form/agentTemplateDraft.ts b/ui/src/components/agent-template-form/agentTemplateDraft.ts index c090370beb..998c9f70a2 100644 --- a/ui/src/components/agent-template-form/agentTemplateDraft.ts +++ b/ui/src/components/agent-template-form/agentTemplateDraft.ts @@ -134,7 +134,7 @@ export function draftFromSpec(spec: AgentTemplateSpec, namespace: string): Agent mcpTools: tools .filter((binding) => binding.mcp) .map((binding) => ({ - serverRef: { name: binding.mcp?.server.name ?? "", namespace: binding.mcp?.server.namespace }, + serverRef: { ...binding.mcp?.server, name: binding.mcp?.server.name ?? "" }, tools: [...(binding.mcp?.tools ?? [])], requireApproval: binding.mcp?.requireApproval, })), @@ -167,8 +167,8 @@ export function specFromDraft( .filter((tool) => tool.serverRef.name.trim() !== "") .map((tool) => ({ mcp: { - // The form only selects RemoteMCPServers. - server: { kind: "RemoteMCPServer" as const, ...normalizedReference(tool.serverRef, draft.namespace) }, + // A new pick is a RemoteMCPServer; a loaded reference keeps its own group and kind. + server: { kind: "RemoteMCPServer", ...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. ...(tool.requireApproval ? { requireApproval: true } : {}), @@ -313,11 +313,16 @@ export function draftProblems( return problems; } -/** Keep local manifests concise while preserving shared resource identity. */ +/** Keep local manifests concise while preserving shared resource identity, group and kind. */ function normalizedReference(ref: ObjectReference, ownerNamespace: string): ObjectReference { const name = ref.name.trim(); const namespace = ref.namespace?.trim(); - return !namespace || namespace === ownerNamespace ? { name } : { name, namespace }; + return { + ...(ref.group ? { group: ref.group } : {}), + ...(ref.kind ? { kind: ref.kind } : {}), + name, + ...(namespace && namespace !== ownerNamespace ? { namespace } : {}), + }; } /** Parses only the object-valued JSON shape the public API accepts. */ From 9ee902812e097a5fc7101331e7fe706f57e80ec7 Mon Sep 17 00:00:00 2001 From: Cody Hartsook Date: Tue, 10 Nov 2026 07:51:40 -0800 Subject: [PATCH 4/4] feat(controller): let embedding controllers register reference kinds Signed-off-by: Cody Hartsook --- .../cross-namespace-references.md | 6 +- go/core/internal/controller/collections.go | 4 +- .../internal/controller/collections_test.go | 72 +++++++++++++++++++ go/core/internal/controller/runtime.go | 24 ++++++- .../internal/translator/adkconfig/builder.go | 6 +- .../internal/translator/claude/compiler.go | 13 ++-- go/core/internal/translator/codex/compiler.go | 17 ++--- go/core/internal/translator/collections.go | 3 + go/core/internal/translator/compiler_test.go | 56 ++++++++++++++- go/core/internal/translator/references.go | 24 ++++++- go/core/pkg/app/app.go | 8 ++- go/core/pkg/referencekind/referencekind.go | 17 +++++ 12 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 go/core/pkg/referencekind/referencekind.go diff --git a/docs/architecture/cross-namespace-references.md b/docs/architecture/cross-namespace-references.md index e0bf7eeee1..dfd0fbc14b 100644 --- a/docs/architecture/cross-namespace-references.md +++ b/docs/architecture/cross-namespace-references.md @@ -4,8 +4,10 @@ Agents can use ModelConfigs and RemoteMCPServers in other namespaces. A referenc 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`. +Omitted values select ModelConfig or RemoteMCPServer. An embedding controller +can register more kinds with `app.Options.ReferenceKinds`, projecting objects +to those two. 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 diff --git a/go/core/internal/controller/collections.go b/go/core/internal/controller/collections.go index c89307fc19..7318ef4f2c 100644 --- a/go/core/internal/controller/collections.go +++ b/go/core/internal/controller/collections.go @@ -13,6 +13,7 @@ import ( configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/translator" + "github.com/kagent-dev/kagent/go/core/pkg/referencekind" ) // Collections contains shared Kubernetes inputs and compiled Agent state. @@ -51,7 +52,7 @@ func (p AgentRuntimeObservation) ResourceName() string { // NewCollections creates the complete read-only input graph. An empty // watchNamespaces list watches all namespaces. -func NewCollections(client kube.Client, watchNamespaces []string, opts krt.OptionsBuilder, settings *configv1.RuntimeDefaultsConfig, images *configv1.HarnessImagesConfig) Collections { +func NewCollections(client kube.Client, watchNamespaces []string, opts krt.OptionsBuilder, settings *configv1.RuntimeDefaultsConfig, images *configv1.HarnessImagesConfig, kinds referencekind.Kinds) Collections { egressPolicies := typedCollection[*kagentv1alpha3.EgressPolicy](client, watchNamespaces, "EgressPolicies", opts) sandboxTemplates := typedCollection[*kagentv1alpha3.SandboxTemplate](client, watchNamespaces, "SandboxTemplates", opts) agents := typedCollection[*kagentv1alpha3.Agent](client, watchNamespaces, "Agents", opts) @@ -68,6 +69,7 @@ func NewCollections(client kube.Client, watchNamespaces []string, opts krt.Optio EgressPolicies: egressPolicies, Harnesses: harnesses, AgentTemplates: agentTemplates, ResolvedModelConfigs: resolvedModelConfigs, RemoteMCPServers: remoteMCPServers, ConfigMaps: configMaps, Secrets: secrets, WorkerPools: workerPools, + MCPServerKinds: kinds.MCPServers, ModelConfigKinds: kinds.ModelConfigs, } compilerCollections.Namespaces = newReferenceNamespaces(client, agents, compilerCollections, opts) reconciliations := newAgentReconciliations(agents, compilerCollections, agentRuntimeObservations, opts, settings, images) diff --git a/go/core/internal/controller/collections_test.go b/go/core/internal/controller/collections_test.go index fa53db5cb5..269743565c 100644 --- a/go/core/internal/controller/collections_test.go +++ b/go/core/internal/controller/collections_test.go @@ -14,10 +14,13 @@ import ( corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/translator" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" + "github.com/kagent-dev/kagent/go/core/pkg/referencekind" ) func TestReconciliationCollectionsCompileAndObserveRevision(t *testing.T) { @@ -206,6 +209,75 @@ func TestReconciliationReportsUnsupportedReferenceKind(t *testing.T) { }) } +func TestReconciliationCompilesRegisteredModelKind(t *testing.T) { + t.Run("Given a registered model kind projecting a passthrough model, when a template references it, assert that the revision targets the projected model", func(t *testing.T) { + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + opts := krt.NewOptionsBuilder(stop, "registered-kind", nil) + kind := schema.GroupKind{Group: "example.com", Kind: "ExampleModelConfig"} + template := &kagentv1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant"}, Spec: kagentv1alpha3.AgentTemplateSpec{ + SystemPrompt: "help", ModelConfig: &kagentv1alpha3.ModelConfigReference{Group: new(kind.Group), Kind: new(kind.Kind), Name: "model"}, + }} + runtimeHarness := harness("team-a", "kagent", nil) + runtimeHarness.Spec.Kagent = &kagentv1alpha3.KagentHarness{} + runtimeHarness.Spec.Workload.Image = new("example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + runtimeHarness.Spec.Substrate.WorkerPoolRef.Name = "default" + mock := krttest.NewMock(t, []any{template, runtimeHarness, &atev1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "default"}}}) + configMaps, secrets := krttest.GetMockCollection[*corev1.ConfigMap](mock), krttest.GetMockCollection[*corev1.Secret](mock) + projected := krt.NewStaticCollection(nil, []*kagentv1alpha3.ModelConfig{{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model"}, + Spec: kagentv1alpha3.ModelConfigSpec{Provider: kagentv1alpha3.ModelProviderOpenAI, Model: "gpt-5", TokenPassthrough: new(true)}, + }}, opts.WithName("ExampleModelConfigs")...) + 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), + ConfigMaps: configMaps, Secrets: secrets, WorkerPools: krttest.GetMockCollection[*atev1.WorkerPool](mock), + ModelConfigKinds: map[schema.GroupKind]krt.Collection[*kagentv1alpha3.ModelConfig]{kind: projected}, + }, 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.CompilationFailure) + require.Equal(t, []egresspolicy.GatewayTarget{ + {Origin: "https://api.openai.com:443", Header: "authorization", Prefix: "Bearer "}, + }, state.Target.Revision.TokenPassthroughTargets) + }) +} + +func TestValidateReferenceKinds(t *testing.T) { + example := schema.GroupKind{Group: "example.com", Kind: "ExampleMCPServer"} + for _, tc := range []struct { + name string + kinds referencekind.Kinds + wantErr string + }{ + { + name: "Given an extra MCP server kind, when startup validates it, assert that it is accepted", + kinds: referencekind.Kinds{MCPServers: map[schema.GroupKind]krt.Collection[*kagentv1alpha3.RemoteMCPServer]{example: nil}}, + }, + { + name: "Given a RemoteMCPServer key, when startup validates it, assert that it is rejected", + kinds: referencekind.Kinds{MCPServers: map[schema.GroupKind]krt.Collection[*kagentv1alpha3.RemoteMCPServer]{kagentv1alpha3.RemoteMCPServerGroupKind: nil}}, + wantErr: "reference kind RemoteMCPServer.api.kagent.dev is built in", + }, + { + name: "Given a ModelConfig key, when startup validates it, assert that it is rejected", + kinds: referencekind.Kinds{ModelConfigs: map[schema.GroupKind]krt.Collection[*kagentv1alpha3.ModelConfig]{kagentv1alpha3.ModelConfigGroupKind: nil}}, + wantErr: "reference kind ModelConfig.api.kagent.dev is built in", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateReferenceKinds(tc.kinds) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, tc.wantErr) + }) + } +} + func TestReconciliationWorkerPoolSandboxClass(t *testing.T) { for _, harnessType := range []translator.HarnessType{ translator.HarnessTypeKagent, translator.HarnessTypeCodex, translator.HarnessTypeClaude, translator.HarnessTypeBYO, diff --git a/go/core/internal/controller/runtime.go b/go/core/internal/controller/runtime.go index 5250425f98..5c131ed63f 100644 --- a/go/core/internal/controller/runtime.go +++ b/go/core/internal/controller/runtime.go @@ -9,7 +9,9 @@ import ( "k8s.io/client-go/rest" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" + "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/controller/apiclient" + "github.com/kagent-dev/kagent/go/core/pkg/referencekind" ) // Runtime owns the Kubernetes client and common options shared by the KRT @@ -23,16 +25,34 @@ type Runtime struct { // NewRuntime creates the shared KRT client and collection graph. Handlers are // deliberately registered separately at the eventual application boundary. -func NewRuntime(config *rest.Config, watchNamespaces []string, stop <-chan struct{}, settings *configv1.RuntimeDefaultsConfig, images *configv1.HarnessImagesConfig) (*Runtime, error) { +func NewRuntime(config *rest.Config, watchNamespaces []string, stop <-chan struct{}, settings *configv1.RuntimeDefaultsConfig, images *configv1.HarnessImagesConfig, referenceKinds func(kube.Client, []string, krt.OptionsBuilder) referencekind.Kinds) (*Runtime, error) { client, err := apiclient.New(config) if err != nil { return nil, fmt.Errorf("create KRT Kubernetes client: %w", err) } options := krt.NewOptionsBuilder(stop, "kagent", krt.GlobalDebugHandler) - collections := NewCollections(client, watchNamespaces, options, settings, images) + var kinds referencekind.Kinds + if referenceKinds != nil { + kinds = referenceKinds(client, watchNamespaces, options) + if err := validateReferenceKinds(kinds); err != nil { + return nil, err + } + } + collections := NewCollections(client, watchNamespaces, options, settings, images, kinds) return &Runtime{Client: client, Options: options, Collections: collections}, nil } +// validateReferenceKinds keeps registered kinds from replacing built-in ones. +func validateReferenceKinds(kinds referencekind.Kinds) error { + if _, found := kinds.MCPServers[v1alpha3.RemoteMCPServerGroupKind]; found { + return fmt.Errorf("reference kind %s is built in", v1alpha3.RemoteMCPServerGroupKind) + } + if _, found := kinds.ModelConfigs[v1alpha3.ModelConfigGroupKind]; found { + return fmt.Errorf("reference kind %s is built in", v1alpha3.ModelConfigGroupKind) + } + return nil +} + // Start starts every informer registered by the KRT collections and keeps // them alive until the application shuts down. The API uses installed CRDs // directly, so it does not start Istio's cluster-wide delayed-CRD watcher. diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index a5c32532bf..629505f30f 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -185,7 +185,8 @@ func (c *Builder) BuildProvenance(ctx context.Context, harness *translator.Harne } for _, resolved := range models { model := resolved.Config - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "ModelConfig", model.Namespace+"/"+model.Name, model.UID, model.Generation, model.Spec)) + kind := translator.SourceKind(model, "ModelConfig") + entries = append(entries, objectProvenance(kind.GroupVersion().String(), kind.Kind, model.Namespace+"/"+model.Name, model.UID, model.Generation, model.Spec)) } for name := range configMaps { fetched := krt.FetchOne(c.ctx, c.collections.ConfigMaps, krt.FilterObjectName(types.NamespacedName{Namespace: harness.Namespace, Name: name})) @@ -197,7 +198,8 @@ func (c *Builder) BuildProvenance(ctx context.Context, harness *translator.Harne } for _, tool := range compiled.MCPTools { server := tool.Server - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "RemoteMCPServer", server.Namespace+"/"+server.Name, server.UID, server.Generation, server.Spec)) + kind := translator.SourceKind(server, "RemoteMCPServer") + entries = append(entries, objectProvenance(kind.GroupVersion().String(), kind.Kind, server.Namespace+"/"+server.Name, server.UID, server.Generation, server.Spec)) for _, header := range server.Spec.HeadersFrom { if header.ValueFrom == nil || header.ValueFrom.Type != v1alpha3.ConfigMapValueSource { continue diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index 88edd984fc..7f4782b645 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -15,6 +15,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" "istio.io/istio/pkg/kube/krt" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -372,23 +373,23 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *translator.Harnes entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "Harness", harness.Name, harness.Source.UID, harness.Source.Generation, harness.Spec)) configMaps := map[types.NamespacedName]struct{}{} objects := map[string]struct{}{} - addObject := func(kind, name string, uid types.UID, generation int64, content any) { - identity := kind + "\x00" + name + addObject := func(kind schema.GroupVersionKind, name string, uid types.UID, generation int64, content any) { + identity := kind.String() + "\x00" + name if _, exists := objects[identity]; exists { return } objects[identity] = struct{}{} - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), kind, name, uid, generation, content)) + entries = append(entries, objectProvenance(kind.GroupVersion().String(), kind.Kind, name, uid, generation, content)) } var addAgent func(*translator.AgentInput) addAgent = func(agent *translator.AgentInput) { template, model := agent.Template, agent.ResolvedModelConfig.Config if template.Source != nil { - addObject("AgentTemplate", template.Name, template.Source.UID, template.Source.Generation, template.Spec) + addObject(v1alpha3.GroupVersion.WithKind("AgentTemplate"), template.Name, template.Source.UID, template.Source.Generation, template.Spec) } // Qualify shared models so same-name resources in different namespaces // remain distinct when deduplicating provenance and computing revisions. - addObject("ModelConfig", model.Namespace+"/"+model.Name, model.UID, model.Generation, model.Spec) + addObject(translator.SourceKind(model, "ModelConfig"), model.Namespace+"/"+model.Name, model.UID, model.Generation, model.Spec) if template.Spec.SystemPromptFrom != nil { configMaps[types.NamespacedName{Namespace: harness.Namespace, Name: template.Spec.SystemPromptFrom.Name}] = struct{}{} } @@ -404,7 +405,7 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *translator.Harnes server := tool.Server if server != nil { // Shared MCP servers also need namespace-qualified provenance identities. - addObject("RemoteMCPServer", server.Namespace+"/"+server.Name, server.UID, server.Generation, server.Spec) + addObject(translator.SourceKind(server, "RemoteMCPServer"), server.Namespace+"/"+server.Name, server.UID, server.Generation, server.Spec) for _, header := range server.Spec.HeadersFrom { if header.ValueFrom != nil && header.ValueFrom.Type == v1alpha3.ConfigMapValueSource { configMaps[types.NamespacedName{Namespace: server.Namespace, Name: header.ValueFrom.Name}] = struct{}{} diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index 1b12dc0183..89271dcbd1 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -14,6 +14,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" "istio.io/istio/pkg/kube/krt" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -317,11 +318,11 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *translator.Harnes // is recorded by the enclosing Agent provenance below. entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "Harness", input.Harness.Name, input.Harness.Source.UID, input.Harness.Source.Generation, input.Harness.Spec)) seenObjects := map[string]struct{}{} - addObject := func(kind, name string, uid types.UID, generation int64, value any) { - identity := kind + "\x00" + name + addObject := func(kind schema.GroupVersionKind, name string, uid types.UID, generation int64, value any) { + identity := kind.String() + "\x00" + name if _, ok := seenObjects[identity]; !ok { seenObjects[identity] = struct{}{} - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), kind, name, uid, generation, value)) + entries = append(entries, objectProvenance(kind.GroupVersion().String(), kind.Kind, name, uid, generation, value)) } } configMaps := map[types.NamespacedName]struct{}{} @@ -329,9 +330,9 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *translator.Harnes addAgent = func(agent *translator.AgentInput) { model := agent.ResolvedModelConfig.Config if source := agent.Template.Source; source != nil { - addObject("AgentTemplate", source.Name, source.UID, source.Generation, agent.Template.Spec) + addObject(v1alpha3.GroupVersion.WithKind("AgentTemplate"), source.Name, source.UID, source.Generation, agent.Template.Spec) } - addObject("ModelConfig", model.Namespace+"/"+model.Name, model.UID, model.Generation, model.Spec) + addObject(translator.SourceKind(model, "ModelConfig"), model.Namespace+"/"+model.Name, model.UID, model.Generation, model.Spec) if agent.Template.Spec.SystemPromptFrom != nil { configMaps[types.NamespacedName{Namespace: input.Harness.Namespace, Name: agent.Template.Spec.SystemPromptFrom.Name}] = struct{}{} } @@ -342,11 +343,7 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *translator.Harnes } for _, tool := range agent.MCPTools { if tool.Server != nil { - identity := "RemoteMCPServer\x00" + tool.Server.Namespace + "/" + tool.Server.Name - if _, ok := seenObjects[identity]; !ok { - seenObjects[identity] = struct{}{} - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "RemoteMCPServer", tool.Server.Namespace+"/"+tool.Server.Name, tool.Server.UID, tool.Server.Generation, tool.Server.Spec)) - } + addObject(translator.SourceKind(tool.Server, "RemoteMCPServer"), tool.Server.Namespace+"/"+tool.Server.Name, tool.Server.UID, tool.Server.Generation, tool.Server.Spec) for _, header := range tool.Server.Spec.HeadersFrom { if header.ValueFrom != nil && header.ValueFrom.Type == v1alpha3.ConfigMapValueSource { configMaps[types.NamespacedName{Namespace: tool.Server.Namespace, Name: header.ValueFrom.Name}] = struct{}{} diff --git a/go/core/internal/translator/collections.go b/go/core/internal/translator/collections.go index e24801759f..ee180a2ff1 100644 --- a/go/core/internal/translator/collections.go +++ b/go/core/internal/translator/collections.go @@ -4,6 +4,7 @@ import ( atev1 "go.ate.dev/substrate/api/infra/k8s/ate/v1" "istio.io/istio/pkg/kube/krt" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/kagent-dev/kagent/go/api/v1alpha3" ) @@ -20,4 +21,6 @@ type Collections struct { ConfigMaps krt.Collection[*corev1.ConfigMap] Secrets krt.Collection[*corev1.Secret] WorkerPools krt.Collection[*atev1.WorkerPool] + MCPServerKinds map[schema.GroupKind]krt.Collection[*v1alpha3.RemoteMCPServer] + ModelConfigKinds map[schema.GroupKind]krt.Collection[*v1alpha3.ModelConfig] } diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index d0aae8bfee..5df1e2fd4a 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -15,6 +15,8 @@ import ( corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" @@ -226,9 +228,23 @@ func defaultWorkerPool() *atev1.WorkerPool { return &atev1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "default"}} } +var ( + exampleMCPServerKind = schema.GroupKind{Group: "example.com", Kind: "ExampleMCPServer"} + exampleModelConfigKind = schema.GroupKind{Group: "example.com", Kind: "ExampleModelConfig"} +) + func mockCollections(t *testing.T, objects ...any) translator.Collections { t.Helper() - mock := krttest.NewMock(t, objects) + // Objects whose TypeMeta names an example.com kind back that registered kind. + var builtIn, projected []any + for _, object := range objects { + if typed, ok := object.(runtime.Object); ok && typed.GetObjectKind().GroupVersionKind().Group == exampleMCPServerKind.Group { + projected = append(projected, object) + } else { + builtIn = append(builtIn, object) + } + } + mock := krttest.NewMock(t, builtIn) collections := translator.Collections{ Namespaces: krttest.GetMockCollection[translator.NamespaceObservation](mock), EgressPolicies: krttest.GetMockCollection[*v1alpha3.EgressPolicy](mock), @@ -248,6 +264,13 @@ func mockCollections(t *testing.T, objects ...any) translator.Collections { } resolvedMock := krttest.NewMock(t, resolved) collections.ResolvedModelConfigs = krttest.GetMockCollection[translator.ResolvedModelConfig](resolvedMock) + projectedMock := krttest.NewMock(t, projected) + collections.MCPServerKinds = map[schema.GroupKind]krt.Collection[*v1alpha3.RemoteMCPServer]{ + exampleMCPServerKind: krttest.GetMockCollection[*v1alpha3.RemoteMCPServer](projectedMock), + } + collections.ModelConfigKinds = map[schema.GroupKind]krt.Collection[*v1alpha3.ModelConfig]{ + exampleModelConfigKind: krttest.GetMockCollection[*v1alpha3.ModelConfig](projectedMock), + } return collections } @@ -1064,6 +1087,37 @@ func TestCompileAgentResolvesReferenceKinds(t *testing.T) { 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)) }) + // Projections share the built-in objects' names, so only the kind tells them apart. + projectedServer := remoteMCPServer("search", "https://tools.example.com/mcp") + projectedServer.TypeMeta = metav1.TypeMeta{APIVersion: "example.com/v1", Kind: exampleMCPServerKind.Kind} + projectedServer.Spec.TokenPassthrough = new(true) + projectedModel := modelConfig() + projectedModel.TypeMeta = metav1.TypeMeta{APIVersion: "example.com/v1", Kind: exampleModelConfigKind.Kind} + projectedModel.Spec.TokenPassthrough, projectedModel.Spec.OpenAI = new(true), &v1alpha3.OpenAIConfig{BaseURL: "https://models.example.com/v1"} + type provenanceInput struct{ APIVersion, Kind, Name string } + for _, tc := range []struct { + kind, name, origin string + projected any + mutate func(*v1alpha3.AgentTemplate) + }{ + {exampleMCPServerKind.Kind, "test/search", "https://tools.example.com:443", projectedServer, func(template *v1alpha3.AgentTemplate) { + template.Spec.Tools[0].MCP.Server.Group, template.Spec.Tools[0].MCP.Server.Kind = new(exampleMCPServerKind.Group), new(exampleMCPServerKind.Kind) + }}, + {exampleModelConfigKind.Kind, "test/default-model", "https://models.example.com:443", projectedModel, func(template *v1alpha3.AgentTemplate) { + template.Spec.ModelConfig.Group, template.Spec.ModelConfig.Kind = new(exampleModelConfigKind.Group), new(exampleModelConfigKind.Kind) + }}, + } { + t.Run("Given a registered "+tc.kind+" projecting a passthrough destination, when a template names it, assert that the agent targets it and records its kind", func(t *testing.T) { + harness, template := fixture() + tc.mutate(template) + result, err := compiler(t, modelConfig(), remoteMCPServer("search", "https://search.example.com/mcp"), harness, tc.projected).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + require.Equal(t, []egresspolicy.GatewayTarget{{Origin: tc.origin, Header: "authorization", Prefix: "Bearer "}}, result.TokenPassthroughTargets) + var provenance struct{ Inputs []provenanceInput } + require.NoError(t, json.Unmarshal(result.Provenance, &provenance)) + require.Contains(t, provenance.Inputs, provenanceInput{"example.com/v1", tc.kind, tc.name}) + }) + } for _, tc := range []struct { field, want string mutate func(*v1alpha3.Harness, *v1alpha3.AgentTemplate) diff --git a/go/core/internal/translator/references.go b/go/core/internal/translator/references.go index fe77c5b66d..7e23bfac3b 100644 --- a/go/core/internal/translator/references.go +++ b/go/core/internal/translator/references.go @@ -7,6 +7,8 @@ import ( "istio.io/istio/pkg/kube/krt" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -67,12 +69,20 @@ func ResolveModelReference(ctx krt.HandlerContext, collections Collections, sour } // fetchModel and fetchMCPServer hold the one kind switch per reference type. -// A nil object means not found; other kinds fail with ErrInvalidKind. +// A nil object means not found; unregistered 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: + if models, ok := collections.ModelConfigKinds[kind]; ok { + model := krt.FetchOne(ctx, models, krt.FilterObjectName(key)) + if model == nil { + return key, nil, nil + } + resolved, err := ResolveModelConfig(ctx, collections, *model) + return key, resolved, err + } return key, nil, fmt.Errorf("resolve model %q: %w %s", key, ErrInvalidKind, kind) } } @@ -82,10 +92,22 @@ func fetchMCPServer(ctx krt.HandlerContext, collections Collections, source stri case v1alpha3.RemoteMCPServerGroupKind: return key, ptr.Deref(krt.FetchOne(ctx, collections.RemoteMCPServers, krt.FilterObjectName(key)), nil), nil default: + if servers, ok := collections.MCPServerKinds[kind]; ok { + return key, ptr.Deref(krt.FetchOne(ctx, servers, krt.FilterObjectName(key)), nil), nil + } return key, nil, fmt.Errorf("resolve MCP server %q: %w %s", key, ErrInvalidKind, kind) } } +// SourceKind returns a referenced object's kind: the TypeMeta a registered +// kind's projection sets, or the built-in kind. +func SourceKind(object runtime.Object, builtin string) schema.GroupVersionKind { + if kind := object.GetObjectKind().GroupVersionKind(); !kind.Empty() { + return kind + } + return v1alpha3.GroupVersion.WithKind(builtin) +} + // 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/pkg/app/app.go b/go/core/pkg/app/app.go index d992c9a2a9..61116a8029 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -31,6 +31,8 @@ import ( "golang.org/x/sync/errgroup" "google.golang.org/grpc" "google.golang.org/protobuf/proto" + "istio.io/istio/pkg/kube" + "istio.io/istio/pkg/kube/krt" corev1 "k8s.io/api/core/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -76,6 +78,7 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" "github.com/kagent-dev/kagent/go/core/pkg/migrations" "github.com/kagent-dev/kagent/go/core/pkg/postgres" + "github.com/kagent-dev/kagent/go/core/pkg/referencekind" "github.com/kagent-dev/kagent/go/pkg/logging" "github.com/kagent-dev/kagent/go/pkg/telemetry" ) @@ -135,6 +138,9 @@ type Options struct { // EgressPolicyCompilers bind tokenPassthrough targets; with any registered, // all must be bound. Authenticator must not forward the caller token. EgressPolicyCompilers []egresspolicy.Compiler + // ReferenceKinds adds kinds model and MCP server references may name; Run fails on a built-in key. + // A callback, as collections need core's client and krt options, which exist only inside Run. + ReferenceKinds func(kube.Client, []string, krt.OptionsBuilder) referencekind.Kinds } // resolve substitutes core's defaults for whichever components the caller left @@ -297,7 +303,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er if err != nil { return fmt.Errorf("create controller manager: %w", err) } - runtime, err := controller.NewRuntime(kubeConfig, watchNamespaces, ctx.Done(), cfg.GetRuntimeDefaults(), cfg.GetHarnessImages()) + runtime, err := controller.NewRuntime(kubeConfig, watchNamespaces, ctx.Done(), cfg.GetRuntimeDefaults(), cfg.GetHarnessImages(), opts.ReferenceKinds) if err != nil { return err } diff --git a/go/core/pkg/referencekind/referencekind.go b/go/core/pkg/referencekind/referencekind.go new file mode 100644 index 0000000000..1c79d626aa --- /dev/null +++ b/go/core/pkg/referencekind/referencekind.go @@ -0,0 +1,17 @@ +// Package referencekind lets an embedding controller add kinds that model and +// MCP server references may name. +package referencekind + +import ( + "istio.io/istio/pkg/kube/krt" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// Kinds maps a reference's group and kind to its objects, projected to the built-in type. +// A projection sets TypeMeta to the source kind and copies its ObjectMeta and status. +type Kinds struct { + MCPServers map[schema.GroupKind]krt.Collection[*v1alpha3.RemoteMCPServer] + ModelConfigs map[schema.GroupKind]krt.Collection[*v1alpha3.ModelConfig] +}