Skip to content
Open
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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ This folder is hosted as a separate [documentation site](https://docs.agentic-la
- **Agent Controller** (`internal/controller/agent_controller.go`): Reconciles Agent resources by:
- Creating Kubernetes Deployments for agent workloads
- Managing Services for protocol exposure
- **Protocol-aware health checking**: Automatically generates appropriate readiness probes
- A2A agents: HTTP GET with configurable paths (validates agent functionality)
- OpenAI agents: TCP socket probe (validates service availability)
- Priority: A2A > OpenAI > No probe
- No protocols: No readiness probe
- Handling framework-specific configurations

- **Admission Webhooks** (`internal/webhook/v1alpha1/`): Provides validation and mutation for Agent resources
Expand Down
1 change: 1 addition & 0 deletions config/samples/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
resources:
- runtime_v1alpha1_agent.yaml
- runtime_v1alpha1_agent_template.yaml
- runtime_v1alpha1_agent_openai.yaml
- configmap.yaml
# +kubebuilder:scaffold:manifestskustomizesamples
1 change: 1 addition & 0 deletions config/samples/runtime_v1alpha1_agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ spec:
image: ghcr.io/agentic-layer/weather-agent:0.3.0
protocols:
- type: A2A
path: "/"
replicas: 1
env:
- name: PORT
Expand Down
22 changes: 22 additions & 0 deletions config/samples/runtime_v1alpha1_agent_openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: runtime.agentic-layer.ai/v1alpha1
kind: Agent
metadata:
labels:
app.kubernetes.io/name: agent-runtime-operator
app.kubernetes.io/managed-by: kustomize
name: openai-agent
spec:
framework: custom
image: ghcr.io/example/openai-agent:latest
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There it is! 🙈 I've looked for the source of the openai-agent recently in our cluster...

Please do not include invalid agents in the sample directory. It should only include working examples.

protocols:
- type: OpenAI # Will generate TCP readiness probe on port 8000
port: 8000
replicas: 1
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: openai-secret
key: api-key
- name: PORT
value: "8000"
12 changes: 7 additions & 5 deletions config/samples/runtime_v1alpha1_agent_template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ spec:
subAgents:
- name: summarizer_agent
url: "https://agents.example.com/summarizer-agent.json"
tools:
- name: news_fetcher
url: "https://news.mcpservers.org/fetch/mcp"
- name: web_fetch
url: "https://remote.mcpservers.org/fetch/mcp"
# Uncommented for now since health probe needs working mcp tools
# tools:
# - name: news_fetcher
# url: "https://news.mcpservers.org/fetch/mcp"
# - name: web_fetch
# url: "https://remote.mcpservers.org/fetch/mcp"
protocols:
- type: A2A
path: "/"
replicas: 1
env:
- name: LOG_LEVEL
Expand Down
152 changes: 146 additions & 6 deletions internal/controller/agent_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import (

const (
agentContainerName = "agent"
agentCardEndpoint = "/.well-known/agent-card.json"
a2AProtocol = "A2A"
)

// AgentReconciler reconciles a Agent object
Expand Down Expand Up @@ -356,6 +358,99 @@ func (r *AgentReconciler) mergeEnvironmentVariables(templateEnvVars, userEnvVars
return result
}

// hasA2AProtocol checks if the agent has A2A protocol configured
func (r *AgentReconciler) hasA2AProtocol(agent *runtimev1alpha1.Agent) bool {
for _, protocol := range agent.Spec.Protocols {

if protocol.Type == a2AProtocol {
return true
}
}
return false
}

// hasOpenAIProtocol checks if the agent has OpenAI protocol configured
func (r *AgentReconciler) hasOpenAIProtocol(agent *runtimev1alpha1.Agent) bool {
for _, protocol := range agent.Spec.Protocols {
if protocol.Type == "OpenAI" {
return true
}
}
return false
}

// getA2AProtocol returns the first A2A protocol configuration found
func (r *AgentReconciler) getA2AProtocol(agent *runtimev1alpha1.Agent) *runtimev1alpha1.AgentProtocol {
for _, protocol := range agent.Spec.Protocols {
if protocol.Type == a2AProtocol {
return &protocol
}
}
return nil
}

// getOpenAIProtocol returns the first OpenAI protocol configuration found
func (r *AgentReconciler) getOpenAIProtocol(agent *runtimev1alpha1.Agent) *runtimev1alpha1.AgentProtocol {
for _, protocol := range agent.Spec.Protocols {
if protocol.Type == "OpenAI" {
return &protocol
}
}
return nil
}

// getProtocolPort returns the port from protocol or default if not specified
func (r *AgentReconciler) getProtocolPort(protocol *runtimev1alpha1.AgentProtocol) int32 {
defaultPort := int32(8000) // Default port if none specified
if protocol != nil && protocol.Port != 0 {
return protocol.Port
}
return defaultPort
}

// getA2AHealthPath returns the A2A health check path based on protocol configuration
func (r *AgentReconciler) getA2AHealthPath(protocol *runtimev1alpha1.AgentProtocol) string {
basePath := agentCardEndpoint
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basePath is usually referred to as the prefix, not the suffix. The variable indirection is not really needed here anyways.

if protocol != nil && protocol.Path != "" {
// If path is explicitly specified, use it
// Special case: "/" means root path (no prefix)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a need for a special case here. The default is an empty path. If a user explicitly configures /, it should be used as-is.

if protocol.Path == "/" {
return basePath
}
return protocol.Path + basePath
}
// Default for agents without protocol specification or path
return "/a2a" + basePath
}

// generateReadinessProbe generates appropriate readiness probe based on agent protocols
func (r *AgentReconciler) generateReadinessProbe(agent *runtimev1alpha1.Agent) *corev1.Probe {
// Check if agent has external dependencies (subAgents or tools)

// Priority: A2A > OpenAI > None
if r.hasA2AProtocol(agent) {
// Use A2A agent card endpoint for health check
a2aProtocol := r.getA2AProtocol(agent)
healthPath := r.getA2AHealthPath(a2aProtocol)
port := r.getProtocolPort(a2aProtocol)

probe := r.buildA2AReadinessProbe(healthPath, port)

return probe
} else if r.hasOpenAIProtocol(agent) {
// Use TCP probe for OpenAI-only agents
openaiProtocol := r.getOpenAIProtocol(agent)
port := r.getProtocolPort(openaiProtocol)

probe := r.buildOpenAIReadinessProbe(port)

return probe
}

// No recognized protocols - no readiness probe
return nil
}

// createDeploymentForAgent creates a deployment for the given Agent
func (r *AgentReconciler) createDeploymentForAgent(agent *runtimev1alpha1.Agent, deploymentName string) (*appsv1.Deployment, error) {
replicas := agent.Spec.Replicas
Expand Down Expand Up @@ -416,11 +511,12 @@ func (r *AgentReconciler) createDeploymentForAgent(agent *runtimev1alpha1.Agent,
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: agentContainerName,
Image: agentImage,
Ports: containerPorts,
Env: allEnvVars,
EnvFrom: agent.Spec.EnvFrom,
Name: agentContainerName,
Image: agentImage,
Ports: containerPorts,
Env: allEnvVars,
EnvFrom: agent.Spec.EnvFrom,
ReadinessProbe: r.generateReadinessProbe(agent),
},
},
},
Expand Down Expand Up @@ -524,6 +620,11 @@ func (r *AgentReconciler) needsDeploymentUpdate(existing, desired *appsv1.Deploy
return true
}

// Check readiness probes
if !r.probesEqual(existingContainer.ReadinessProbe, desiredContainer.ReadinessProbe) {
return true
}

return false
}

