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/taskruntoolcall_types.go b/kubechain/api/v1alpha1/taskruntoolcall_types.go index 8cea135..1af029f 100644 --- a/kubechain/api/v1alpha1/taskruntoolcall_types.go +++ b/kubechain/api/v1alpha1/taskruntoolcall_types.go @@ -26,6 +26,10 @@ type TaskRunToolCallSpec struct { // +kubebuilder:validation:Required ToolRef LocalObjectReference `json:"toolRef"` + // ToolType identifies the type of the tool (Standard, MCP, HumanContact) + // +optional + ToolType ToolType `json:"toolType,omitempty"` + // Arguments contains the arguments for the tool call // +kubebuilder:validation:Required Arguments string `json:"arguments"` diff --git a/kubechain/api/v1alpha1/tool_types.go b/kubechain/api/v1alpha1/tool_types.go index 796cd38..7cf0bf2 100644 --- a/kubechain/api/v1alpha1/tool_types.go +++ b/kubechain/api/v1alpha1/tool_types.go @@ -5,6 +5,19 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// ToolType defines the type of a tool in the system +// +kubebuilder:validation:Enum=Standard;MCP;HumanContact +type ToolType string + +const ( + // ToolTypeStandard indicates a standard tool defined in the system + ToolTypeStandard ToolType = "Standard" + // ToolTypeMCP indicates a tool provided by an MCP server + ToolTypeMCP ToolType = "MCP" + // ToolTypeHumanContact indicates a tool for human interaction + ToolTypeHumanContact ToolType = "HumanContact" +) + // ToolSpec defines the desired state of Tool type ToolSpec struct { // Name is used for inline/function tools (optional if the object name is used). 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/crd/bases/kubechain.humanlayer.dev_taskruntoolcalls.yaml b/kubechain/config/crd/bases/kubechain.humanlayer.dev_taskruntoolcalls.yaml index b98cfe4..2b4281d 100644 --- a/kubechain/config/crd/bases/kubechain.humanlayer.dev_taskruntoolcalls.yaml +++ b/kubechain/config/crd/bases/kubechain.humanlayer.dev_taskruntoolcalls.yaml @@ -87,6 +87,14 @@ spec: required: - name type: object + toolType: + description: ToolType identifies the type of the tool (Standard, MCP, + HumanContact) + enum: + - Standard + - MCP + - HumanContact + type: string required: - arguments - taskRunRef 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/adapters/mcp_adapter.go b/kubechain/internal/adapters/mcp_adapter.go index f44ba86..a52e397 100644 --- a/kubechain/internal/adapters/mcp_adapter.go +++ b/kubechain/internal/adapters/mcp_adapter.go @@ -41,8 +41,9 @@ func ConvertMCPToolsToLLMClientTools(mcpTools []kubechainv1alpha1.MCPTool, serve // Create the tool with the function definition clientTools = append(clientTools, llmclient.Tool{ - Type: "function", - Function: toolFunction, + Type: "function", + Function: toolFunction, + KubechainToolType: kubechainv1alpha1.ToolTypeMCP, // Set as MCP type }) } diff --git a/kubechain/internal/controller/agent/agent_controller.go b/kubechain/internal/controller/agent/agent_controller.go index 8b11376..36ea628 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,54 @@ func (r *AgentReconciler) validateMCPServers(ctx context.Context, agent *kubecha return validMCPServers, nil } +// validateHumanContactChannels checks if all referenced contact channels exist and are ready +// and have the required context information for the LLM +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) + } + + // Check that the context about the user/channel is provided based on the channel type + switch channel.Spec.Type { + case kubechainv1alpha1.ContactChannelTypeEmail: + if channel.Spec.Email == nil { + return validChannels, fmt.Errorf("ContactChannel %q is missing Email configuration", channelRef.Name) + } + if channel.Spec.Email.ContextAboutUser == "" { + return validChannels, fmt.Errorf("ContactChannel %q must have ContextAboutUser set", channelRef.Name) + } + case kubechainv1alpha1.ContactChannelTypeSlack: + if channel.Spec.Slack == nil { + return validChannels, fmt.Errorf("ContactChannel %q is missing Slack configuration", channelRef.Name) + } + if channel.Spec.Slack.ContextAboutChannelOrUser == "" { + return validChannels, fmt.Errorf("ContactChannel %q must have ContextAboutChannelOrUser set", channelRef.Name) + } + default: + return validChannels, fmt.Errorf("ContactChannel %q has unsupported type %q", channelRef.Name, channel.Spec.Type) + } + + 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 +191,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 +204,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 +222,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 +241,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 +277,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 +290,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..14393e1 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,34 @@ 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", + ContextAboutUser: "A helpful test user who provides quick responses", + }, + }, + } + 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 +114,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 +132,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 +165,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 +177,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 +215,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 +249,117 @@ 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") + }) + + It("should fail validation when HumanContactChannel is missing required context", func() { + By("creating a contact channel without context") + contactChannelWithoutContext := &kubechainv1alpha1.ContactChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "contactchannel-without-context", + Namespace: "default", + }, + Spec: kubechainv1alpha1.ContactChannelSpec{ + Type: kubechainv1alpha1.ContactChannelTypeEmail, + APIKeyFrom: kubechainv1alpha1.APIKeySource{ + SecretKeyRef: kubechainv1alpha1.SecretKeyRef{ + Name: "test-secret", + Key: "api-key", + }, + }, + Email: &kubechainv1alpha1.EmailChannelConfig{ + Address: "nocontext@example.com", + // Intentionally omitting ContextAboutUser + }, + }, + } + Expect(k8sClient.Create(ctx, contactChannelWithoutContext)).To(Succeed()) + + // Mark ContactChannel as ready + contactChannelWithoutContext.Status.Ready = true + contactChannelWithoutContext.Status.Status = "Ready" + contactChannelWithoutContext.Status.StatusDetail = "Ready for testing" + Expect(k8sClient.Status().Update(ctx, contactChannelWithoutContext)).To(Succeed()) + + By("creating the test agent referring to the contact channel without context") + testAgent := &utils.TestScopedAgent{ + Name: resourceName, + SystemPrompt: "Test agent", + Tools: []string{toolName}, + LLM: llmName, + HumanContactChannels: []string{"contactchannel-without-context"}, + } + 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("must have ContextAboutUser set")) + + 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("must have ContextAboutUser set")) + + By("checking that a failure event was created") + utils.ExpectRecorder(eventRecorder).ToEmitEventContaining("ValidationFailed") + + // Cleanup + By("Cleanup the test ContactChannel without context") + contactChannel := &kubechainv1alpha1.ContactChannel{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "contactchannel-without-context", Namespace: "default"}, contactChannel) + if err == nil { + Expect(k8sClient.Delete(ctx, contactChannel)).To(Succeed()) + } + }) }) }) diff --git a/kubechain/internal/controller/taskrun/taskrun_controller.go b/kubechain/internal/controller/taskrun/taskrun_controller.go index 41b87ae..8c2bb2d 100644 --- a/kubechain/internal/controller/taskrun/taskrun_controller.go +++ b/kubechain/internal/controller/taskrun/taskrun_controller.go @@ -365,7 +365,7 @@ func (r *TaskRunReconciler) getLLMAndCredentials(ctx context.Context, agent *kub return llm, apiKey, nil } -// collectTools gathers tools from all sources (Tool CRDs and MCP servers) +// collectTools gathers tools from all sources (Tool CRDs, MCP servers, and Human Contact Channels) func (r *TaskRunReconciler) collectTools(ctx context.Context, agent *kubechainv1alpha1.Agent) []llmclient.Tool { logger := log.FromContext(ctx) var tools []llmclient.Tool @@ -414,6 +414,25 @@ func (r *TaskRunReconciler) collectTools(ctx context.Context, agent *kubechainv1 } } + // Finally, add tools from Human Contact Channels + if len(agent.Status.ValidHumanContactChannels) > 0 { + logger.Info("Adding human contact channel tools to LLM request", "channelCount", len(agent.Status.ValidHumanContactChannels)) + + for _, validChannel := range agent.Status.ValidHumanContactChannels { + // Get the ContactChannel resource + channel := &kubechainv1alpha1.ContactChannel{} + if err := r.Get(ctx, client.ObjectKey{Namespace: agent.Namespace, Name: validChannel.Name}, channel); err != nil { + logger.Error(err, "Failed to get ContactChannel", "name", validChannel.Name) + continue + } + + // Convert to LLM client format + clientTool := llmclient.FromContactChannel(*channel) + tools = append(tools, *clientTool) + logger.Info("Added human contact channel tool", "name", channel.Name, "type", channel.Spec.Type) + } + } + return tools } @@ -473,7 +492,7 @@ func (r *TaskRunReconciler) endTaskRunSpan(ctx context.Context, taskRun *kubecha } // processLLMResponse handles the LLM's output and updates status accordingly -func (r *TaskRunReconciler) processLLMResponse(ctx context.Context, output *kubechainv1alpha1.Message, taskRun *kubechainv1alpha1.TaskRun, statusUpdate *kubechainv1alpha1.TaskRun) (ctrl.Result, error) { +func (r *TaskRunReconciler) processLLMResponse(ctx context.Context, output *kubechainv1alpha1.Message, taskRun *kubechainv1alpha1.TaskRun, statusUpdate *kubechainv1alpha1.TaskRun, tools []llmclient.Tool) (ctrl.Result, error) { logger := log.FromContext(ctx) if output.Content != "" { @@ -517,13 +536,13 @@ func (r *TaskRunReconciler) processLLMResponse(ctx context.Context, output *kube return ctrl.Result{}, err } - return r.createToolCalls(ctx, taskRun, statusUpdate, output.ToolCalls) + return r.createToolCalls(ctx, taskRun, statusUpdate, output.ToolCalls, tools) } return ctrl.Result{}, nil } // createToolCalls creates TaskRunToolCall objects for each tool call -func (r *TaskRunReconciler) createToolCalls(ctx context.Context, taskRun *kubechainv1alpha1.TaskRun, statusUpdate *kubechainv1alpha1.TaskRun, toolCalls []kubechainv1alpha1.ToolCall) (ctrl.Result, error) { +func (r *TaskRunReconciler) createToolCalls(ctx context.Context, taskRun *kubechainv1alpha1.TaskRun, statusUpdate *kubechainv1alpha1.TaskRun, toolCalls []kubechainv1alpha1.ToolCall, tools []llmclient.Tool) (ctrl.Result, error) { logger := log.FromContext(ctx) if statusUpdate.Status.ToolCallRequestID == "" { @@ -532,9 +551,22 @@ func (r *TaskRunReconciler) createToolCalls(ctx context.Context, taskRun *kubech return ctrl.Result{}, err } + // Create a map of tool name to tool type for quick lookup + toolTypeMap := make(map[string]kubechainv1alpha1.ToolType) + for _, tool := range tools { + toolTypeMap[tool.Function.Name] = tool.KubechainToolType + } + // For each tool call, create a new TaskRunToolCall with a unique name using the ToolCallRequestID for i, tc := range toolCalls { newName := fmt.Sprintf("%s-%s-tc-%02d", statusUpdate.Name, statusUpdate.Status.ToolCallRequestID, i+1) + + // Determine tool type from our map, default to Standard if not found + toolType := kubechainv1alpha1.ToolTypeStandard + if tt, exists := toolTypeMap[tc.Function.Name]; exists { + toolType = tt + } + newTRTC := &kubechainv1alpha1.TaskRunToolCall{ ObjectMeta: metav1.ObjectMeta{ Name: newName, @@ -561,6 +593,7 @@ func (r *TaskRunReconciler) createToolCalls(ctx context.Context, taskRun *kubech ToolRef: kubechainv1alpha1.LocalObjectReference{ Name: tc.Function.Name, }, + ToolType: toolType, // Include the tool type Arguments: tc.Function.Arguments, }, } @@ -568,7 +601,7 @@ func (r *TaskRunReconciler) createToolCalls(ctx context.Context, taskRun *kubech logger.Error(err, "Failed to create TaskRunToolCall", "name", newName) return ctrl.Result{}, err } - logger.Info("Created TaskRunToolCall", "name", newName, "requestId", statusUpdate.Status.ToolCallRequestID) + logger.Info("Created TaskRunToolCall", "name", newName, "requestId", statusUpdate.Status.ToolCallRequestID, "toolType", toolType) r.recorder.Event(taskRun, corev1.EventTypeNormal, "ToolCallCreated", "Created TaskRunToolCall "+newName) } return ctrl.Result{RequeueAfter: time.Second * 5}, nil @@ -807,7 +840,7 @@ func (r *TaskRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct logger.V(3).Info("Processing LLM response") // Step 9: Process LLM response var llmResult ctrl.Result - llmResult, err = r.processLLMResponse(ctx, output, &taskRun, statusUpdate) + llmResult, err = r.processLLMResponse(ctx, output, &taskRun, statusUpdate, tools) if err != nil { logger.Error(err, "Failed to process LLM response") statusUpdate.Status.Status = StatusError diff --git a/kubechain/internal/controller/taskruntoolcall/taskruntoolcall_controller.go b/kubechain/internal/controller/taskruntoolcall/taskruntoolcall_controller.go index 8b937ba..41bcbe0 100644 --- a/kubechain/internal/controller/taskruntoolcall/taskruntoolcall_controller.go +++ b/kubechain/internal/controller/taskruntoolcall/taskruntoolcall_controller.go @@ -137,14 +137,28 @@ func convertToFloat(val interface{}) (float64, error) { } } -// checkIfMCPTool checks if a tool name follows the MCPServer tool pattern (serverName__toolName) -// and returns the serverName, toolName, and whether it's an MCP tool -func isMCPTool(toolName string) (serverName string, actualToolName string, isMCP bool) { - parts := strings.Split(toolName, "__") +// isMCPTool checks if a tool is an MCP tool and extracts the server name and actual tool name +// We're still parsing the name because we need to extract the server name and tool name +// In the future, we may want to store these separately in the TaskRunToolCall spec +func isMCPTool(trtc *kubechainv1alpha1.TaskRunToolCall) (serverName string, actualToolName string, isMCP bool) { + // First check if we have a tool type field + if trtc.Spec.ToolType == kubechainv1alpha1.ToolTypeMCP { + // For MCP tools, we still need to parse the name to get the server and tool parts + parts := strings.Split(trtc.Spec.ToolRef.Name, "__") + if len(parts) == 2 { + return parts[0], parts[1], true + } + // This shouldn't happen if toolType is set correctly, but just in case + return "", trtc.Spec.ToolRef.Name, true + } + + // Fallback to the old way for backward compatibility + parts := strings.Split(trtc.Spec.ToolRef.Name, "__") if len(parts) == 2 { return parts[0], parts[1], true } - return "", toolName, false + + return "", trtc.Spec.ToolRef.Name, false } // executeMCPTool executes a tool call on an MCP server @@ -629,7 +643,7 @@ func (r *TaskRunToolCallReconciler) getMCPServer(ctx context.Context, trtc *kube logger := log.FromContext(ctx) // Check if this is an MCP tool - serverName, _, isMCP := isMCPTool(trtc.Spec.ToolRef.Name) + serverName, _, isMCP := isMCPTool(trtc) if !isMCP { return nil, false, nil } @@ -919,7 +933,7 @@ func (r *TaskRunToolCallReconciler) dispatchToolExecution(ctx context.Context, t args map[string]interface{}, ) (ctrl.Result, error) { // Check for MCP tool first - serverName, mcpToolName, isMCP := isMCPTool(trtc.Spec.ToolRef.Name) + serverName, mcpToolName, isMCP := isMCPTool(trtc) if isMCP && r.MCPManager != nil { return r.processMCPTool(ctx, trtc, serverName, mcpToolName, args) } 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/internal/llmclient/openai_client.go b/kubechain/internal/llmclient/openai_client.go index 4262eac..7c3bebb 100644 --- a/kubechain/internal/llmclient/openai_client.go +++ b/kubechain/internal/llmclient/openai_client.go @@ -79,6 +79,9 @@ type Tool struct { // Type indicates the type of tool. Currently only "function" is supported. Type string `json:"type"` Function ToolFunction `json:"function"` + // KubechainToolType represents the Kubechain-specific type of tool (Standard, MCP, HumanContact) + // This field is not sent to the LLM API but is used internally for tool identification + KubechainToolType v1alpha1.ToolType `json:"-"` } func FromKubechainTool(tool v1alpha1.Tool) *Tool { @@ -89,6 +92,7 @@ func FromKubechainTool(tool v1alpha1.Tool) *Tool { Name: tool.Spec.Name, Description: tool.Spec.Description, }, + KubechainToolType: v1alpha1.ToolTypeStandard, // Standard tool by default } // Parse the parameters if they exist @@ -103,6 +107,47 @@ func FromKubechainTool(tool v1alpha1.Tool) *Tool { return clientTool } +// FromContactChannel creates a Tool from a ContactChannel resource +func FromContactChannel(channel v1alpha1.ContactChannel) *Tool { + // Create base parameters structure for human contact tools + params := ToolFunctionParameters{ + Type: "object", + Properties: map[string]ToolFunctionParameter{ + "message": {Type: "string"}, + }, + Required: []string{"message"}, + } + + var description string + var name string + + // Customize based on channel type + switch channel.Spec.Type { + case v1alpha1.ContactChannelTypeEmail: + name = fmt.Sprintf("human_contact_email_%s", channel.Name) + description = channel.Spec.Email.ContextAboutUser + + case v1alpha1.ContactChannelTypeSlack: + name = fmt.Sprintf("human_contact_slack_%s", channel.Name) + description = channel.Spec.Slack.ContextAboutChannelOrUser + + default: + name = fmt.Sprintf("human_contact_%s", channel.Name) + description = fmt.Sprintf("Contact a human via %s channel", channel.Spec.Type) + } + + // Create the Tool + return &Tool{ + Type: "function", + Function: ToolFunction{ + Name: name, + Description: description, + Parameters: params, + }, + KubechainToolType: v1alpha1.ToolTypeHumanContact, // Set as HumanContact type + } +} + func FromKubechainMessages(messages []v1alpha1.Message) []OpenAIMessage { openaiMessages := make([]OpenAIMessage, len(messages)) for i, message := range messages { diff --git a/kubechain/internal/llmclient/openai_client_test.go b/kubechain/internal/llmclient/openai_client_test.go new file mode 100644 index 0000000..2a21bc2 --- /dev/null +++ b/kubechain/internal/llmclient/openai_client_test.go @@ -0,0 +1,85 @@ +package llmclient + +import ( + "testing" + + "github.com/humanlayer/smallchain/kubechain/api/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func TestFromContactChannel(t *testing.T) { + tests := []struct { + name string + channel v1alpha1.ContactChannel + expected Tool + }{ + { + name: "email contact channel", + channel: v1alpha1.ContactChannel{ + Spec: v1alpha1.ContactChannelSpec{ + Type: v1alpha1.ContactChannelTypeEmail, + Email: &v1alpha1.EmailChannelConfig{ + Address: "test@example.com", + ContextAboutUser: "A helpful test user who provides quick responses", + }, + }, + }, + expected: Tool{ + Type: "function", + Function: ToolFunction{ + Name: "human_contact_email_", + Description: "A helpful test user who provides quick responses", + Parameters: ToolFunctionParameters{ + Type: "object", + Properties: map[string]ToolFunctionParameter{ + "message": {Type: "string"}, + }, + Required: []string{"message"}, + }, + }, + }, + }, + { + name: "slack contact channel", + channel: v1alpha1.ContactChannel{ + Spec: v1alpha1.ContactChannelSpec{ + Type: v1alpha1.ContactChannelTypeSlack, + Slack: &v1alpha1.SlackChannelConfig{ + ChannelOrUserID: "C12345678", + ContextAboutChannelOrUser: "A team channel for engineering discussions", + }, + }, + }, + expected: Tool{ + Type: "function", + Function: ToolFunction{ + Name: "human_contact_slack_", + Description: "A team channel for engineering discussions", + Parameters: ToolFunctionParameters{ + Type: "object", + Properties: map[string]ToolFunctionParameter{ + "message": {Type: "string"}, + }, + Required: []string{"message"}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set the name to match the expected pattern + tt.channel.Name = "" + result := FromContactChannel(tt.channel) + + // Assert function name contains the expected prefix + assert.Contains(t, result.Function.Name, tt.expected.Function.Name) + + // Assert other fields match exactly + assert.Equal(t, tt.expected.Type, result.Type) + assert.Equal(t, tt.expected.Function.Description, result.Function.Description) + assert.Equal(t, tt.expected.Function.Parameters, result.Function.Parameters) + }) + } +} 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()) }