Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions internal/embed/infrastructure/base/templates/agent-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,82 @@ spec:
type: object
spec:
properties:
disabledToolsets:
description: |-
Hermes agent.disabled_toolsets. Nil = ["memory","web"] (historical
default). Explicit empty list disables none.
items:
maxLength: 64
type: string
maxItems: 32
type: array
maxTurns:
description: Hermes agent.max_turns. Nil = 30 (historical sub-agent
default).
maximum: 10000
minimum: 1
type: integer
mcpServers:
description: |-
MCP servers rendered into Hermes config.yaml under mcp_servers.
Empty/omitted: no mcp_servers block (byte-identical to pre-field config).
items:
description: |-
MCPServer is one Hermes MCP server entry (stdio transport). Env values
may use ${VAR} interpolation; Hermes resolves them in-pod at runtime —
the controller does not expand them.
properties:
args:
description: Arguments passed to Command. Omitted from YAML
when empty.
items:
type: string
maxItems: 64
type: array
command:
description: Command to launch the MCP server process.
maxLength: 256
minLength: 1
type: string
env:
additionalProperties:
type: string
description: |-
Environment variables for the MCP process. Values may contain
${VAR} placeholders resolved by Hermes in-pod (not by the controller).
maxProperties: 64
type: object
name:
description: Server name used as the key under mcp_servers in
config.yaml.
maxLength: 64
minLength: 1
pattern: ^[a-zA-Z0-9][a-zA-Z0-9._-]*$
type: string
timeout:
description: Optional process timeout in seconds. Omitted from
YAML when nil.
minimum: 1
type: integer
required:
- command
- name
type: object
maxItems: 32
type: array
model:
description: |-
LiteLLM model name to pin. Empty = controller picks cluster
top-of-rank on first deploy and writes status.pinnedModel.
maxLength: 256
type: string
modelProvider:
description: |-
Hermes model provider. Empty or "custom" keeps the cluster LiteLLM
path (base_url + api_key). Other values (e.g. "xai-oauth") omit those
and resolve credentials in-pod.
maxLength: 64
type: string
objective:
description: |-
Operator-supplied objective text. Substituted into the SOUL.md
Expand Down
47 changes: 45 additions & 2 deletions internal/monetizeapi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,11 @@ type AgentSpec struct {
// top-of-rank on first deploy and writes status.pinnedModel.
// +kubebuilder:validation:MaxLength=256
Model string `json:"model,omitempty"`
// Hermes model provider. Empty or "custom" keeps the cluster LiteLLM
// path (base_url + api_key). Other values (e.g. "xai-oauth") omit those
// and resolve credentials in-pod.
// +kubebuilder:validation:MaxLength=64
ModelProvider string `json:"modelProvider,omitempty"`
// Allow-listed skills written to the per-agent skills dir on first
// reconcile. Agent can edit afterwards; this is a seed, not a sandbox.
// +kubebuilder:validation:MaxItems=64
Expand All @@ -910,8 +915,46 @@ type AgentSpec struct {
// Operator-supplied objective text. Substituted into the SOUL.md
// template by the seeder on first write. Agent owns SOUL.md after that.
// +kubebuilder:validation:MaxLength=4096
Objective string `json:"objective,omitempty"`
Wallet AgentWallet `json:"wallet,omitempty"`
Objective string `json:"objective,omitempty"`
// MCP servers rendered into Hermes config.yaml under mcp_servers.
// Empty/omitted: no mcp_servers block (byte-identical to pre-field config).
// +kubebuilder:validation:MaxItems=32
MCPServers []MCPServer `json:"mcpServers,omitempty"`
// Hermes agent.max_turns. Nil = 30 (historical sub-agent default).
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10000
MaxTurns *int `json:"maxTurns,omitempty"`
// Hermes agent.disabled_toolsets. Nil = ["memory","web"] (historical
// default). Explicit empty list disables none.
// +kubebuilder:validation:MaxItems=32
// +kubebuilder:validation:items:MaxLength=64
DisabledToolsets []string `json:"disabledToolsets,omitempty"`
Wallet AgentWallet `json:"wallet,omitempty"`
}

// MCPServer is one Hermes MCP server entry (stdio transport). Env values
// may use ${VAR} interpolation; Hermes resolves them in-pod at runtime —
// the controller does not expand them.
type MCPServer struct {
// Server name used as the key under mcp_servers in config.yaml.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=64
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`
Name string `json:"name"`
// Command to launch the MCP server process.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=256
Command string `json:"command"`
// Arguments passed to Command. Omitted from YAML when empty.
// +kubebuilder:validation:MaxItems=64
Args []string `json:"args,omitempty"`
// Optional process timeout in seconds. Omitted from YAML when nil.
// +kubebuilder:validation:Minimum=1
Timeout *int `json:"timeout,omitempty"`
// Environment variables for the MCP process. Values may contain
// ${VAR} placeholders resolved by Hermes in-pod (not by the controller).
// +kubebuilder:validation:MaxProperties=64
Env map[string]string `json:"env,omitempty"`
}

type AgentWallet struct {
Expand Down
49 changes: 49 additions & 0 deletions internal/monetizeapi/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 87 additions & 0 deletions internal/serviceoffercontroller/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package serviceoffercontroller

import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"log"
Expand Down Expand Up @@ -33,6 +34,19 @@ const (
// True only when both Validated and Provisioned are True.
agentConditionReady = "Ready"

// agentConditionConfigDrift reports whether the live hermes-config
// ConfigMap was edited out-of-band relative to the hash the
// controller stamped on its last write. True only when reconcile
// skips the ConfigMap apply (desired hash still matches the stored
// annotation) but live data no longer hashes to that annotation.
agentConditionConfigDrift = "ConfigDrift"

// hermesConfigHashAnnotation is stamped on hermes-config ConfigMaps
// with the sha256 hex of data["config.yaml"] at the controller's last
// write. Survives controller-pod restarts so provisionAgent can skip
// rewrites when the CR-rendered desired config is unchanged.
hermesConfigHashAnnotation = "obol.org/hermes-config-hash"

// agentFinalizer keeps the CR around until the controller has had a
// chance to tear down per-agent resources (Deployments, PVCs,
// Secrets, etc.) ahead of the namespace deletion that K8s' GC would
Expand Down Expand Up @@ -295,6 +309,11 @@ func (c *Controller) updateAgentStatus(ctx context.Context, raw *unstructured.Un
// shared cluster Secret so sub-agents can route inference. Resources
// are server-side-applied with the controller's field manager so
// repeated reconciles converge instead of fighting each other.
//
// hermes-config is special-cased: when the live ConfigMap already carries
// an annotation equal to the freshly-rendered desired hash, the apply is
// skipped so operator out-of-band edits are not clobbered every reconcile.
// Out-of-band drift is reported via the ConfigDrift condition instead.
func (c *Controller) provisionAgent(ctx context.Context, agent *monetizeapi.Agent, status *monetizeapi.AgentStatus) error {
apiKey, err := c.ensureAgentAPIKey(ctx, agent)
if err != nil {
Expand All @@ -315,13 +334,81 @@ func (c *Controller) provisionAgent(ctx context.Context, agent *monetizeapi.Agen
}

for _, m := range manifests {
if m.GetKind() == "ConfigMap" && m.GetName() == hermesConfigMap {
if err := c.applyOrSkipHermesConfig(ctx, agent, status, m); err != nil {
return err
}
continue
}
if err := c.applyAgentObject(ctx, c.resourceFor(m), m); err != nil {
return fmt.Errorf("apply %s/%s %s: %w", m.GetKind(), m.GetName(), m.GetNamespace(), err)
}
}
return nil
}

// applyOrSkipHermesConfig gates the hermes-config ConfigMap write on the
// content hash stamped as hermesConfigHashAnnotation on the live object.
// When desired hash equals the stored annotation, the apply is skipped so
// controller restarts and no-op reconciles do not rewrite the ConfigMap.
// Drift (live data no longer matching the stored annotation) is surfaced
// on status rather than clobbered.
func (c *Controller) applyOrSkipHermesConfig(ctx context.Context, agent *monetizeapi.Agent, status *monetizeapi.AgentStatus, desired *unstructured.Unstructured) error {
configYAML, _, _ := unstructured.NestedString(desired.Object, "data", "config.yaml")
desiredHash := fmt.Sprintf("%x", sha256.Sum256([]byte(configYAML)))

live, err := c.configMaps.Namespace(agent.Namespace).Get(ctx, hermesConfigMap, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
live = nil
err = nil
}
if err != nil {
return fmt.Errorf("get hermes-config: %w", err)
}

skipApply, drift := hermesConfigDecision(live, desiredHash)
if skipApply {
if drift {
setAgentCondition(status, agentConditionConfigDrift, "True", "OutOfBandEdit",
"live hermes-config content differs from what the controller last wrote")
} else {
setAgentCondition(status, agentConditionConfigDrift, "False", "InSync",
"live hermes-config content matches controller-written hash")
}
return nil
}

if err := c.applyAgentObject(ctx, c.resourceFor(desired), desired); err != nil {
return fmt.Errorf("apply %s/%s %s: %w", desired.GetKind(), desired.GetName(), desired.GetNamespace(), err)
}
setAgentCondition(status, agentConditionConfigDrift, "False", "InSync",
"live hermes-config content matches controller-written hash")
return nil
}

// hermesConfigDecision decides whether provisionAgent should skip applying
// the hermes-config ConfigMap and whether live content has drifted out-of-band.
//
// live == nil means ConfigMap not found (Get returned apierrors.IsNotFound).
// skipApply is true only when desiredHash equals the hash annotation the
// controller stamped on its last write — never by comparing desired content
// against live content directly (that would treat deliberate operator edits
// as "drift to revert" and reintroduce every-reconcile wipes).
// drift is only meaningful when skipApply is true; when skipApply is false
// this function always returns drift=false.
func hermesConfigDecision(live *unstructured.Unstructured, desiredHash string) (skipApply, drift bool) {
if live == nil {
return false, false
}
stored, _, _ := unstructured.NestedString(live.Object, "metadata", "annotations", hermesConfigHashAnnotation)
if stored != desiredHash {
return false, false
}
liveData, _, _ := unstructured.NestedString(live.Object, "data", "config.yaml")
liveHash := fmt.Sprintf("%x", sha256.Sum256([]byte(liveData)))
return true, liveHash != desiredHash
}

// hasFinalizer reports whether the given raw CR carries the named
// finalizer. Mirrors the slices.Contains pattern the offer reconciler
// uses, but in unstructured-land where each finalizer is an `any`.
Expand Down
Loading
Loading