Expand Down Expand Up @@ -559,6 +660,7 @@ func (r *AgentReconciler) updateAgentContainer(deployment, desiredDeployment *ap
agentContainer.Ports = desiredAgentContainer.Ports
agentContainer.Env = desiredAgentContainer.Env
agentContainer.EnvFrom = desiredAgentContainer.EnvFrom
agentContainer.ReadinessProbe = desiredAgentContainer.ReadinessProbe

return nil
}
Expand Down Expand Up @@ -663,6 +765,11 @@ func (r *AgentReconciler) servicePortsEqual(existing, desired []corev1.ServicePo
return true
}

// probesEqual compares two readiness probes for equality
func (r *AgentReconciler) probesEqual(existing, desired *corev1.Probe) bool {
return equality.ProbesEqual(existing, desired)
}

// sanitizeAgentName sanitizes the agent name to meet environment variable naming requirements.
// Environment variable names should start with a letter (a-z, A-Z) or underscore (_),
// and can only contain letters, digits (0-9), and underscores.
Expand Down Expand Up @@ -709,7 +816,7 @@ func (r *AgentReconciler) sanitizeAgentName(name string) string {
func (r *AgentReconciler) buildA2AAgentCardUrl(agent *runtimev1alpha1.Agent) string {
// Find the A2A protocol
for _, protocol := range agent.Spec.Protocols {
if protocol.Type == "A2A" {
if protocol.Type == a2AProtocol {
return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s",
agent.Name, agent.Namespace, protocol.Port, protocol.Path)
}
Expand All @@ -726,3 +833,36 @@ func (r *AgentReconciler) SetupWithManager(mgr ctrl.Manager) error {
Named("agent").
Complete(r)
}

// buildOpenAIReadinessProbe creates TCP-readiness-probe for OpenAI-protocols
func (r *AgentReconciler) buildOpenAIReadinessProbe(port int32) *corev1.Probe {
return &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
TCPSocket: &corev1.TCPSocketAction{
Port: intstr.FromInt(int(port)),
},
},
InitialDelaySeconds: 60,
PeriodSeconds: 10,
TimeoutSeconds: 3,
SuccessThreshold: 1,
FailureThreshold: 3,
}
}

// buildA2AReadinessProbe creates HTTP-readiness-probe for A2A-protocols.
func (r *AgentReconciler) buildA2AReadinessProbe(healthPath string, port int32) *corev1.Probe {
return &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: healthPath,
Port: intstr.FromInt(int(port)),
},
},
InitialDelaySeconds: 60,
PeriodSeconds: 10,
TimeoutSeconds: 3,
SuccessThreshold: 1,
FailureThreshold: 3,
}
}
Loading
Loading