From cc2f86cd41ad3ca1378de62764ca0b1d03e630cb Mon Sep 17 00:00:00 2001 From: Vimal Kumar Date: Tue, 14 Jul 2026 11:46:25 +0530 Subject: [PATCH] OLS-3422 Add NoActionRequired terminal phase for AgenticRun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the analysis agent determines no remediation is needed (false alarm, self-healed issue), the AgenticRun now auto-terminates to NoActionRequired instead of proceeding to execution. Supports revision feedback to re-analyze. - Add AgenticRunPhaseNoActionRequired phase constant and DerivePhase logic - Add ActionRequiredValue string enum (True/False) to AnalysisResultStatus - Add DiagnosisResult field to AnalysisResultStatus (value type with omitzero) - Wire actionRequired and diagnosis through agent output, schemas, and handlers - Support revision feedback: NoActionRequired → re-analysis on revision - Add unit tests for phase derivation, schemas, state machine, and handlers Co-Authored-By: Claude Opus 4.6 Signed-off-by: Vimal Kumar --- api/v1alpha1/agenticrun_types.go | 5 + api/v1alpha1/analysisresult_types.go | 34 ++++- api/v1alpha1/derive_phase_test.go | 23 ++++ .../agentic.openshift.io_analysisresults.yaml | 47 ++++++- controller/agenticrun/agent.go | 18 ++- controller/agenticrun/handlers.go | 26 ++++ controller/agenticrun/handlers_test.go | 13 +- controller/agenticrun/helpers.go | 5 +- controller/agenticrun/reconciler.go | 23 +++- controller/agenticrun/results.go | 6 + controller/agenticrun/sandbox_agent.go | 17 ++- controller/agenticrun/schemas.go | 40 +++++- controller/agenticrun/schemas_test.go | 72 ++++++++++ controller/agenticrun/state_machine_test.go | 129 ++++++++++++++++++ examples/setup/09-no-action-required-run.yaml | 38 ++++++ 15 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 examples/setup/09-no-action-required-run.yaml diff --git a/api/v1alpha1/agenticrun_types.go b/api/v1alpha1/agenticrun_types.go index 9ba5340d..311c4d83 100644 --- a/api/v1alpha1/agenticrun_types.go +++ b/api/v1alpha1/agenticrun_types.go @@ -39,6 +39,7 @@ const ( AgenticRunPhaseEscalating AgenticRunPhase = "Escalating" AgenticRunPhaseEscalated AgenticRunPhase = "Escalated" AgenticRunPhaseEmergencyStopped AgenticRunPhase = "EmergencyStopped" + AgenticRunPhaseNoActionRequired AgenticRunPhase = "NoActionRequired" ) // Condition reasons used by DerivePhase for state transitions. @@ -46,6 +47,7 @@ const ( const ( ReasonRetryingExecution = "RetryingExecution" ReasonRetriesExhausted = "RetriesExhausted" + ReasonNoActionRequired = "NoActionRequired" ) // DerivePhase computes the display phase from conditions. Conditions are @@ -112,6 +114,9 @@ func DerivePhase(conditions []metav1.Condition) AgenticRunPhase { if c := get(AgenticRunConditionAnalyzed); c != nil { switch c.Status { case metav1.ConditionTrue: + if c.Reason == ReasonNoActionRequired { + return AgenticRunPhaseNoActionRequired + } return AgenticRunPhaseProposed case metav1.ConditionUnknown: return AgenticRunPhaseAnalyzing diff --git a/api/v1alpha1/analysisresult_types.go b/api/v1alpha1/analysisresult_types.go index 1fc6d1e7..7662b468 100644 --- a/api/v1alpha1/analysisresult_types.go +++ b/api/v1alpha1/analysisresult_types.go @@ -20,6 +20,26 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// ActionRequiredValue is a string enum indicating whether the analysis +// determined that remediation action is required. Uses string values +// "True"/"False" instead of a boolean to satisfy the kube-api-linter +// nobools rule. +// +kubebuilder:validation:Enum=True;False +type ActionRequiredValue string + +const ( + ActionRequiredTrue ActionRequiredValue = "True" + ActionRequiredFalse ActionRequiredValue = "False" +) + +// ActionRequiredFromBool converts a Go bool to ActionRequiredValue. +func ActionRequiredFromBool(b bool) ActionRequiredValue { + if b { + return ActionRequiredTrue + } + return ActionRequiredFalse +} + // AnalysisResultStatus is the status of an AnalysisResult. // // +kubebuilder:validation:MinProperties=1 @@ -34,10 +54,22 @@ type AnalysisResultStatus struct { // +kubebuilder:validation:MaxItems=8 Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + // actionRequired indicates whether the analysis determined that + // remediation action is needed. When "False", the run transitions + // to NoActionRequired (terminal) instead of Proposed. + // +optional + ActionRequired ActionRequiredValue `json:"actionRequired,omitempty"` + + // diagnosis is the top-level root cause analysis, populated when + // actionRequired is "False" (no remediation options are generated). + // When options are present, each option carries its own diagnosis. + // +optional + Diagnosis DiagnosisResult `json:"diagnosis,omitzero"` + // options contains the remediation options returned by the analysis agent. // +optional // +listType=atomic - // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MinItems=0 // +kubebuilder:validation:MaxItems=10 Options []RemediationOption `json:"options,omitempty"` diff --git a/api/v1alpha1/derive_phase_test.go b/api/v1alpha1/derive_phase_test.go index 207cf51e..ee470a45 100644 --- a/api/v1alpha1/derive_phase_test.go +++ b/api/v1alpha1/derive_phase_test.go @@ -217,6 +217,29 @@ func TestDerivePhase(t *testing.T) { }, want: AgenticRunPhaseProposed, }, + { + name: "no action required", + conditions: []metav1.Condition{ + cond(AgenticRunConditionAnalyzed, metav1.ConditionTrue, ReasonNoActionRequired), + }, + want: AgenticRunPhaseNoActionRequired, + }, + { + name: "no action required overridden by denied", + conditions: []metav1.Condition{ + cond(AgenticRunConditionAnalyzed, metav1.ConditionTrue, ReasonNoActionRequired), + cond(AgenticRunConditionDenied, metav1.ConditionTrue, "UserDenied"), + }, + want: AgenticRunPhaseDenied, + }, + { + name: "no action required overridden by emergency stopped", + conditions: []metav1.Condition{ + cond(AgenticRunConditionAnalyzed, metav1.ConditionTrue, ReasonNoActionRequired), + cond(AgenticRunConditionEmergencyStopped, metav1.ConditionTrue, "SystemSuspended"), + }, + want: AgenticRunPhaseEmergencyStopped, + }, } for _, tt := range tests { diff --git a/config/crd/bases/agentic.openshift.io_analysisresults.yaml b/config/crd/bases/agentic.openshift.io_analysisresults.yaml index 3783a419..80d2eedf 100644 --- a/config/crd/bases/agentic.openshift.io_analysisresults.yaml +++ b/config/crd/bases/agentic.openshift.io_analysisresults.yaml @@ -68,6 +68,15 @@ spec: description: status contains result data and conditions. minProperties: 1 properties: + actionRequired: + description: |- + actionRequired indicates whether the analysis determined that + remediation action is needed. When "False", the run transitions + to NoActionRequired (terminal) instead of Proposed. + enum: + - "True" + - "False" + type: string conditions: description: conditions track the lifecycle of this result. items: @@ -130,6 +139,42 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + diagnosis: + description: |- + diagnosis is the top-level root cause analysis, populated when + actionRequired is "False" (no remediation options are generated). + When options are present, each option carries its own diagnosis. + properties: + confidence: + description: |- + confidence is the agent's self-assessed confidence in its diagnosis. + Higher confidence generally correlates with clearer symptoms and + more deterministic root causes. + enum: + - Low + - Medium + - High + type: string + rootCause: + description: |- + rootCause is a concise Markdown-formatted description of the identified + root cause (e.g., "OOMKilled due to memory limit of 256Mi"). + Maximum 1024 characters. + maxLength: 1024 + minLength: 1 + type: string + summary: + description: |- + summary is a Markdown-formatted diagnosis summary explaining the + problem, its symptoms, and the agent's findings. Maximum 8192 characters. + maxLength: 8192 + minLength: 1 + type: string + required: + - confidence + - rootCause + - summary + type: object failureReason: description: failureReason is populated when the step failed due to a system error. @@ -586,7 +631,7 @@ spec: - message: diagnosis is required when remediationPlan is present rule: '!has(self.remediationPlan) || has(self.diagnosis)' maxItems: 10 - minItems: 1 + minItems: 0 type: array x-kubernetes-list-type: atomic sandbox: diff --git a/controller/agenticrun/agent.go b/controller/agenticrun/agent.go index d21ff378..07ea3d89 100644 --- a/controller/agenticrun/agent.go +++ b/controller/agenticrun/agent.go @@ -7,9 +7,19 @@ import ( ) // AnalysisOutput holds the analysis agent's output. +// ActionRequired is a pointer: nil means "action required" (safe default, +// backward-compatible with agents that don't emit the field). type AnalysisOutput struct { - Success bool - Options []agenticv1alpha1.RemediationOption + Success bool + ActionRequired *bool + Options []agenticv1alpha1.RemediationOption + Diagnosis *agenticv1alpha1.DiagnosisResult +} + +// IsActionRequired returns true when ActionRequired is nil (safe default) +// or explicitly true. +func (a *AnalysisOutput) IsActionRequired() bool { + return a.ActionRequired == nil || *a.ActionRequired } // ExecutionOutput holds the execution agent's output. @@ -54,8 +64,10 @@ type AgentCaller interface { type StubAgentCaller struct{} func (s *StubAgentCaller) Analyze(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string) (*AnalysisOutput, error) { + actionRequired := true return &AnalysisOutput{ - Success: true, + Success: true, + ActionRequired: &actionRequired, Options: []agenticv1alpha1.RemediationOption{{ Title: "Stub remediation", Diagnosis: agenticv1alpha1.DiagnosisResult{ diff --git a/controller/agenticrun/handlers.go b/controller/agenticrun/handlers.go index 9df931b1..069408b7 100644 --- a/controller/agenticrun/handlers.go +++ b/controller/agenticrun/handlers.go @@ -117,6 +117,11 @@ func (r *AgenticRunReconciler) handleAnalysis( r.Audit.EmitAnalysisCompleted(spanCtx, run, analysisCR) } run.Status.Steps.Analysis.Results = append(run.Status.Steps.Analysis.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(analysisResult.Success)}) + + if !analysisResult.IsActionRequired() { + return r.setNoActionRequired(ctx, run, base, ErrUpdateAfterAnalysis, "Analysis determined no remediation action is required") + } + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ Type: agenticv1alpha1.AgenticRunConditionAnalyzed, Status: metav1.ConditionTrue, @@ -202,6 +207,11 @@ func (r *AgenticRunReconciler) handleRevision( r.Audit.EmitAnalysisCompleted(spanCtx, run, analysisCR) } run.Status.Steps.Analysis.Results = append(run.Status.Steps.Analysis.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(analysisResult.Success)}) + + if !analysisResult.IsActionRequired() { + return r.setNoActionRequired(ctx, run, base, ErrUpdateAfterRevision, "Revision analysis determined no remediation action is required") + } + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ Type: agenticv1alpha1.AgenticRunConditionAnalyzed, Status: metav1.ConditionTrue, @@ -726,6 +736,22 @@ func (r *AgenticRunReconciler) handleEscalation( return ctrl.Result{}, nil } +func (r *AgenticRunReconciler) setNoActionRequired(ctx context.Context, run *agenticv1alpha1.AgenticRun, base *agenticv1alpha1.AgenticRun, errSentinel, message string) (ctrl.Result, error) { + log := logf.FromContext(ctx) + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: agenticv1alpha1.AgenticRunConditionAnalyzed, + Status: metav1.ConditionTrue, + Reason: reasonNoActionRequired, + Message: message, + ObservedGeneration: run.Generation, + }) + if err := r.statusPatch(ctx, run, base); err != nil { + return ctrl.Result{}, fmt.Errorf("%s: %w", errSentinel, err) + } + log.Info("no action required", "message", message) + return ctrl.Result{}, nil +} + func conditionTime(conditions []metav1.Condition, condType string) *metav1.Time { if c := meta.FindStatusCondition(conditions, condType); c != nil { return &c.LastTransitionTime diff --git a/controller/agenticrun/handlers_test.go b/controller/agenticrun/handlers_test.go index a05f5ff1..1e0011fd 100644 --- a/controller/agenticrun/handlers_test.go +++ b/controller/agenticrun/handlers_test.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -684,7 +685,8 @@ func TestReconcile_RevisionWithFeedback(t *testing.T) { func TestReconcile_ExecutionRBACCreatedOnApproval(t *testing.T) { agent := newTestAgentCaller() agent.analyzeResult = &AnalysisOutput{ - Success: true, + Success: true, + ActionRequired: ptr.To(true), Options: []agenticv1alpha1.RemediationOption{{ Title: "Increase memory", Diagnosis: agenticv1alpha1.DiagnosisResult{ @@ -769,7 +771,8 @@ func TestReconcile_ExecutionRBACCreatedOnApproval(t *testing.T) { func TestReconcile_ExecutionRBACCleanedOnFailure(t *testing.T) { agent := newTestAgentCaller() agent.analyzeResult = &AnalysisOutput{ - Success: true, + Success: true, + ActionRequired: ptr.To(true), Options: []agenticv1alpha1.RemediationOption{{ Title: "Fix it", Diagnosis: agenticv1alpha1.DiagnosisResult{ @@ -1082,7 +1085,8 @@ func TestReconcile_ExecutionSelectsOption(t *testing.T) { agent := newTestAgentCaller() agent.analyzeResult = &AnalysisOutput{ - Success: true, + Success: true, + ActionRequired: ptr.To(true), Options: []agenticv1alpha1.RemediationOption{ {Title: "Option A", Diagnosis: agenticv1alpha1.DiagnosisResult{Summary: "diag-A"}}, {Title: "Option B", Diagnosis: agenticv1alpha1.DiagnosisResult{Summary: "diag-B"}}, @@ -1159,7 +1163,8 @@ func TestReconcile_TrimOptionsOnExecution(t *testing.T) { agent := newTestAgentCaller() agent.analyzeResult = &AnalysisOutput{ - Success: true, + Success: true, + ActionRequired: ptr.To(true), Options: []agenticv1alpha1.RemediationOption{ {Title: "Option A", Diagnosis: agenticv1alpha1.DiagnosisResult{Summary: "diag-A"}}, {Title: "Option B", Diagnosis: agenticv1alpha1.DiagnosisResult{Summary: "diag-B"}}, diff --git a/controller/agenticrun/helpers.go b/controller/agenticrun/helpers.go index e5877677..ba0ba221 100644 --- a/controller/agenticrun/helpers.go +++ b/controller/agenticrun/helpers.go @@ -51,6 +51,7 @@ const ( reasonRetryingExecution = agenticv1alpha1.ReasonRetryingExecution reasonRetriesExhausted = agenticv1alpha1.ReasonRetriesExhausted reasonSystemSuspended = "SystemSuspended" + reasonNoActionRequired = agenticv1alpha1.ReasonNoActionRequired LogKeyName = "name" LogKeyStep = "step" @@ -150,7 +151,7 @@ func terminalReason(run *agenticv1alpha1.AgenticRun) string { if c.Status == metav1.ConditionFalse && c.Reason == reasonFailed { return c.Message } - if c.Status == metav1.ConditionTrue && (c.Reason == reasonUserDenied || c.Reason == reasonSystemSuspended) { + if c.Status == metav1.ConditionTrue && (c.Reason == reasonUserDenied || c.Reason == reasonSystemSuspended || c.Reason == reasonNoActionRequired) { return c.Message } } @@ -159,7 +160,7 @@ func terminalReason(run *agenticv1alpha1.AgenticRun) string { func isTerminal(phase agenticv1alpha1.AgenticRunPhase) bool { switch phase { - case agenticv1alpha1.AgenticRunPhaseCompleted, agenticv1alpha1.AgenticRunPhaseFailed, agenticv1alpha1.AgenticRunPhaseDenied, agenticv1alpha1.AgenticRunPhaseEscalated, agenticv1alpha1.AgenticRunPhaseEmergencyStopped: + case agenticv1alpha1.AgenticRunPhaseCompleted, agenticv1alpha1.AgenticRunPhaseFailed, agenticv1alpha1.AgenticRunPhaseDenied, agenticv1alpha1.AgenticRunPhaseEscalated, agenticv1alpha1.AgenticRunPhaseEmergencyStopped, agenticv1alpha1.AgenticRunPhaseNoActionRequired: return true } return false diff --git a/controller/agenticrun/reconciler.go b/controller/agenticrun/reconciler.go index a90ad006..19ef9508 100644 --- a/controller/agenticrun/reconciler.go +++ b/controller/agenticrun/reconciler.go @@ -85,6 +85,21 @@ func (r *AgenticRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) // --- Terminal phases (before suspension guard so audit cleanup always runs) --- switch phase { + case agenticv1alpha1.AgenticRunPhaseNoActionRequired: + if !needsRevision(&run) { + if hasSandboxClaims(&run) { + if err := r.Agent.ReleaseSandboxes(ctx, &run); err != nil { + log.Error(err, "sandbox cleanup failed at terminal phase") + } + } + if r.Audit != nil { + r.Audit.EndApprovalWait(&run, nil) + r.Audit.EmitAgenticRunTerminal(ctx, &run, string(phase), terminalReason(&run)) + r.Audit.EndLifecycleSpan(&run) + } + return ctrl.Result{}, nil + } + case agenticv1alpha1.AgenticRunPhaseCompleted, agenticv1alpha1.AgenticRunPhaseDenied, agenticv1alpha1.AgenticRunPhaseEscalated, @@ -135,7 +150,7 @@ func (r *AgenticRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) // Recover lifecycle trace context for in-progress runs after operator restart (§5). // Uses RecoverLifecycleContext (not EnsureLifecycleSpan) to avoid exporting a duplicate span. // Also restarts the approval wait span if the run is waiting for execution approval. - if r.Audit != nil && !isTerminal(phase) { + if r.Audit != nil && (!isTerminal(phase) || (phase == agenticv1alpha1.AgenticRunPhaseNoActionRequired && needsRevision(&run))) { r.Audit.RecoverLifecycleContext(ctx, &run) if phase == agenticv1alpha1.AgenticRunPhaseProposed { r.Audit.StartApprovalWait(ctx, &run) @@ -197,6 +212,12 @@ func (r *AgenticRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) } return r.handleEscalation(ctx, &run, resolved, approval, policy) + case agenticv1alpha1.AgenticRunPhaseNoActionRequired: + if needsRevision(&run) { + return r.handleRevision(ctx, &run, resolved, approval, policy) + } + return ctrl.Result{}, nil + default: log.V(1).Info("unhandled phase, no-op", LogKeyPhase, phase) return ctrl.Result{}, nil diff --git a/controller/agenticrun/results.go b/controller/agenticrun/results.go index 936c78d1..7bba5678 100644 --- a/controller/agenticrun/results.go +++ b/controller/agenticrun/results.go @@ -111,6 +111,10 @@ func (r *AgenticRunReconciler) createAnalysisResult( if result != nil { cr.Status.Options = result.Options + cr.Status.ActionRequired = agenticv1alpha1.ActionRequiredFromBool(result.IsActionRequired()) + if result.Diagnosis != nil { + cr.Status.Diagnosis = *result.Diagnosis + } } snapshot := cr.DeepCopy() @@ -294,6 +298,8 @@ func copyResultStatus(dst, src client.Object) { case *agenticv1alpha1.AnalysisResult: if s, ok := src.(*agenticv1alpha1.AnalysisResult); ok { d.Status.Options = s.Status.Options + d.Status.ActionRequired = s.Status.ActionRequired + d.Status.Diagnosis = s.Status.Diagnosis d.Status.FailureReason = s.Status.FailureReason d.Status.Sandbox = s.Status.Sandbox } diff --git a/controller/agenticrun/sandbox_agent.go b/controller/agenticrun/sandbox_agent.go index 7f088ce7..b5732a33 100644 --- a/controller/agenticrun/sandbox_agent.go +++ b/controller/agenticrun/sandbox_agent.go @@ -31,8 +31,10 @@ const ( ) type analysisResponse struct { - Success bool `json:"success"` - Options []agenticv1alpha1.RemediationOption `json:"options"` + Success bool `json:"success"` + ActionRequired *bool `json:"actionRequired,omitempty"` + Diagnosis *agenticv1alpha1.DiagnosisResult `json:"diagnosis,omitempty"` + Options []agenticv1alpha1.RemediationOption `json:"options"` } type executionResponse struct { @@ -100,9 +102,16 @@ func (s *SandboxAgentCaller) Analyze(ctx context.Context, run *agenticv1alpha1.A } } + actionRequired := resp.ActionRequired == nil || *resp.ActionRequired + if resp.Diagnosis == nil && !actionRequired { + log.Error(nil, "analysis response missing diagnosis for no-action-required result", "run", run.Name) + } + return &AnalysisOutput{ - Success: resp.Success, - Options: resp.Options, + Success: resp.Success, + ActionRequired: &actionRequired, + Options: resp.Options, + Diagnosis: resp.Diagnosis, }, nil } diff --git a/controller/agenticrun/schemas.go b/controller/agenticrun/schemas.go index c8cf67e2..4999c001 100644 --- a/controller/agenticrun/schemas.go +++ b/controller/agenticrun/schemas.go @@ -14,10 +14,24 @@ import ( var AnalysisOutputSchema = json.RawMessage(`{ "type": "object", "properties": { + "actionRequired": { + "type": "boolean", + "description": "Whether remediation action is required. Set to false when the issue is a false alarm, already self-healed, or no cluster changes are needed. When false, provide a diagnosis explaining why no action is needed and omit options." + }, + "diagnosis": { + "type": "object", + "description": "Top-level root cause analysis. Required when actionRequired is false. When actionRequired is true, diagnosis is provided per-option instead.", + "properties": { + "summary": { "type": "string", "description": "Markdown-formatted diagnosis summary explaining the problem, symptoms, and findings" }, + "confidence": { "type": "string", "enum": ["Low", "Medium", "High"], "description": "Your confidence in this diagnosis" }, + "rootCause": { "type": "string", "description": "Concise one-line root cause" } + }, + "required": ["summary", "confidence", "rootCause"] + }, "options": { "type": "array", - "description": "One or more remediation options, ordered by recommendation. Provide at least one.", - "minItems": 1, + "description": "One or more remediation options, ordered by recommendation. Required when actionRequired is true. Provide an empty array [] when actionRequired is false.", + "minItems": 0, "items": { "type": "object", "properties": { @@ -125,7 +139,7 @@ var AnalysisOutputSchema = json.RawMessage(`{ } } }, - "required": ["options"] + "required": ["actionRequired", "options"] }`) // MinimalAnalysisOutputSchema is the base analysis output schema used @@ -136,10 +150,24 @@ var AnalysisOutputSchema = json.RawMessage(`{ var MinimalAnalysisOutputSchema = json.RawMessage(`{ "type": "object", "properties": { + "actionRequired": { + "type": "boolean", + "description": "Whether remediation action is required. Set to false when the issue is a false alarm, already self-healed, or no cluster changes are needed." + }, + "diagnosis": { + "type": "object", + "description": "Top-level root cause analysis. Required when actionRequired is false.", + "properties": { + "summary": { "type": "string", "description": "Markdown-formatted diagnosis summary" }, + "confidence": { "type": "string", "enum": ["Low", "Medium", "High"], "description": "Your confidence in this diagnosis" }, + "rootCause": { "type": "string", "description": "Concise one-line root cause" } + }, + "required": ["summary", "confidence", "rootCause"] + }, "options": { "type": "array", - "description": "One or more output options, ordered by recommendation. Provide at least one.", - "minItems": 1, + "description": "One or more output options, ordered by recommendation. Required when actionRequired is true.", + "minItems": 0, "items": { "type": "object", "properties": { @@ -149,7 +177,7 @@ var MinimalAnalysisOutputSchema = json.RawMessage(`{ } } }, - "required": ["options"] + "required": ["actionRequired", "options"] }`) var ExecutionOutputSchema = json.RawMessage(`{ diff --git a/controller/agenticrun/schemas_test.go b/controller/agenticrun/schemas_test.go index b5a6609f..e2e422c7 100644 --- a/controller/agenticrun/schemas_test.go +++ b/controller/agenticrun/schemas_test.go @@ -371,6 +371,78 @@ func extractOptionProperties(t *testing.T, schema json.RawMessage) map[string]an return extractOptionItems(t, schema)["properties"].(map[string]any) } +func TestAnalysisOutputSchema_ActionRequiredAndDiagnosis(t *testing.T) { + var parsed map[string]any + if err := json.Unmarshal(AnalysisOutputSchema, &parsed); err != nil { + t.Fatal(err) + } + props := parsed["properties"].(map[string]any) + + ar, ok := props["actionRequired"] + if !ok { + t.Fatal("schema missing actionRequired property") + } + if ar.(map[string]any)["type"] != "boolean" { + t.Error("actionRequired should be boolean") + } + + diag, ok := props["diagnosis"] + if !ok { + t.Fatal("schema missing diagnosis property") + } + diagProps := diag.(map[string]any)["properties"].(map[string]any) + for _, key := range []string{"summary", "confidence", "rootCause"} { + if _, ok := diagProps[key]; !ok { + t.Errorf("diagnosis missing property %q", key) + } + } + + required := parsed["required"].([]any) + requiredSet := map[string]bool{} + for _, r := range required { + requiredSet[r.(string)] = true + } + if !requiredSet["actionRequired"] { + t.Error("actionRequired should be required") + } + + options := props["options"].(map[string]any) + minItems := options["minItems"].(float64) + if minItems != 0 { + t.Errorf("options minItems = %v, want 0", minItems) + } +} + +func TestMinimalAnalysisOutputSchema_ActionRequiredAndDiagnosis(t *testing.T) { + var parsed map[string]any + if err := json.Unmarshal(MinimalAnalysisOutputSchema, &parsed); err != nil { + t.Fatal(err) + } + props := parsed["properties"].(map[string]any) + + if _, ok := props["actionRequired"]; !ok { + t.Fatal("MinimalAnalysisOutputSchema missing actionRequired property") + } + if _, ok := props["diagnosis"]; !ok { + t.Fatal("MinimalAnalysisOutputSchema missing diagnosis property") + } + + required := parsed["required"].([]any) + requiredSet := map[string]bool{} + for _, r := range required { + requiredSet[r.(string)] = true + } + if !requiredSet["actionRequired"] { + t.Error("actionRequired should be required in minimal schema") + } + + options := props["options"].(map[string]any) + minItems := options["minItems"].(float64) + if minItems != 0 { + t.Errorf("minimal options minItems = %v, want 0", minItems) + } +} + func TestMinimalAnalysisOutputSchema_ValidJSON(t *testing.T) { var parsed map[string]any if err := json.Unmarshal(MinimalAnalysisOutputSchema, &parsed); err != nil { diff --git a/controller/agenticrun/state_machine_test.go b/controller/agenticrun/state_machine_test.go index ec89762e..61fb315d 100644 --- a/controller/agenticrun/state_machine_test.go +++ b/controller/agenticrun/state_machine_test.go @@ -8,6 +8,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -1059,3 +1060,131 @@ func TestPolicyChange_ManualToAutomatic(t *testing.T) { reconcileOnce(r, "fix-crash") assertPhase(t, r, "fix-crash", agenticv1alpha1.AgenticRunPhaseProposed) } + +// --------------------------------------------------------------------------- +// NoActionRequired: analysis terminates without execution +// --------------------------------------------------------------------------- + +func TestNoActionRequired_TerminalWithoutExecution(t *testing.T) { + run := testAgenticRun() + agent := newTestAgentCaller() + agent.analyzeResult = &AnalysisOutput{ + Success: true, + ActionRequired: ptr.To(false), + Diagnosis: &agenticv1alpha1.DiagnosisResult{ + Summary: "Alert is a false alarm — pod restarted once due to a transient OOM but has been stable since", + Confidence: "High", + RootCause: "Transient OOM, already self-healed", + }, + } + r, fc := newReconcilerWithPolicy(t, run, agent, testAutoApprovePolicy()) + + // Analysis auto-approved → NoActionRequired (terminal) + reconcileOnce(r, "fix-crash") + p := assertPhase(t, r, "fix-crash", agenticv1alpha1.AgenticRunPhaseNoActionRequired) + + // Verify AnalysisResult CR was created with ActionRequired=False and Diagnosis + if len(p.Status.Steps.Analysis.Results) == 0 { + t.Fatal("expected analysis result ref") + } + var ar agenticv1alpha1.AnalysisResult + if err := fc.Get(context.Background(), types.NamespacedName{Name: p.Status.Steps.Analysis.Results[0].Name, Namespace: "default"}, &ar); err != nil { + t.Fatalf("get AnalysisResult: %v", err) + } + if ar.Status.ActionRequired != agenticv1alpha1.ActionRequiredFalse { + t.Errorf("ActionRequired = %q, want %q", ar.Status.ActionRequired, agenticv1alpha1.ActionRequiredFalse) + } + if ar.Status.Diagnosis.Summary == "" { + t.Fatal("expected Diagnosis to be set") + } + if ar.Status.Diagnosis.Confidence != "High" { + t.Errorf("Diagnosis.Confidence = %q, want High", ar.Status.Diagnosis.Confidence) + } + + // Execution and verification should never have run + if len(p.Status.Steps.Execution.Results) != 0 { + t.Error("execution should not have run") + } + if len(p.Status.Steps.Verification.Results) != 0 { + t.Error("verification should not have run") + } + + // Re-reconcile is a no-op (terminal) + result, err := reconcileOnce(r, "fix-crash") + mustNotRequeue(t, result, err, "terminal no-op re-reconcile") + assertPhase(t, r, "fix-crash", agenticv1alpha1.AgenticRunPhaseNoActionRequired) +} + +func TestNoActionRequired_RevisionTriggersReanalysis(t *testing.T) { + run := testAgenticRun() + agent := newTestAgentCaller() + agent.analyzeResult = &AnalysisOutput{ + Success: true, + ActionRequired: ptr.To(false), + Diagnosis: &agenticv1alpha1.DiagnosisResult{ + Summary: "False alarm", + Confidence: "Medium", + RootCause: "Transient issue", + }, + } + r, fc := newReconcilerWithPolicy(t, run, agent, testAutoApprovePolicy()) + + // Analysis → NoActionRequired + result, err := reconcileOnce(r, "fix-crash") + mustNotRequeue(t, result, err, "initial no-action-required analysis") + assertPhase(t, r, "fix-crash", agenticv1alpha1.AgenticRunPhaseNoActionRequired) + + // Admin disagrees — submits revision feedback to re-analyze + agent.analyzeResult = &AnalysisOutput{ + Success: true, + ActionRequired: ptr.To(true), + Options: []agenticv1alpha1.RemediationOption{{ + Title: "Increase memory", + Diagnosis: agenticv1alpha1.DiagnosisResult{ + Summary: "OOM", Confidence: "High", RootCause: "Low limit", + }, + RemediationPlan: agenticv1alpha1.RemediationPlan{ + Description: "Increase to 512Mi", + Actions: []agenticv1alpha1.ProposedAction{{Command: "kubectl patch deploy/web -n production -p '{}'", Type: "mutation", Description: "Patch"}}, + Risk: "Low", + Reversible: agenticv1alpha1.ReversibilityReversible, + }, + }}, + } + reviseAgenticRun(t, fc, "fix-crash", "This is NOT a false alarm, the pod keeps crashing") + + // Revision triggers re-analysis → should now be Proposed + result, err = reconcileOnce(r, "fix-crash") + mustNotRequeue(t, result, err, "revision-triggered reanalysis") + assertPhase(t, r, "fix-crash", agenticv1alpha1.AgenticRunPhaseProposed) + + p := mustGetAgenticRun(t, r, "fix-crash") + if len(p.Status.Steps.Analysis.Results) < 2 { + t.Fatalf("expected at least 2 analysis results (initial + revision), got %d", len(p.Status.Steps.Analysis.Results)) + } +} + +func TestNoActionRequired_NilActionRequiredDefaultsToTrue(t *testing.T) { + run := testAgenticRun() + agent := newTestAgentCaller() + agent.analyzeResult = &AnalysisOutput{ + Success: true, + ActionRequired: nil, + Options: []agenticv1alpha1.RemediationOption{{ + Title: "Increase memory", + Diagnosis: agenticv1alpha1.DiagnosisResult{ + Summary: "OOM", Confidence: "High", RootCause: "Low limit", + }, + RemediationPlan: agenticv1alpha1.RemediationPlan{ + Description: "Increase to 512Mi", + Actions: []agenticv1alpha1.ProposedAction{{Command: "kubectl patch deploy/web -n production -p '{}'", Type: "mutation", Description: "Patch"}}, + Risk: "Low", + Reversible: agenticv1alpha1.ReversibilityReversible, + }, + }}, + } + r, _ := newReconcilerWithPolicy(t, run, agent, testAutoApprovePolicy()) + + reconcileOnce(r, "fix-crash") + assertPhase(t, r, "fix-crash", agenticv1alpha1.AgenticRunPhaseProposed) +} diff --git a/examples/setup/09-no-action-required-run.yaml b/examples/setup/09-no-action-required-run.yaml new file mode 100644 index 00000000..b4f81bae --- /dev/null +++ b/examples/setup/09-no-action-required-run.yaml @@ -0,0 +1,38 @@ +# Example AgenticRun that triggers NoActionRequired — the analysis agent +# determines no remediation is needed (false alarm / self-healed). +--- +apiVersion: v1 +kind: Pod +metadata: + name: hello-printer + namespace: openshift-lightspeed +spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: hello + image: busybox + command: ["sh", "-c", "while true; do echo hello; sleep 1; done"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +--- +apiVersion: agentic.openshift.io/v1alpha1 +kind: AgenticRun +metadata: + name: false-alarm-crashloop + namespace: openshift-lightspeed +spec: + request: "Pod hello-printer is crash looping in openshift-lightspeed namespace" + targetNamespaces: + - openshift-lightspeed + analysis: + agent: default + execution: + agent: default + verification: + agent: default