فهرست منبع

feat(template): add loco-agents template

Thomas Zhang 2 ماه پیش
والد
کامیت
25edb9a392
4فایلهای تغییر یافته به همراه353 افزوده شده و 0 حذف شده
  1. 2 0
      internal/controller/component_controller.go
  2. 269 0
      internal/reconciler/loco_agents.go
  3. 80 0
      pkg/templates/locostack/agents.go
  4. 2 0
      pkg/templates/templates.go

+ 2 - 0
internal/controller/component_controller.go

@@ -173,6 +173,8 @@ func (r *ComponentReconciler) getComponentReconciler(comp *v1alpha1.Component, s
 		return reconciler.NewLlamaCPPReconciler(r.Client, r.Scheme, stack, comp)
 	case "loco-context":
 		return reconciler.NewLocoContextReconciler(r.Client, r.Scheme, stack, comp)
+	case "loco-agents":
+		return reconciler.NewLocoAgentsReconciler(r.Client, r.Scheme, stack, comp)
 	default:
 		return reconciler.NewDefaultComponentReconciler(r.Client, r.Scheme, stack, comp)
 	}

+ 269 - 0
internal/reconciler/loco_agents.go

@@ -0,0 +1,269 @@
+/*
+Copyright 2026 LocoStack.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package reconciler
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"maps"
+	"sort"
+	"strings"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	"github.com/LocoStack/loco-operator/pkg/templates/litellm"
+	"go.yaml.in/yaml/v2"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+type LocoAgentsReconciler struct {
+	*DefaultComponentReconciler
+	client    client.Client
+	scheme    *runtime.Scheme
+	stack     *v1alpha1.Stack
+	component *v1alpha1.Component
+}
+
+func NewLocoAgentsReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LocoAgentsReconciler {
+	return &LocoAgentsReconciler{
+		DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component),
+		client:                     client,
+		scheme:                     scheme,
+		stack:                      stack,
+		component:                  component,
+	}
+}
+
+func (r *LocoAgentsReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) {
+	endpointResolver := NewEndpointResolver(r.client, r.scheme, r.stack.Namespace)
+
+	var modelEndpoint string
+	var modelName string
+	toolEndpoints := make(map[string]string)
+	toolNames := make([]string, 0)
+	kbEndpoints := make(map[string]string)
+	deps := make([]client.Object, 0)
+	for _, dep := range r.component.Spec.Dependencies {
+		obj, endpoint, err := endpointResolver.ResolveEndpoint(ctx, r.stack, dep)
+		if err != nil {
+			return nil, fmt.Errorf("Failed to resolve endpoint for %s %s: %w", dep.Kind, dep.Name, err)
+		}
+		switch dep.Kind {
+		case "ExternalModel":
+			fallthrough
+		case "ManagedModel":
+			var category string
+			if extModel, ok := obj.(*v1alpha1.ExternalModel); ok {
+				category = extModel.Spec.Category
+				modelName = extModel.Spec.ModelName
+			} else if managedModel, ok := obj.(*v1alpha1.ManagedModel); ok {
+				category = managedModel.Spec.Category
+				modelName = managedModel.Spec.ModelName
+			} else {
+				return nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
+			}
+			if category != "language" {
+				return nil, fmt.Errorf("Unsupported model category for agent: %s", category)
+			}
+			modelEndpoint = endpoint
+		case "ExternalTool":
+			fallthrough
+		case "ManagedTool":
+			toolEndpoints[dep.Name] = endpoint
+			if extTool, ok := obj.(*v1alpha1.ExternalTool); ok {
+				toolNames = append(toolNames, extTool.Spec.ToolName)
+			} else if managedTool, ok := obj.(*v1alpha1.ManagedTool); ok {
+				toolNames = append(toolNames, managedTool.Spec.ToolName)
+			}
+		case "ExternalKnowledgeBase":
+			fallthrough
+		case "ManagedKnowledgeBase":
+			kbEndpoints[dep.Name] = endpoint
+		default:
+			return nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
+		}
+		deps = append(deps, obj)
+	}
+
+	gatewayComp, err := endpointResolver.resolveGatewayEndpoint(ctx, r.stack)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to resolve gateway for %s: %w", r.component.Name, err)
+	}
+	masterKey, err := r.gatewayMasterKey(ctx, gatewayComp)
+	if err != nil {
+		return nil, fmt.Errorf("resolving gateway master key: %w", err)
+	}
+
+	agentConfigHash, err := r.reconcileAgentConfig(ctx, r.component.Spec.Variables["systemPrompt"], modelEndpoint, masterKey, modelName, kbEndpoints)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to reconcile config for %s: %w", r.component.Name, err)
+	}
+
+	mcpConfigHash, err := r.reconcileMcpConfig(ctx, gatewayComp.Status.Endpoint, masterKey, toolNames, toolEndpoints)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to reconcile MCP config for %s: %w", r.component.Name, err)
+	}
+
+	h := sha256.Sum256([]byte(agentConfigHash + mcpConfigHash))
+	configHash := hex.EncodeToString(h[:])
+	if tmpl.Metadata.Annotations == nil {
+		tmpl.Metadata.Annotations = make(map[string]string)
+	}
+	maps.Copy(tmpl.Metadata.Annotations, map[string]string{
+		"locostack.com/configHash": configHash,
+	})
+
+	if _, err := r.DefaultComponentReconciler.ReconcileComponent(ctx, tmpl, variables); err != nil {
+		return nil, err
+	}
+	return deps, nil
+}
+
+type modelConfig struct {
+	Endpoint string `yaml:"endpoint"`
+	APIKey   string `yaml:"apiKey,omitempty"`
+	Name     string `yaml:"name,omitempty"`
+}
+
+type endpoint struct {
+	Name     string `json:"name"`
+	Endpoint string `json:"endpoint"`
+}
+
+type agentConfig struct {
+	SystemPrompt   string      `yaml:"systemPrompt"`
+	Model          modelConfig `yaml:"model"`
+	KnowledgeBases []endpoint  `yaml:"knowledgeBases,omitempty"`
+	MCPConfigPath  string      `yaml:"mcpConfigPath,omitempty"`
+}
+
+func (r *LocoAgentsReconciler) reconcileAgentConfig(ctx context.Context, systemPrompt string, modelEndpoint, modelAPIKey, modelName string, knowledgeBases map[string]string) (string, error) {
+	config := agentConfig{
+		SystemPrompt: systemPrompt,
+		Model: modelConfig{
+			Endpoint: modelEndpoint,
+			APIKey:   modelAPIKey,
+			Name:     modelName,
+		},
+		MCPConfigPath: "/etc/mcp/mcp.json",
+	}
+	kbNames := make([]string, 0, len(knowledgeBases))
+	for kbName := range knowledgeBases {
+		kbNames = append(kbNames, kbName)
+	}
+	sort.Strings(kbNames)
+	for _, kbName := range kbNames {
+		config.KnowledgeBases = append(config.KnowledgeBases, endpoint{
+			Name:     kbName,
+			Endpoint: knowledgeBases[kbName],
+		})
+	}
+	yamlBytes, err := yaml.Marshal(config)
+	h := sha256.Sum256(yamlBytes)
+	configHash := hex.EncodeToString(h[:])
+	if err != nil {
+		return "", fmt.Errorf("marshaling agent config: %w", err)
+	}
+	secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}}
+	_, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
+		if secret.Labels == nil {
+			secret.Labels = map[string]string{}
+		}
+		maps.Copy(secret.Labels, r.ResourceLabels())
+		if secret.Annotations == nil {
+			secret.Annotations = make(map[string]string)
+		}
+		secret.Annotations["hash"] = configHash
+		secret.Data = map[string][]byte{
+			"agent.yaml": yamlBytes,
+		}
+		return controllerutil.SetControllerReference(r.component, secret, r.scheme)
+	})
+	return configHash, err
+}
+
+type mcpConfig struct {
+	MCPServers map[string]mcpServer `json:"mcpServers"`
+}
+
+type mcpServer struct {
+	URL     string            `json:"url"`
+	Headers map[string]string `json:"headers,omitempty"`
+}
+
+// reconcileMcpConfig writes the agent's mcp.json as a Secret (not a ConfigMap): when a
+// gateway is present, the generated config embeds the resolved litellm master key value
+// in the auth header, so the credential must not land in a ConfigMap.
+func (r *LocoAgentsReconciler) reconcileMcpConfig(ctx context.Context, gatewayEndpoint, masterKey string, toolNames []string, toolEndpoints map[string]string) (string, error) {
+	cfg := mcpConfig{MCPServers: make(map[string]mcpServer)}
+	if gatewayEndpoint != "" && len(toolNames) > 0 {
+		cfg.MCPServers["litellm"] = mcpServer{
+			URL: fmt.Sprintf("%s/%s/mcp", gatewayEndpoint, strings.Join(toolNames, ",")),
+			Headers: map[string]string{
+				litellm.MCPAuthHeaderName: "Bearer " + masterKey,
+			},
+		}
+	} else {
+		for toolName, toolEndpoint := range toolEndpoints {
+			cfg.MCPServers[toolName] = mcpServer{URL: toolEndpoint}
+		}
+	}
+	jsonBytes, err := json.Marshal(cfg)
+	if err != nil {
+		return "", fmt.Errorf("marshaling agent mcp config: %w", err)
+	}
+	h := sha256.Sum256(jsonBytes)
+	configHash := hex.EncodeToString(h[:])
+	secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("mcp-config"), Namespace: r.component.GetNamespace()}}
+	_, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
+		if secret.Labels == nil {
+			secret.Labels = map[string]string{}
+		}
+		maps.Copy(secret.Labels, r.ResourceLabels())
+		if secret.Annotations == nil {
+			secret.Annotations = make(map[string]string)
+		}
+		secret.Annotations["hash"] = configHash
+		secret.Data = map[string][]byte{
+			"mcp.json": jsonBytes,
+		}
+		return controllerutil.SetControllerReference(r.component, secret, r.scheme)
+	})
+	return configHash, err
+}
+
+func (r *LocoAgentsReconciler) gatewayMasterKey(ctx context.Context, gatewayComp *v1alpha1.Component) (string, error) {
+	if gatewayComp == nil {
+		return "", nil
+	}
+	secret := &corev1.Secret{}
+	secretName := litellm.MasterKeySecretName(gatewayComp.Name)
+	if err := r.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: r.component.GetNamespace()}, secret); err != nil {
+		return "", fmt.Errorf("Failed to get gateway master key secret %s: %w", secretName, err)
+	}
+	key, ok := secret.Data[litellm.LITELLM_AUTH_SECRET_KEY]
+	if !ok {
+		return "", fmt.Errorf("Secret %s does not contain key %q", secretName, litellm.LITELLM_AUTH_SECRET_KEY)
+	}
+	return string(key), nil
+}

+ 80 - 0
pkg/templates/locostack/agents.go

@@ -0,0 +1,80 @@
+/*
+Copyright 2026 LocoStack.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package locostack
+
+import (
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/utils/ptr"
+)
+
+var LocoAgentsTemplate = v1alpha1.Template{
+	Name: "loco-agents",
+	Spec: v1alpha1.TemplateSpec{
+		Runtime: v1alpha1.RuntimeSpec{
+			Name:  "loco-simple-agent",
+			Image: "locostack.com/loco-simple-agent:latest",
+			Env: []corev1.EnvVar{
+				{
+					Name:  "PORT",
+					Value: "$(spec.runtime.port)",
+				},
+			},
+			VolumeMounts: []corev1.VolumeMount{
+				{
+					Name:      "config",
+					MountPath: "/etc/locostack",
+					ReadOnly:  true,
+				},
+				{
+					Name:      "mcp-config",
+					MountPath: "/etc/mcp",
+					ReadOnly:  true,
+				},
+			},
+			Port: 8080,
+		},
+		SecurityContext: &corev1.PodSecurityContext{
+			RunAsNonRoot: ptr.To(true),
+			RunAsUser:    ptr.To[int64](10001),
+			RunAsGroup:   ptr.To[int64](10001),
+			FSGroup:      ptr.To[int64](10001),
+			SeccompProfile: &corev1.SeccompProfile{
+				Type: corev1.SeccompProfileTypeRuntimeDefault,
+			},
+			SupplementalGroups: []int64{},
+		},
+		Volumes: []corev1.Volume{
+			{
+				Name: "config",
+				VolumeSource: corev1.VolumeSource{
+					Secret: &corev1.SecretVolumeSource{
+						SecretName: "$(metadata.name)-config",
+					},
+				},
+			},
+			{
+				Name: "mcp-config",
+				VolumeSource: corev1.VolumeSource{
+					Secret: &corev1.SecretVolumeSource{
+						SecretName: "$(metadata.name)-mcp-config",
+					},
+				},
+			},
+		},
+	},
+}

+ 2 - 0
pkg/templates/templates.go

@@ -272,4 +272,6 @@ func init() {
 	Manager.RegisterRuntime(locostack.LocoContextTemplate.Spec.Runtime)
 	Manager.RegisterTemplate(locostack.LocoSimpleRAGIngestionTemplate)
 	Manager.RegisterTemplate(locostack.LocoSimpleRAGEgestionTemplate)
+	Manager.RegisterTemplate(locostack.LocoAgentsTemplate)
+	Manager.RegisterRuntime(locostack.LocoAgentsTemplate.Spec.Runtime)
 }