/* 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" "github.com/LocoStack/loco-operator/pkg/templates/opencode" 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" ) // LocoOpenCodeAgentReconciler reconciles the "opencode" template, running the OpenCode // CLI (https://opencode.ai) in headless server mode. Unlike loco-agents, OpenCode is // configured entirely through its own config file rather than a LocoStack-specific // shim, so this reconciler translates AgentComponents into OpenCode's native schema: // a custom "openai-compatible" provider for the model, native "mcp" entries for tools, // and native SKILL.md discovery for skills (via AddSkillsToTmpl). // // KnowledgeBases and Agents (subagents) dependencies are resolved by getDependencies // like any other agent template, but intentionally left unused here: OpenCode has no // native knowledge-base/RAG concept, and no native support for remote (A2A) subagents. type LocoOpenCodeAgentReconciler struct { *DefaultComponentReconciler *AgentReconciler client client.Client scheme *runtime.Scheme stack *v1alpha1.Stack component *v1alpha1.Component } func NewLocoOpenCodeAgentReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LocoOpenCodeAgentReconciler { return &LocoOpenCodeAgentReconciler{ DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component), AgentReconciler: NewAgentReconciler(client, scheme, stack, component), client: client, scheme: scheme, stack: stack, component: component, } } func (r *LocoOpenCodeAgentReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) { deps, comps, err := r.getDependencies(ctx) if err != nil { return nil, fmt.Errorf("Failed to get dependencies for %s: %w", r.component.Name, err) } configHash, err := r.reconcileOpenCodeConfig(ctx, r.component.Spec.Variables["systemPrompt"], comps) if err != nil { return nil, fmt.Errorf("Failed to reconcile config for %s: %w", r.component.Name, err) } skillsHash, err := r.AddSkillsToTmpl(ctx, comps, tmpl, opencode.OpenCodeSkillsRoot) if err != nil { return nil, fmt.Errorf("Failed to mount skills for %s: %w", r.component.Name, err) } if tmpl.Metadata.Annotations == nil { tmpl.Metadata.Annotations = make(map[string]string) } // OpenCode discovers its skills at startup and the mounted ConfigMaps change under a // running Pod, so the skill files count towards the Pod template just like the config h := sha256.Sum256([]byte(configHash + skillsHash)) maps.Copy(tmpl.Metadata.Annotations, map[string]string{ "locostack.com/configHash": hex.EncodeToString(h[:]), }) if _, err := r.DefaultComponentReconciler.ReconcileComponent(ctx, tmpl, variables); err != nil { return nil, err } return deps, nil } type openCodeProviderModel struct { Name string `json:"name,omitempty"` } type openCodeProviderOptions struct { BaseURL string `json:"baseURL"` APIKey string `json:"apiKey,omitempty"` } type openCodeProvider struct { NPM string `json:"npm"` Name string `json:"name,omitempty"` Options openCodeProviderOptions `json:"options"` Models map[string]openCodeProviderModel `json:"models"` } type openCodeMCPServer struct { Type string `json:"type"` URL string `json:"url"` Headers map[string]string `json:"headers,omitempty"` } type openCodeAgentConfig struct { Prompt string `json:"prompt,omitempty"` } type openCodeConfig struct { Schema string `json:"$schema"` Provider map[string]openCodeProvider `json:"provider"` Model string `json:"model,omitempty"` MCP map[string]openCodeMCPServer `json:"mcp,omitempty"` Agent map[string]openCodeAgentConfig `json:"agent,omitempty"` } const openCodeModelProviderID = "locostack" func (r *LocoOpenCodeAgentReconciler) reconcileOpenCodeConfig(ctx context.Context, systemPrompt string, comps *AgentComponents) (string, error) { cfg := openCodeConfig{ Schema: "https://opencode.ai/config.json", Provider: map[string]openCodeProvider{ openCodeModelProviderID: { NPM: "@ai-sdk/openai-compatible", Name: "LocoStack", Options: openCodeProviderOptions{ BaseURL: comps.Model.URL, APIKey: comps.GatewayKey, }, Models: map[string]openCodeProviderModel{ comps.Model.Name: {}, }, }, }, Model: fmt.Sprintf("%s/%s", openCodeModelProviderID, comps.Model.Name), } if systemPrompt != "" { cfg.Agent = map[string]openCodeAgentConfig{ "build": {Prompt: systemPrompt}, } } if len(comps.Tools) > 0 { cfg.MCP = make(map[string]openCodeMCPServer) if comps.Gateway.URL != "" { toolNames := make([]string, 0, len(comps.Tools)) for _, tool := range comps.Tools { toolNames = append(toolNames, tool.Name) } sort.Strings(toolNames) cfg.MCP["litellm"] = openCodeMCPServer{ Type: "remote", URL: fmt.Sprintf("%s/%s/mcp", comps.Gateway.URL, strings.Join(toolNames, ",")), Headers: map[string]string{ litellm.MCPAuthHeaderName: "Bearer " + comps.GatewayKey, }, } } else { for _, tool := range comps.Tools { cfg.MCP[tool.Name] = openCodeMCPServer{Type: "remote", URL: tool.URL} } } } jsonBytes, err := json.Marshal(cfg) if err != nil { return "", fmt.Errorf("marshaling opencode config: %w", err) } h := sha256.Sum256(jsonBytes) configHash := hex.EncodeToString(h[:]) 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{ "opencode.json": jsonBytes, } return controllerutil.SetControllerReference(r.component, secret, r.scheme) }) return configHash, err }