loco_agents.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /*
  2. Copyright 2026 LocoStack.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package reconciler
  14. import (
  15. "context"
  16. "crypto/sha256"
  17. "encoding/hex"
  18. "encoding/json"
  19. "fmt"
  20. "maps"
  21. "sort"
  22. "strings"
  23. "github.com/LocoStack/loco-operator/api/v1alpha1"
  24. "github.com/LocoStack/loco-operator/pkg/templates/litellm"
  25. "go.yaml.in/yaml/v2"
  26. corev1 "k8s.io/api/core/v1"
  27. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  28. "k8s.io/apimachinery/pkg/runtime"
  29. "sigs.k8s.io/controller-runtime/pkg/client"
  30. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  31. )
  32. type LocoAgentsReconciler struct {
  33. *DefaultComponentReconciler
  34. client client.Client
  35. scheme *runtime.Scheme
  36. stack *v1alpha1.Stack
  37. component *v1alpha1.Component
  38. }
  39. func NewLocoAgentsReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LocoAgentsReconciler {
  40. return &LocoAgentsReconciler{
  41. DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component),
  42. client: client,
  43. scheme: scheme,
  44. stack: stack,
  45. component: component,
  46. }
  47. }
  48. func (r *LocoAgentsReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) {
  49. endpointResolver := NewEndpointResolver(r.client, r.scheme, r.stack.Namespace)
  50. var modelEndpoint string
  51. var modelName string
  52. toolEndpoints := make(map[string]string)
  53. toolNames := make([]string, 0)
  54. kbEndpoints := make(map[string]string)
  55. deps := make([]client.Object, 0)
  56. for _, dep := range r.component.Spec.Dependencies {
  57. obj, endpoint, err := endpointResolver.ResolveEndpoint(ctx, r.stack, dep)
  58. if err != nil {
  59. return nil, fmt.Errorf("Failed to resolve endpoint for %s %s: %w", dep.Kind, dep.Name, err)
  60. }
  61. switch dep.Kind {
  62. case "ExternalModel":
  63. fallthrough
  64. case "ManagedModel":
  65. var category string
  66. if extModel, ok := obj.(*v1alpha1.ExternalModel); ok {
  67. category = extModel.Spec.Category
  68. modelName = extModel.Spec.ModelName
  69. } else if managedModel, ok := obj.(*v1alpha1.ManagedModel); ok {
  70. category = managedModel.Spec.Category
  71. modelName = managedModel.Spec.ModelName
  72. } else {
  73. return nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
  74. }
  75. if category != "language" {
  76. return nil, fmt.Errorf("Unsupported model category for agent: %s", category)
  77. }
  78. modelEndpoint = endpoint
  79. case "ExternalTool":
  80. fallthrough
  81. case "ManagedTool":
  82. toolEndpoints[dep.Name] = endpoint
  83. if extTool, ok := obj.(*v1alpha1.ExternalTool); ok {
  84. toolNames = append(toolNames, extTool.Spec.ToolName)
  85. } else if managedTool, ok := obj.(*v1alpha1.ManagedTool); ok {
  86. toolNames = append(toolNames, managedTool.Spec.ToolName)
  87. }
  88. case "ExternalKnowledgeBase":
  89. fallthrough
  90. case "ManagedKnowledgeBase":
  91. kbEndpoints[dep.Name] = endpoint
  92. default:
  93. return nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
  94. }
  95. deps = append(deps, obj)
  96. }
  97. gatewayComp, err := endpointResolver.resolveGatewayEndpoint(ctx, r.stack)
  98. if err != nil {
  99. return nil, fmt.Errorf("Failed to resolve gateway for %s: %w", r.component.Name, err)
  100. }
  101. masterKey, err := r.gatewayMasterKey(ctx, gatewayComp)
  102. if err != nil {
  103. return nil, fmt.Errorf("resolving gateway master key: %w", err)
  104. }
  105. agentConfigHash, err := r.reconcileAgentConfig(ctx, r.component.Spec.Variables["systemPrompt"], modelEndpoint, masterKey, modelName, kbEndpoints)
  106. if err != nil {
  107. return nil, fmt.Errorf("Failed to reconcile config for %s: %w", r.component.Name, err)
  108. }
  109. mcpConfigHash, err := r.reconcileMcpConfig(ctx, gatewayComp.Status.Endpoint, masterKey, toolNames, toolEndpoints)
  110. if err != nil {
  111. return nil, fmt.Errorf("Failed to reconcile MCP config for %s: %w", r.component.Name, err)
  112. }
  113. h := sha256.Sum256([]byte(agentConfigHash + mcpConfigHash))
  114. configHash := hex.EncodeToString(h[:])
  115. if tmpl.Metadata.Annotations == nil {
  116. tmpl.Metadata.Annotations = make(map[string]string)
  117. }
  118. maps.Copy(tmpl.Metadata.Annotations, map[string]string{
  119. "locostack.com/configHash": configHash,
  120. })
  121. if _, err := r.DefaultComponentReconciler.ReconcileComponent(ctx, tmpl, variables); err != nil {
  122. return nil, err
  123. }
  124. return deps, nil
  125. }
  126. type modelConfig struct {
  127. Endpoint string `yaml:"endpoint"`
  128. APIKey string `yaml:"apiKey,omitempty"`
  129. Name string `yaml:"name,omitempty"`
  130. }
  131. type endpoint struct {
  132. Name string `json:"name"`
  133. Endpoint string `json:"endpoint"`
  134. }
  135. type agentConfig struct {
  136. SystemPrompt string `yaml:"systemPrompt"`
  137. Model modelConfig `yaml:"model"`
  138. KnowledgeBases []endpoint `yaml:"knowledgeBases,omitempty"`
  139. MCPConfigPath string `yaml:"mcpConfigPath,omitempty"`
  140. }
  141. func (r *LocoAgentsReconciler) reconcileAgentConfig(ctx context.Context, systemPrompt string, modelEndpoint, modelAPIKey, modelName string, knowledgeBases map[string]string) (string, error) {
  142. config := agentConfig{
  143. SystemPrompt: systemPrompt,
  144. Model: modelConfig{
  145. Endpoint: modelEndpoint,
  146. APIKey: modelAPIKey,
  147. Name: modelName,
  148. },
  149. MCPConfigPath: "/etc/mcp/mcp.json",
  150. }
  151. kbNames := make([]string, 0, len(knowledgeBases))
  152. for kbName := range knowledgeBases {
  153. kbNames = append(kbNames, kbName)
  154. }
  155. sort.Strings(kbNames)
  156. for _, kbName := range kbNames {
  157. config.KnowledgeBases = append(config.KnowledgeBases, endpoint{
  158. Name: kbName,
  159. Endpoint: knowledgeBases[kbName],
  160. })
  161. }
  162. yamlBytes, err := yaml.Marshal(config)
  163. h := sha256.Sum256(yamlBytes)
  164. configHash := hex.EncodeToString(h[:])
  165. if err != nil {
  166. return "", fmt.Errorf("marshaling agent config: %w", err)
  167. }
  168. secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}}
  169. _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
  170. if secret.Labels == nil {
  171. secret.Labels = map[string]string{}
  172. }
  173. maps.Copy(secret.Labels, r.ResourceLabels())
  174. if secret.Annotations == nil {
  175. secret.Annotations = make(map[string]string)
  176. }
  177. secret.Annotations["hash"] = configHash
  178. secret.Data = map[string][]byte{
  179. "agent.yaml": yamlBytes,
  180. }
  181. return controllerutil.SetControllerReference(r.component, secret, r.scheme)
  182. })
  183. return configHash, err
  184. }
  185. type mcpConfig struct {
  186. MCPServers map[string]mcpServer `json:"mcpServers"`
  187. }
  188. type mcpServer struct {
  189. URL string `json:"url"`
  190. Headers map[string]string `json:"headers,omitempty"`
  191. }
  192. // reconcileMcpConfig writes the agent's mcp.json as a Secret (not a ConfigMap): when a
  193. // gateway is present, the generated config embeds the resolved litellm master key value
  194. // in the auth header, so the credential must not land in a ConfigMap.
  195. func (r *LocoAgentsReconciler) reconcileMcpConfig(ctx context.Context, gatewayEndpoint, masterKey string, toolNames []string, toolEndpoints map[string]string) (string, error) {
  196. cfg := mcpConfig{MCPServers: make(map[string]mcpServer)}
  197. if gatewayEndpoint != "" && len(toolNames) > 0 {
  198. cfg.MCPServers["litellm"] = mcpServer{
  199. URL: fmt.Sprintf("%s/%s/mcp", gatewayEndpoint, strings.Join(toolNames, ",")),
  200. Headers: map[string]string{
  201. litellm.MCPAuthHeaderName: "Bearer " + masterKey,
  202. },
  203. }
  204. } else {
  205. for toolName, toolEndpoint := range toolEndpoints {
  206. cfg.MCPServers[toolName] = mcpServer{URL: toolEndpoint}
  207. }
  208. }
  209. jsonBytes, err := json.Marshal(cfg)
  210. if err != nil {
  211. return "", fmt.Errorf("marshaling agent mcp config: %w", err)
  212. }
  213. h := sha256.Sum256(jsonBytes)
  214. configHash := hex.EncodeToString(h[:])
  215. secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("mcp-config"), Namespace: r.component.GetNamespace()}}
  216. _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
  217. if secret.Labels == nil {
  218. secret.Labels = map[string]string{}
  219. }
  220. maps.Copy(secret.Labels, r.ResourceLabels())
  221. if secret.Annotations == nil {
  222. secret.Annotations = make(map[string]string)
  223. }
  224. secret.Annotations["hash"] = configHash
  225. secret.Data = map[string][]byte{
  226. "mcp.json": jsonBytes,
  227. }
  228. return controllerutil.SetControllerReference(r.component, secret, r.scheme)
  229. })
  230. return configHash, err
  231. }
  232. func (r *LocoAgentsReconciler) gatewayMasterKey(ctx context.Context, gatewayComp *v1alpha1.Component) (string, error) {
  233. if gatewayComp == nil {
  234. return "", nil
  235. }
  236. secret := &corev1.Secret{}
  237. secretName := litellm.MasterKeySecretName(gatewayComp.Name)
  238. if err := r.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: r.component.GetNamespace()}, secret); err != nil {
  239. return "", fmt.Errorf("Failed to get gateway master key secret %s: %w", secretName, err)
  240. }
  241. key, ok := secret.Data[litellm.LITELLM_AUTH_SECRET_KEY]
  242. if !ok {
  243. return "", fmt.Errorf("Secret %s does not contain key %q", secretName, litellm.LITELLM_AUTH_SECRET_KEY)
  244. }
  245. return string(key), nil
  246. }