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
14 changes: 13 additions & 1 deletion api/v1alpha1/agenticrun_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,22 @@ type AgenticRunStep struct {
// for this step. Use this when different steps need different skills.
// +optional
Tools ToolsSpec `json:"tools,omitzero"`

// timeoutMinutes sets the timeout for this step's sandbox agent call.
// This controls only the agent call duration; pod startup always uses
// a fixed five-minute ceiling (defaultSandboxTimeout). Increase this
// for long-running tools (e.g., IntelliAide RCA takes 10-30 minutes).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Defaults to 5 minutes when omitted.
//
// Mutable: can be adjusted at any time; the value is read when the step starts.
// +optional
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=60
TimeoutMinutes int32 `json:"timeoutMinutes,omitempty"`
}

func (s AgenticRunStep) IsZero() bool {
return s.Agent == "" && s.Tools.IsZero()
return s.Agent == "" && s.Tools.IsZero() && s.TimeoutMinutes == 0
}

// AgenticRunSpec defines the desired state of AgenticRun.
Expand Down
39 changes: 39 additions & 0 deletions config/crd/bases/agentic.openshift.io_agenticruns.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ spec:
- message: 'must be a valid DNS subdomain: lowercase alphanumeric
characters, hyphens, and dots'
rule: '!format.dns1123Subdomain().validate(self).hasValue()'
timeoutMinutes:
description: |-
timeoutMinutes sets the timeout for this step's sandbox agent call.
This controls only the agent call duration; pod startup always uses
a fixed five-minute ceiling (defaultSandboxTimeout). Increase this
for long-running tools (e.g., IntelliAide RCA takes 10-30 minutes).
Defaults to 5 minutes when omitted.

Mutable: can be adjusted at any time; the value is read when the step starts.
format: int32
maximum: 60
minimum: 1
type: integer
tools:
description: |-
tools provides per-step tools that replace the shared spec.tools
Expand Down Expand Up @@ -500,6 +513,19 @@ spec:
- message: 'must be a valid DNS subdomain: lowercase alphanumeric
characters, hyphens, and dots'
rule: '!format.dns1123Subdomain().validate(self).hasValue()'
timeoutMinutes:
description: |-
timeoutMinutes sets the timeout for this step's sandbox agent call.
This controls only the agent call duration; pod startup always uses
a fixed five-minute ceiling (defaultSandboxTimeout). Increase this
for long-running tools (e.g., IntelliAide RCA takes 10-30 minutes).
Defaults to 5 minutes when omitted.

Mutable: can be adjusted at any time; the value is read when the step starts.
format: int32
maximum: 60
minimum: 1
type: integer
tools:
description: |-
tools provides per-step tools that replace the shared spec.tools
Expand Down Expand Up @@ -1294,6 +1320,19 @@ spec:
- message: 'must be a valid DNS subdomain: lowercase alphanumeric
characters, hyphens, and dots'
rule: '!format.dns1123Subdomain().validate(self).hasValue()'
timeoutMinutes:
description: |-
timeoutMinutes sets the timeout for this step's sandbox agent call.
This controls only the agent call duration; pod startup always uses
a fixed five-minute ceiling (defaultSandboxTimeout). Increase this
for long-running tools (e.g., IntelliAide RCA takes 10-30 minutes).
Defaults to 5 minutes when omitted.

Mutable: can be adjusted at any time; the value is read when the step starts.
format: int32
maximum: 60
minimum: 1
type: integer
tools:
description: |-
tools provides per-step tools that replace the shared spec.tools
Expand Down
17 changes: 9 additions & 8 deletions controller/agenticrun/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agenticrun

