diff --git a/.gitignore b/.gitignore index d826ad2..cac4a27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .idea/ **/CLAUDE.local.md *__debug_bin* + +# macOS system files +.DS_Store +**/.DS_Store + diff --git a/kubechain/.gitignore b/kubechain/.gitignore index 0f47bba..7b557a4 100644 --- a/kubechain/.gitignore +++ b/kubechain/.gitignore @@ -28,3 +28,7 @@ go.work *.swp *.swo *~ + +# macOS system files +.DS_Store +**/.DS_Store diff --git a/kubechain/api/v1alpha1/agent_types.go b/kubechain/api/v1alpha1/agent_types.go index 66e22f1..ba0e8a2 100644 --- a/kubechain/api/v1alpha1/agent_types.go +++ b/kubechain/api/v1alpha1/agent_types.go @@ -18,6 +18,10 @@ type AgentSpec struct { // +optional MCPServers []LocalObjectReference `json:"mcpServers,omitempty"` + // HumanContactChannels is a list of ContactChannel resources that can be used for human interactions + // +optional + HumanContactChannels []LocalObjectReference `json:"humanContactChannels,omitempty"` + // System is the system prompt for the agent // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 @@ -51,6 +55,10 @@ type AgentStatus struct { // ValidMCPServers is the list of MCP servers that were successfully validated // +optional ValidMCPServers []ResolvedMCPServer `json:"validMCPServers,omitempty"` + + // ValidHumanContactChannels is the list of human contact channels that were successfully validated + // +optional + ValidHumanContactChannels []ResolvedContactChannel `json:"validHumanContactChannels,omitempty"` } type ResolvedTool struct { @@ -73,6 +81,16 @@ type ResolvedMCPServer struct { Tools []string `json:"tools,omitempty"` } +type ResolvedContactChannel struct { + // Name of the contact channel + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Type of the contact channel (e.g., "slack", "email") + // +kubebuilder:validation:Required + Type string `json:"type"` +} + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Ready",type="boolean",JSONPath=".status.ready" diff --git a/kubechain/api/v1alpha1/zz_generated.deepcopy.go b/kubechain/api/v1alpha1/zz_generated.deepcopy.go index 27482c6..87f6386 100644 --- a/kubechain/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubechain/api/v1alpha1/zz_generated.deepcopy.go @@ -128,6 +128,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = make([]LocalObjectReference, len(*in)) copy(*out, *in) } + if in.HumanContactChannels != nil { + in, out := &in.HumanContactChannels, &out.HumanContactChannels + *out = make([]LocalObjectReference, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -155,6 +160,11 @@ func (in *AgentStatus) DeepCopyInto(out *AgentStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ValidHumanContactChannels != nil { + in, out := &in.ValidHumanContactChannels, &out.ValidHumanContactChannels + *out = make([]ResolvedContactChannel, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentStatus. @@ -632,6 +642,21 @@ func (in *NameReference) DeepCopy() *NameReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedContactChannel) DeepCopyInto(out *ResolvedContactChannel) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedContactChannel. +func (in *ResolvedContactChannel) DeepCopy() *ResolvedContactChannel { + if in == nil { + return nil + } + out := new(ResolvedContactChannel) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResolvedMCPServer) DeepCopyInto(out *ResolvedMCPServer) { *out = *in diff --git a/kubechain/config/crd/bases/kubechain.humanlayer.dev_agents.yaml b/kubechain/config/crd/bases/kubechain.humanlayer.dev_agents.yaml index 432d424..bc81dab 100644 --- a/kubechain/config/crd/bases/kubechain.humanlayer.dev_agents.yaml +++ b/kubechain/config/crd/bases/kubechain.humanlayer.dev_agents.yaml @@ -50,6 +50,21 @@ spec: spec: description: AgentSpec defines the desired state of Agent properties: + humanContactChannels: + description: HumanContactChannels is a list of ContactChannel resources + that can be used for human interactions + items: + description: LocalObjectReference contains enough information to + locate the referenced resource in the same namespace + properties: + name: + description: Name of the referent + minLength: 1 + type: string + required: + - name + type: object + type: array llmRef: description: LLMRef references the LLM to use for this agent properties: @@ -114,6 +129,22 @@ spec: description: StatusDetail provides additional details about the current status type: string + validHumanContactChannels: + description: ValidHumanContactChannels is the list of human contact + channels that were successfully validated + items: + properties: + name: + description: Name of the contact channel + type: string + type: + description: Type of the contact channel (e.g., "slack", "email") + type: string + required: + - name + - type + type: object + type: array validMCPServers: description: ValidMCPServers is the list of MCP servers that were successfully validated diff --git a/kubechain/config/manager/kustomization.yaml b/kubechain/config/manager/kustomization.yaml index 9ab4348..ad9ca9d 100644 --- a/kubechain/config/manager/kustomization.yaml +++ b/kubechain/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: controller - newTag: "202504041032" + newTag: "202504041316" diff --git a/kubechain/internal/controller/agent/agent_controller.go b/kubechain/internal/controller/agent/agent_controller.go index 8b11376..a01a98c 100644 --- a/kubechain/internal/controller/agent/agent_controller.go +++ b/kubechain/internal/controller/agent/agent_controller.go @@ -25,6 +25,7 @@ const ( // +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=llms,verbs=get;list;watch // +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=tools,verbs=get;list;watch // +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=mcpservers,verbs=get;list;watch +// +kubebuilder:rbac:groups=kubechain.humanlayer.dev,resources=contactchannels,verbs=get;list;watch // AgentReconciler reconciles a Agent object type AgentReconciler struct { @@ -121,6 +122,33 @@ func (r *AgentReconciler) validateMCPServers(ctx context.Context, agent *kubecha return validMCPServers, nil } +// validateHumanContactChannels checks if all referenced contact channels exist and are ready +func (r *AgentReconciler) validateHumanContactChannels(ctx context.Context, agent *kubechainv1alpha1.Agent) ([]kubechainv1alpha1.ResolvedContactChannel, error) { + validChannels := make([]kubechainv1alpha1.ResolvedContactChannel, 0, len(agent.Spec.HumanContactChannels)) + + for _, channelRef := range agent.Spec.HumanContactChannels { + channel := &kubechainv1alpha1.ContactChannel{} + err := r.Get(ctx, client.ObjectKey{ + Namespace: agent.Namespace, + Name: channelRef.Name, + }, channel) + if err != nil { + return validChannels, fmt.Errorf("failed to get ContactChannel %q: %w", channelRef.Name, err) + } + + if !channel.Status.Ready { + return validChannels, fmt.Errorf("ContactChannel %q is not ready", channelRef.Name) + } + + validChannels = append(validChannels, kubechainv1alpha1.ResolvedContactChannel{ + Name: channelRef.Name, + Type: string(channel.Spec.Type), + }) + } + + return validChannels, nil +} + // Reconcile validates the agent's LLM and Tool references func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -142,9 +170,10 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl r.recorder.Event(&agent, corev1.EventTypeNormal, "Initializing", "Starting validation") } - // Initialize empty valid tools and servers slices + // Initialize empty valid tools, servers, and human contact channels slices validTools := make([]kubechainv1alpha1.ResolvedTool, 0) validMCPServers := make([]kubechainv1alpha1.ResolvedMCPServer, 0) + validHumanContactChannels := make([]kubechainv1alpha1.ResolvedContactChannel, 0) // Validate LLM reference if err := r.validateLLM(ctx, &agent); err != nil { @@ -154,6 +183,7 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl statusUpdate.Status.StatusDetail = err.Error() statusUpdate.Status.ValidTools = validTools statusUpdate.Status.ValidMCPServers = validMCPServers + statusUpdate.Status.ValidHumanContactChannels = validHumanContactChannels r.recorder.Event(&agent, corev1.EventTypeWarning, "ValidationFailed", err.Error()) if updateErr := r.Status().Update(ctx, statusUpdate); updateErr != nil { logger.Error(updateErr, "Failed to update Agent status") @@ -171,6 +201,7 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl statusUpdate.Status.StatusDetail = err.Error() statusUpdate.Status.ValidTools = validTools statusUpdate.Status.ValidMCPServers = validMCPServers + statusUpdate.Status.ValidHumanContactChannels = validHumanContactChannels r.recorder.Event(&agent, corev1.EventTypeWarning, "ValidationFailed", err.Error()) if updateErr := r.Status().Update(ctx, statusUpdate); updateErr != nil { logger.Error(updateErr, "Failed to update Agent status") @@ -189,6 +220,27 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl statusUpdate.Status.StatusDetail = err.Error() statusUpdate.Status.ValidTools = validTools statusUpdate.Status.ValidMCPServers = validMCPServers + statusUpdate.Status.ValidHumanContactChannels = validHumanContactChannels + r.recorder.Event(&agent, corev1.EventTypeWarning, "ValidationFailed", err.Error()) + if updateErr := r.Status().Update(ctx, statusUpdate); updateErr != nil { + logger.Error(updateErr, "Failed to update Agent status") + return ctrl.Result{}, fmt.Errorf("failed to update agent status: %v", err) + } + return ctrl.Result{}, err // requeue + } + } + + // Validate HumanContactChannel references, if any + if len(agent.Spec.HumanContactChannels) > 0 { + validHumanContactChannels, err = r.validateHumanContactChannels(ctx, &agent) + if err != nil { + logger.Error(err, "HumanContactChannel validation failed") + statusUpdate.Status.Ready = false + statusUpdate.Status.Status = StatusError + statusUpdate.Status.StatusDetail = err.Error() + statusUpdate.Status.ValidTools = validTools + statusUpdate.Status.ValidMCPServers = validMCPServers + statusUpdate.Status.ValidHumanContactChannels = validHumanContactChannels r.recorder.Event(&agent, corev1.EventTypeWarning, "ValidationFailed", err.Error()) if updateErr := r.Status().Update(ctx, statusUpdate); updateErr != nil { logger.Error(updateErr, "Failed to update Agent status") @@ -204,6 +256,7 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl statusUpdate.Status.StatusDetail = "All dependencies validated successfully" statusUpdate.Status.ValidTools = validTools statusUpdate.Status.ValidMCPServers = validMCPServers + statusUpdate.Status.ValidHumanContactChannels = validHumanContactChannels r.recorder.Event(&agent, corev1.EventTypeNormal, "ValidationSucceeded", "All dependencies validated successfully") // Update status @@ -216,7 +269,8 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl "name", agent.Name, "ready", statusUpdate.Status.Ready, "status", statusUpdate.Status.Status, - "validTools", statusUpdate.Status.ValidTools) + "validTools", statusUpdate.Status.ValidTools, + "validHumanContactChannels", statusUpdate.Status.ValidHumanContactChannels) return ctrl.Result{}, nil } diff --git a/kubechain/internal/controller/agent/agent_controller_test.go b/kubechain/internal/controller/agent/agent_controller_test.go index 45c65d9..4562da0 100644 --- a/kubechain/internal/controller/agent/agent_controller_test.go +++ b/kubechain/internal/controller/agent/agent_controller_test.go @@ -19,6 +19,7 @@ var _ = Describe("Agent Controller", func() { const resourceName = "test-agent" const llmName = "test-llm" const toolName = "test-tool" + const humanContactChannelName = "test-humancontactchannel" ctx := context.Background() @@ -67,6 +68,33 @@ var _ = Describe("Agent Controller", func() { // Mark Tool as ready tool.Status.Ready = true Expect(k8sClient.Status().Update(ctx, tool)).To(Succeed()) + + // Create a test ContactChannel + contactChannel := &kubechainv1alpha1.ContactChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: humanContactChannelName, + Namespace: "default", + }, + Spec: kubechainv1alpha1.ContactChannelSpec{ + Type: kubechainv1alpha1.ContactChannelTypeEmail, + APIKeyFrom: kubechainv1alpha1.APIKeySource{ + SecretKeyRef: kubechainv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "api-key", + }, + }, + Email: &kubechainv1alpha1.EmailChannelConfig{ + Address: "test@example.com", + }, + }, + } + Expect(k8sClient.Create(ctx, contactChannel)).To(Succeed()) + + // Mark ContactChannel as ready + contactChannel.Status.Ready = true + contactChannel.Status.Status = "Ready" + contactChannel.Status.StatusDetail = "Ready for testing" + Expect(k8sClient.Status().Update(ctx, contactChannel)).To(Succeed()) }) AfterEach(func() { @@ -85,6 +113,13 @@ var _ = Describe("Agent Controller", func() { Expect(k8sClient.Delete(ctx, tool)).To(Succeed()) } + By("Cleanup the test ContactChannel") + contactChannel := &kubechainv1alpha1.ContactChannel{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: humanContactChannelName, Namespace: "default"}, contactChannel) + if err == nil { + Expect(k8sClient.Delete(ctx, contactChannel)).To(Succeed()) + } + By("Cleanup the test Agent") agent := &kubechainv1alpha1.Agent{} err = k8sClient.Get(ctx, typeNamespacedName, agent) @@ -96,10 +131,11 @@ var _ = Describe("Agent Controller", func() { It("should successfully validate an agent with valid dependencies", func() { By("creating the test agent") testAgent := &utils.TestScopedAgent{ - Name: resourceName, - SystemPrompt: "Test agent", - Tools: []string{toolName}, - LLM: llmName, + Name: resourceName, + SystemPrompt: "Test agent", + Tools: []string{toolName}, + LLM: llmName, + HumanContactChannels: []string{humanContactChannelName}, } testAgent.Setup(k8sClient) defer testAgent.Teardown() @@ -128,6 +164,10 @@ var _ = Describe("Agent Controller", func() { Kind: "Tool", Name: toolName, })) + Expect(updatedAgent.Status.ValidHumanContactChannels).To(ContainElement(kubechainv1alpha1.ResolvedContactChannel{ + Name: humanContactChannelName, + Type: "email", + })) By("checking that a success event was created") utils.ExpectRecorder(eventRecorder).ToEmitEventContaining("ValidationSucceeded") @@ -136,10 +176,11 @@ var _ = Describe("Agent Controller", func() { It("should fail validation with non-existent LLM", func() { By("creating the test agent with invalid LLM") testAgent := &utils.TestScopedAgent{ - Name: resourceName, - SystemPrompt: "Test agent", - Tools: []string{toolName}, - LLM: "nonexistent-llm", + Name: resourceName, + SystemPrompt: "Test agent", + Tools: []string{toolName}, + LLM: "nonexistent-llm", + HumanContactChannels: []string{humanContactChannelName}, } testAgent.Setup(k8sClient) defer testAgent.Teardown() @@ -173,10 +214,11 @@ var _ = Describe("Agent Controller", func() { It("should fail validation with non-existent Tool", func() { By("creating the test agent with invalid Tool") testAgent := &utils.TestScopedAgent{ - Name: resourceName, - SystemPrompt: "Test agent", - Tools: []string{"nonexistent-tool"}, - LLM: llmName, + Name: resourceName, + SystemPrompt: "Test agent", + Tools: []string{"nonexistent-tool"}, + LLM: llmName, + HumanContactChannels: []string{humanContactChannelName}, } testAgent.Setup(k8sClient) defer testAgent.Teardown() @@ -206,5 +248,43 @@ var _ = Describe("Agent Controller", func() { By("checking that a failure event was created") utils.ExpectRecorder(eventRecorder).ToEmitEventContaining("ValidationFailed") }) + + It("should fail validation with non-existent HumanContactChannel", func() { + By("creating the test agent with invalid HumanContactChannel") + testAgent := &utils.TestScopedAgent{ + Name: resourceName, + SystemPrompt: "Test agent", + Tools: []string{toolName}, + LLM: llmName, + HumanContactChannels: []string{"nonexistent-humancontactchannel"}, + } + testAgent.Setup(k8sClient) + defer testAgent.Teardown() + + By("reconciling the agent") + eventRecorder := record.NewFakeRecorder(10) + reconciler := &AgentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + recorder: eventRecorder, + } + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`"nonexistent-humancontactchannel" not found`)) + + By("checking the agent status") + updatedAgent := &kubechainv1alpha1.Agent{} + err = k8sClient.Get(ctx, typeNamespacedName, updatedAgent) + Expect(err).NotTo(HaveOccurred()) + Expect(updatedAgent.Status.Ready).To(BeFalse()) + Expect(updatedAgent.Status.Status).To(Equal("Error")) + Expect(updatedAgent.Status.StatusDetail).To(ContainSubstring(`"nonexistent-humancontactchannel" not found`)) + + By("checking that a failure event was created") + utils.ExpectRecorder(eventRecorder).ToEmitEventContaining("ValidationFailed") + }) }) }) diff --git a/kubechain/internal/humanlayerapi/.gitignore b/kubechain/internal/humanlayerapi/.gitignore index daf913b..ad49ac3 100644 --- a/kubechain/internal/humanlayerapi/.gitignore +++ b/kubechain/internal/humanlayerapi/.gitignore @@ -22,3 +22,7 @@ _testmain.go *.exe *.test *.prof + +# macOS system files +.DS_Store +**/.DS_Store diff --git a/kubechain/test/utils/objects_utils.go b/kubechain/test/utils/objects_utils.go index 5f2649c..b82154e 100644 --- a/kubechain/test/utils/objects_utils.go +++ b/kubechain/test/utils/objects_utils.go @@ -12,11 +12,12 @@ import ( ) type TestScopedAgent struct { - Name string - SystemPrompt string - Tools []string - LLM string - client client.Client + Name string + SystemPrompt string + Tools []string + LLM string + HumanContactChannels []string + client client.Client } func (t *TestScopedAgent) Setup(k8sClient client.Client) { @@ -39,6 +40,13 @@ func (t *TestScopedAgent) Setup(k8sClient client.Client) { } return refs }(), + HumanContactChannels: func() []kubechain.LocalObjectReference { + refs := make([]kubechain.LocalObjectReference, len(t.HumanContactChannels)) + for i, channel := range t.HumanContactChannels { + refs[i] = kubechain.LocalObjectReference{Name: channel} + } + return refs + }(), }, } Expect(t.client.Create(context.Background(), agent)).To(Succeed()) @@ -57,6 +65,16 @@ func (t *TestScopedAgent) Setup(k8sClient client.Client) { } return tools }() + agent.Status.ValidHumanContactChannels = func() []kubechain.ResolvedContactChannel { + channels := make([]kubechain.ResolvedContactChannel, len(t.HumanContactChannels)) + for i, channel := range t.HumanContactChannels { + channels[i] = kubechain.ResolvedContactChannel{ + Name: channel, + Type: "email", // Default type for testing + } + } + return channels + }() Expect(t.client.Status().Update(context.Background(), agent)).To(Succeed()) }