agent.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package reconciler
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "encoding/json"
  7. "fmt"
  8. "maps"
  9. "sort"
  10. "strings"
  11. "github.com/LocoStack/loco-operator/api/v1alpha1"
  12. "github.com/LocoStack/loco-operator/pkg/templates/litellm"
  13. corev1 "k8s.io/api/core/v1"
  14. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  15. "k8s.io/apimachinery/pkg/runtime"
  16. "sigs.k8s.io/controller-runtime/pkg/client"
  17. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  18. )
  19. type AgentReconciler struct {
  20. client client.Client
  21. scheme *runtime.Scheme
  22. stack *v1alpha1.Stack
  23. component *v1alpha1.Component
  24. }
  25. func NewAgentReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *AgentReconciler {
  26. return &AgentReconciler{
  27. client: client,
  28. scheme: scheme,
  29. stack: stack,
  30. component: component,
  31. }
  32. }
  33. type agentComponentEndpoint struct {
  34. Name string
  35. URL string
  36. }
  37. type AgentComponents struct {
  38. Gateway agentComponentEndpoint
  39. GatewayKey string
  40. Model agentComponentEndpoint
  41. Skills []string
  42. Tools []agentComponentEndpoint
  43. KnowledgeBases []agentComponentEndpoint
  44. Agents []agentComponentEndpoint
  45. }
  46. func (r *AgentReconciler) getDependencies(ctx context.Context) ([]client.Object, *AgentComponents, error) {
  47. comps := &AgentComponents{
  48. Tools: []agentComponentEndpoint{},
  49. KnowledgeBases: []agentComponentEndpoint{},
  50. }
  51. endpointResolver := NewEndpointResolver(r.client, r.scheme, r.stack.Namespace)
  52. deps := make([]client.Object, 0)
  53. for _, dep := range r.component.Spec.Dependencies {
  54. obj, endpoint, err := endpointResolver.ResolveEndpoint(ctx, r.stack, dep)
  55. if err != nil {
  56. return nil, nil, fmt.Errorf("Failed to resolve endpoint for %s %s: %w", dep.Kind, dep.Name, err)
  57. }
  58. switch dep.Kind {
  59. case "ExternalModel":
  60. fallthrough
  61. case "ManagedModel":
  62. var category string
  63. if extModel, ok := obj.(*v1alpha1.ExternalModel); ok {
  64. category = extModel.Spec.Category
  65. comps.Model.Name = extModel.Spec.ModelName
  66. } else if managedModel, ok := obj.(*v1alpha1.ManagedModel); ok {
  67. category = managedModel.Spec.Category
  68. comps.Model.Name = managedModel.Spec.ModelName
  69. } else {
  70. return nil, nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
  71. }
  72. if category != "language" {
  73. return nil, nil, fmt.Errorf("Unsupported model category for agent: %s", category)
  74. }
  75. comps.Model.URL = endpoint
  76. case "Skill":
  77. comps.Skills = append(comps.Skills, dep.Name)
  78. case "ExternalTool":
  79. fallthrough
  80. case "ManagedTool":
  81. var toolName string
  82. if extTool, ok := obj.(*v1alpha1.ExternalTool); ok {
  83. toolName = extTool.Spec.ToolName
  84. } else if managedTool, ok := obj.(*v1alpha1.ManagedTool); ok {
  85. toolName = managedTool.Spec.ToolName
  86. }
  87. comps.Tools = append(comps.Tools, agentComponentEndpoint{Name: toolName, URL: endpoint})
  88. case "ExternalKnowledgeBase":
  89. fallthrough
  90. case "ManagedKnowledgeBase":
  91. comps.KnowledgeBases = append(comps.KnowledgeBases, agentComponentEndpoint{Name: dep.Name, URL: endpoint})
  92. case "Agent":
  93. comps.Agents = append(comps.Agents, agentComponentEndpoint{Name: dep.Name, URL: endpoint})
  94. default:
  95. return nil, nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
  96. }
  97. deps = append(deps, obj)
  98. }
  99. sort.Strings(comps.Skills)
  100. sort.Slice(comps.Tools, func(i, j int) bool {
  101. return comps.Tools[i].Name < comps.Tools[j].Name
  102. })
  103. sort.Slice(comps.KnowledgeBases, func(i, j int) bool {
  104. return comps.KnowledgeBases[i].Name < comps.KnowledgeBases[j].Name
  105. })
  106. sort.Slice(comps.Agents, func(i, j int) bool {
  107. return comps.Agents[i].Name < comps.Agents[j].Name
  108. })
  109. gatewayComp, err := endpointResolver.resolveGatewayEndpoint(ctx, r.stack)
  110. if err != nil {
  111. return nil, nil, fmt.Errorf("Failed to resolve gateway for %s: %w", r.component.Name, err)
  112. }
  113. comps.Gateway = agentComponentEndpoint{Name: gatewayComp.Name, URL: gatewayComp.Status.Endpoint}
  114. masterKey, err := r.gatewayMasterKey(ctx, gatewayComp)
  115. if err != nil {
  116. return nil, nil, fmt.Errorf("resolving gateway master key: %w", err)
  117. }
  118. comps.GatewayKey = masterKey
  119. return deps, comps, nil
  120. }
  121. func (r *AgentReconciler) gatewayMasterKey(ctx context.Context, gatewayComp *v1alpha1.Component) (string, error) {
  122. if gatewayComp == nil {
  123. return "", nil
  124. }
  125. secret := &corev1.Secret{}
  126. secretName := litellm.MasterKeySecretName(gatewayComp.Name)
  127. if err := r.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: r.component.GetNamespace()}, secret); err != nil {
  128. return "", fmt.Errorf("Failed to get gateway master key secret %s: %w", secretName, err)
  129. }
  130. key, ok := secret.Data[litellm.LITELLM_AUTH_SECRET_KEY]
  131. if !ok {
  132. return "", fmt.Errorf("Secret %s does not contain key %q", secretName, litellm.LITELLM_AUTH_SECRET_KEY)
  133. }
  134. return string(key), nil
  135. }
  136. // AddSkillsToTmpl adds skill mounts to the template,
  137. // mounting each dependency Skill's ConfigMap under <skillsRoot>/skills/<skillName>,
  138. // and returns a hash of the mounted files. Agent runtimes read their skills once at
  139. // startup, and a ConfigMap edited in place never restarts the Pod, so callers fold that
  140. // hash into the Pod template to roll the agent out whenever a Skill's content changes.
  141. // A Skill's ConfigMap stores one entry per file (SKILL.md plus its scripts/references/
  142. // assets), keyed by v1alpha1.SkillConfigMapKey(path) rather than the raw relative path,
  143. // since ConfigMap keys cannot contain "/". The ConfigMap's v1alpha1.SkillFilePathsAnnotation
  144. // records the key -> original relative path mapping, so it can be read directly rather than
  145. // re-fetched and recomputed from the Skill spec; a ConfigMapVolumeSource with per-key Items
  146. // then lays the files back out as a real directory tree using that mapping.
  147. func (r *AgentReconciler) AddSkillsToTmpl(ctx context.Context, comps *AgentComponents, tmpl *v1alpha1.Template, skillsRoot string) (string, error) {
  148. volumes := make([]corev1.Volume, 0, len(comps.Skills))
  149. volumeMounts := make([]corev1.VolumeMount, 0, len(comps.Skills))
  150. skillsHash := sha256.New()
  151. for _, skillName := range comps.Skills {
  152. cm := &corev1.ConfigMap{}
  153. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.stack.Namespace, Name: skillName}, cm); err != nil {
  154. return "", fmt.Errorf("Failed to get ConfigMap for Skill %s: %w", skillName, err)
  155. }
  156. filePathsJSON, ok := cm.Annotations[v1alpha1.SkillFilePathsAnnotation]
  157. if !ok {
  158. return "", fmt.Errorf("ConfigMap for Skill %s is missing the %q annotation", skillName, v1alpha1.SkillFilePathsAnnotation)
  159. }
  160. var filePaths map[string]string
  161. if err := json.Unmarshal([]byte(filePathsJSON), &filePaths); err != nil {
  162. return "", fmt.Errorf("unmarshaling file paths for Skill %s: %w", skillName, err)
  163. }
  164. keys := sortedKeys(filePaths)
  165. items := make([]corev1.KeyToPath, 0, len(keys))
  166. for _, key := range keys {
  167. items = append(items, corev1.KeyToPath{
  168. Key: key,
  169. Path: filePaths[key],
  170. })
  171. _, _ = fmt.Fprintf(skillsHash, "%s/%s=%s\n", skillName, filePaths[key], cm.Data[key])
  172. }
  173. volumes = append(volumes, corev1.Volume{
  174. Name: skillName,
  175. VolumeSource: corev1.VolumeSource{
  176. ConfigMap: &corev1.ConfigMapVolumeSource{
  177. LocalObjectReference: corev1.LocalObjectReference{Name: skillName},
  178. Items: items,
  179. },
  180. },
  181. })
  182. volumeMounts = append(volumeMounts, corev1.VolumeMount{
  183. Name: skillName,
  184. MountPath: fmt.Sprintf("%s/skills/%s", skillsRoot, skillName),
  185. ReadOnly: true,
  186. })
  187. }
  188. tmpl.Spec.Volumes = append(tmpl.Spec.Volumes, volumes...)
  189. tmpl.Spec.Runtime.VolumeMounts = append(tmpl.Spec.Runtime.VolumeMounts, volumeMounts...)
  190. return hex.EncodeToString(skillsHash.Sum(nil)), nil
  191. }
  192. type mcpConfig struct {
  193. MCPServers map[string]mcpServer `json:"mcpServers"`
  194. }
  195. type mcpServer struct {
  196. URL string `json:"url"`
  197. Headers map[string]string `json:"headers,omitempty"`
  198. }
  199. // reconcileMcpConfig writes the agent's mcp.json as a Secret (not a ConfigMap): when a
  200. // gateway is present, the generated config embeds the resolved litellm master key value
  201. // in the auth header, so the credential must not land in a ConfigMap.
  202. func (r *AgentReconciler) reconcileMcpConfig(ctx context.Context, comps *AgentComponents) (string, error) {
  203. cfg := mcpConfig{MCPServers: make(map[string]mcpServer)}
  204. if comps.Gateway.URL != "" && len(comps.Tools) > 0 {
  205. toolNames := make([]string, 0, len(comps.Tools))
  206. for _, tool := range comps.Tools {
  207. toolNames = append(toolNames, tool.Name)
  208. }
  209. sort.Strings(toolNames)
  210. cfg.MCPServers["litellm"] = mcpServer{
  211. URL: fmt.Sprintf("%s/%s/mcp", comps.Gateway.URL, strings.Join(toolNames, ",")),
  212. Headers: map[string]string{
  213. litellm.MCPAuthHeaderName: "Bearer " + comps.GatewayKey,
  214. },
  215. }
  216. } else {
  217. for _, tool := range comps.Tools {
  218. cfg.MCPServers[tool.Name] = mcpServer{URL: tool.URL}
  219. }
  220. }
  221. jsonBytes, err := json.Marshal(cfg)
  222. if err != nil {
  223. return "", fmt.Errorf("marshaling agent mcp config: %w", err)
  224. }
  225. h := sha256.Sum256(jsonBytes)
  226. configHash := hex.EncodeToString(h[:])
  227. secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-mcp-config", r.component.GetName()), Namespace: r.component.GetNamespace()}}
  228. _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
  229. if secret.Labels == nil {
  230. secret.Labels = map[string]string{}
  231. }
  232. maps.Copy(secret.Labels, ResourceLabels(r.stack.Name, r.component.Spec.Category, r.component.GetName(), ""))
  233. if secret.Annotations == nil {
  234. secret.Annotations = make(map[string]string)
  235. }
  236. secret.Annotations["hash"] = configHash
  237. secret.Data = map[string][]byte{
  238. "mcp.json": jsonBytes,
  239. }
  240. return controllerutil.SetControllerReference(r.component, secret, r.scheme)
  241. })
  242. return configHash, err
  243. }