import (
"context"
"time"

agenticv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1"
)
Expand Down Expand Up @@ -53,18 +54,18 @@ type EscalationOutput struct {
// HTTP implementations POST to /v1/agent/run — a step-agnostic
// endpoint where all workflow context is in the request payload.
type AgentCaller interface {
Analyze(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, requestText string, serviceAccount string) (*AnalysisOutput, error)
Execute(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, option *agenticv1alpha1.RemediationOption, serviceAccount string) (*ExecutionOutput, error)
Verify(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, option *agenticv1alpha1.RemediationOption, exec *ExecutionOutput, serviceAccount string) (*VerificationOutput, error)
Escalate(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, requestText string, serviceAccount string) (*EscalationOutput, error)
Analyze(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, requestText string, serviceAccount string, timeout time.Duration) (*AnalysisOutput, error)
Execute(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, option *agenticv1alpha1.RemediationOption, serviceAccount string, timeout time.Duration) (*ExecutionOutput, error)
Verify(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, option *agenticv1alpha1.RemediationOption, exec *ExecutionOutput, serviceAccount string, timeout time.Duration) (*VerificationOutput, error)
Escalate(ctx context.Context, run *agenticv1alpha1.AgenticRun, step resolvedStep, requestText string, serviceAccount string, timeout time.Duration) (*EscalationOutput, error)
ReleaseSandboxes(ctx context.Context, run *agenticv1alpha1.AgenticRun) error
}

// StubAgentCaller returns canned success results. Wire in a real
// implementation (sandbox + HTTP) when the agent infrastructure is ready.
type StubAgentCaller struct{}

func (s *StubAgentCaller) Analyze(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string) (*AnalysisOutput, error) {
func (s *StubAgentCaller) Analyze(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string, _ time.Duration) (*AnalysisOutput, error) {
actionRequired := true
return &AnalysisOutput{
Success: true,
Expand All @@ -84,7 +85,7 @@ func (s *StubAgentCaller) Analyze(_ context.Context, _ *agenticv1alpha1.AgenticR
}, nil
}

func (s *StubAgentCaller) Execute(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ string) (*ExecutionOutput, error) {
func (s *StubAgentCaller) Execute(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ string, _ time.Duration) (*ExecutionOutput, error) {
return &ExecutionOutput{
Success: true,
ActionsTaken: []agenticv1alpha1.ExecutionAction{{
Expand All @@ -95,7 +96,7 @@ func (s *StubAgentCaller) Execute(_ context.Context, _ *agenticv1alpha1.AgenticR
}, nil
}

func (s *StubAgentCaller) Escalate(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string) (*EscalationOutput, error) {
func (s *StubAgentCaller) Escalate(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string, _ time.Duration) (*EscalationOutput, error) {
return &EscalationOutput{
Success: true,
Summary: "Stub escalation summary",
Expand All @@ -107,7 +108,7 @@ func (s *StubAgentCaller) ReleaseSandboxes(_ context.Context, _ *agenticv1alpha1
return nil
}

func (s *StubAgentCaller) Verify(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ *ExecutionOutput, _ string) (*VerificationOutput, error) {
func (s *StubAgentCaller) Verify(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ *ExecutionOutput, _ string, _ time.Duration) (*VerificationOutput, error) {
return &VerificationOutput{
Success: true,
Checks: []agenticv1alpha1.VerifyCheck{{
Expand Down
12 changes: 6 additions & 6 deletions controller/agenticrun/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (r *AgenticRunReconciler) handleAnalysis(
r.Audit.EmitAgenticRunReceived(spanCtx, run)
}

analysisResult, err := r.Agent.Analyze(spanCtx, run, resolved.Analysis, run.Spec.Request, defaultSandboxSA)
analysisResult, err := r.Agent.Analyze(spanCtx, run, resolved.Analysis, run.Spec.Request, defaultSandboxSA, stepTimeout(resolved.Analysis))
if err != nil {
return r.failStep(spanCtx, run, agenticv1alpha1.AgenticRunConditionAnalyzed, err)
}
Expand Down Expand Up @@ -182,7 +182,7 @@ func (r *AgenticRunReconciler) handleRevision(
revisionSuffix := buildRevisionContext(run)
requestWithRevision := run.Spec.Request + "\n\n" + revisionSuffix

analysisResult, err := r.Agent.Analyze(spanCtx, run, resolved.Analysis, requestWithRevision, defaultSandboxSA)
analysisResult, err := r.Agent.Analyze(spanCtx, run, resolved.Analysis, requestWithRevision, defaultSandboxSA, stepTimeout(resolved.Analysis))
if err != nil {
return r.failStep(spanCtx, run, agenticv1alpha1.AgenticRunConditionAnalyzed, err)
}
Expand Down Expand Up @@ -329,7 +329,7 @@ func (r *AgenticRunReconciler) handleExecution(
}
}

execResult, err := r.Agent.Execute(spanCtx, run, *resolved.Execution, selectedOption, execSA)
execResult, err := r.Agent.Execute(spanCtx, run, *resolved.Execution, selectedOption, execSA, stepTimeout(*resolved.Execution))
if err != nil {
return r.failStep(spanCtx, run, agenticv1alpha1.AgenticRunConditionExecuted, err)
}
Expand Down Expand Up @@ -468,7 +468,7 @@ func (r *AgenticRunReconciler) handleVerification(
}
}

verifyResult, err := r.Agent.Verify(spanCtx, run, *resolved.Verification, selectedOption, execOutput, defaultSandboxSA)
verifyResult, err := r.Agent.Verify(spanCtx, run, *resolved.Verification, selectedOption, execOutput, defaultSandboxSA, stepTimeout(*resolved.Verification))
if err != nil {
return r.failStep(spanCtx, run, agenticv1alpha1.AgenticRunConditionVerified, err)
}
Expand Down Expand Up @@ -669,7 +669,7 @@ func (r *AgenticRunReconciler) handleEscalation(
if err := r.Get(ctx, types.NamespacedName{Name: agent.Spec.LLMProvider.Name}, &llm); err != nil {
return r.failStep(ctx, run, agenticv1alpha1.AgenticRunConditionEscalated, fmt.Errorf("%s %q: %w", ErrGetEscalationLLMProvider, agent.Spec.LLMProvider.Name, err))
}
step = resolvedStep{Agent: &agent, LLM: &llm, Tools: step.Tools}
step = resolvedStep{Agent: &agent, LLM: &llm, Tools: step.Tools, TimeoutMinutes: step.TimeoutMinutes}
}

base := run.DeepCopy()
Expand All @@ -694,7 +694,7 @@ func (r *AgenticRunReconciler) handleEscalation(
}

escalationText := buildEscalationRequest(run)
escalationResult, err := r.Agent.Escalate(spanCtx, run, step, escalationText, defaultSandboxSA)
escalationResult, err := r.Agent.Escalate(spanCtx, run, step, escalationText, defaultSandboxSA, stepTimeout(step))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err != nil {
return r.failStep(spanCtx, run, agenticv1alpha1.AgenticRunConditionEscalated, err)
}
Expand Down
59 changes: 51 additions & 8 deletions controller/agenticrun/reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,42 @@ type testAgentCaller struct {
executeResult *ExecutionOutput
verifyResult *VerificationOutput
escalateResult *EscalationOutput

// lastAnalyzeTimeout records the timeout passed to the most recent
// Analyze call, so tests can assert that timeoutMinutes resolved from
// the AgenticRun spec is actually forwarded through the reconciler.
lastAnalyzeTimeout time.Duration
}

func newTestAgentCaller() *testAgentCaller {
stub := &StubAgentCaller{}
a, _ := stub.Analyze(context.Background(), nil, resolvedStep{}, "", "")
e, _ := stub.Execute(context.Background(), nil, resolvedStep{}, nil, "")
v, _ := stub.Verify(context.Background(), nil, resolvedStep{}, nil, nil, "")
esc, _ := stub.Escalate(context.Background(), nil, resolvedStep{}, "", "")
a, _ := stub.Analyze(context.Background(), nil, resolvedStep{}, "", "", 0)
e, _ := stub.Execute(context.Background(), nil, resolvedStep{}, nil, "", 0)
v, _ := stub.Verify(context.Background(), nil, resolvedStep{}, nil, nil, "", 0)
esc, _ := stub.Escalate(context.Background(), nil, resolvedStep{}, "", "", 0)
return &testAgentCaller{analyzeResult: a, executeResult: e, verifyResult: v, escalateResult: esc}
}

func (ta *testAgentCaller) Analyze(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string) (*AnalysisOutput, error) {
func (ta *testAgentCaller) Analyze(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string, timeout time.Duration) (*AnalysisOutput, error) {
ta.lastAnalyzeTimeout = timeout
if ta.analyzeErr != nil {
return nil, ta.analyzeErr
}
return ta.analyzeResult, nil
}
func (ta *testAgentCaller) Execute(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ string) (*ExecutionOutput, error) {
func (ta *testAgentCaller) Execute(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ string, _ time.Duration) (*ExecutionOutput, error) {
if ta.executeErr != nil {
return nil, ta.executeErr
}
return ta.executeResult, nil
}
func (ta *testAgentCaller) Verify(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ *ExecutionOutput, _ string) (*VerificationOutput, error) {
func (ta *testAgentCaller) Verify(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ *agenticv1alpha1.RemediationOption, _ *ExecutionOutput, _ string, _ time.Duration) (*VerificationOutput, error) {
if ta.verifyErr != nil {
return nil, ta.verifyErr
}
return ta.verifyResult, nil
}
func (ta *testAgentCaller) Escalate(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string) (*EscalationOutput, error) {
func (ta *testAgentCaller) Escalate(_ context.Context, _ *agenticv1alpha1.AgenticRun, _ resolvedStep, _ string, _ string, _ time.Duration) (*EscalationOutput, error) {
if ta.escalateErr != nil {
return nil, ta.escalateErr
}
Expand Down Expand Up @@ -966,3 +972,40 @@ func TestHandleRBACCleanup_InvalidAnnotation(t *testing.T) {
t.Error("expected Requeue (cleanup succeeded, annotation reset to 0)")
}
}

// TestReconcile_PropagatesStepTimeout verifies that timeoutMinutes set on an
// AgenticRunStep is resolved and forwarded to the AgentCaller as time.Duration.
func TestReconcile_PropagatesStepTimeout(t *testing.T) {
const wantMinutes int32 = 30

scheme := testScheme()
run := &agenticv1alpha1.AgenticRun{
ObjectMeta: metav1.ObjectMeta{Name: "timeout-check", Namespace: "default"},
Spec: agenticv1alpha1.AgenticRunSpec{
Request: "Pod crashing",
Tools: testTools(),
Analysis: agenticv1alpha1.AgenticRunStep{
Agent: "default",
TimeoutMinutes: wantMinutes,
},
Execution: agenticv1alpha1.AgenticRunStep{Agent: "default"},
Verification: agenticv1alpha1.AgenticRunStep{Agent: "default"},
},
}

objs := append([]client.Object{run}, defaultObjects()...)
fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).
WithStatusSubresource(run, &agenticv1alpha1.AnalysisResult{}, &agenticv1alpha1.ExecutionResult{}, &agenticv1alpha1.VerificationResult{}, &agenticv1alpha1.EscalationResult{}).Build()

caller := newTestAgentCaller()
r := &AgenticRunReconciler{Client: fc, Agent: caller, Namespace: "default"}

if _, err := reconcileOnce(r, "timeout-check"); err != nil {
t.Fatalf("reconcile: %v", err)
}

want := time.Duration(wantMinutes) * time.Minute
if caller.lastAnalyzeTimeout != want {
t.Errorf("Analyze timeout = %v, want %v", caller.lastAnalyzeTimeout, want)
}
}
13 changes: 7 additions & 6 deletions controller/agenticrun/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ const (
)

type resolvedStep struct {
Agent *agenticv1alpha1.Agent
LLM *agenticv1alpha1.LLMProvider
Tools *agenticv1alpha1.ToolsSpec
Agent *agenticv1alpha1.Agent
LLM *agenticv1alpha1.LLMProvider
Tools *agenticv1alpha1.ToolsSpec
TimeoutMinutes int32
}

type resolvedWorkflow struct {
Expand Down Expand Up @@ -80,22 +81,22 @@ func resolveAgenticRun(ctx context.Context, c client.Client, run *agenticv1alpha
if err != nil {
return nil, fmt.Errorf("%s: %w", ErrResolveAnalysisStep, err)
}
resolved.Analysis = resolvedStep{Agent: agent, LLM: llm, Tools: toolsForStep(run.Spec.Analysis)}
resolved.Analysis = resolvedStep{Agent: agent, LLM: llm, Tools: toolsForStep(run.Spec.Analysis), TimeoutMinutes: run.Spec.Analysis.TimeoutMinutes}

if !run.Spec.Execution.IsZero() {
agent, llm, err := resolveAgent(effectiveAgent(agenticv1alpha1.SandboxStepExecution, run.Spec.Execution))
if err != nil {
return nil, fmt.Errorf("%s: %w", ErrResolveExecutionStep, err)
}
resolved.Execution = &resolvedStep{Agent: agent, LLM: llm, Tools: toolsForStep(run.Spec.Execution)}
resolved.Execution = &resolvedStep{Agent: agent, LLM: llm, Tools: toolsForStep(run.Spec.Execution), TimeoutMinutes: run.Spec.Execution.TimeoutMinutes}
}

if !run.Spec.Verification.IsZero() {
agent, llm, err := resolveAgent(effectiveAgent(agenticv1alpha1.SandboxStepVerification, run.Spec.Verification))
if err != nil {
return nil, fmt.Errorf("%s: %w", ErrResolveVerificationStep, err)
}
resolved.Verification = &resolvedStep{Agent: agent, LLM: llm, Tools: toolsForStep(run.Spec.Verification)}
resolved.Verification = &resolvedStep{Agent: agent, LLM: llm, Tools: toolsForStep(run.Spec.Verification), TimeoutMinutes: run.Spec.Verification.TimeoutMinutes}
}

return resolved, nil
Expand Down
Loading