Skip to content

Commit c4bfce5

Browse files
committed
Split AgentHarness controllers by runtime
1 parent f18bd86 commit c4bfce5

12 files changed

Lines changed: 703 additions & 492 deletions

go/api/v1alpha2/agentharness_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ const (
301301
AgentHarnessConditionTypeAccepted = "Accepted"
302302
AgentHarnessConditionTypeActorTemplateReady = "ActorTemplateReady"
303303
AgentHarnessConditionTypeActorReady = "ActorReady"
304+
AgentHarnessConditionTypeBootstrapReady = "BootstrapReady"
304305
)
305306

306307
// +kubebuilder:object:root=true

go/core/internal/controller/agentharness_controller.go

Lines changed: 0 additions & 428 deletions
This file was deleted.

go/core/internal/controller/agentharness_controller_test.go

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,7 @@ func TestAgentHarnessController_SubstrateWaitsForGeneratedTemplate(t *testing.T)
100100
lifecycle := &fakeSubstrateLifecycle{state: substrate.LifecycleState{ActorTemplateReady: false}}
101101
backend := &fakeAgentHarnessBackend{}
102102
controller.SubstrateLifecycle = lifecycle
103-
controller.SubstrateBackends = map[v1alpha2.AgentHarnessBackendType]sandboxbackend.AsyncBackend{
104-
v1alpha2.AgentHarnessBackendOpenClaw: backend,
105-
}
103+
controller.OpenClawBackend = backend
106104

107105
result, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
108106
require.NoError(t, err)
@@ -124,9 +122,7 @@ func TestAgentHarnessController_SubstrateLifecycleErrorSetsStatus(t *testing.T)
124122
lifecycle := &fakeSubstrateLifecycle{ensureErr: errors.New("workerpool missing")}
125123
backend := &fakeAgentHarnessBackend{}
126124
controller.SubstrateLifecycle = lifecycle
127-
controller.SubstrateBackends = map[v1alpha2.AgentHarnessBackendType]sandboxbackend.AsyncBackend{
128-
v1alpha2.AgentHarnessBackendOpenClaw: backend,
129-
}
125+
controller.OpenClawBackend = backend
130126

131127
_, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
132128
require.ErrorContains(t, err, "workerpool missing")
@@ -145,9 +141,7 @@ func TestAgentHarnessController_SubstrateReadyCreatesActorAndRunsBootstrap(t *te
145141
lifecycle := &fakeSubstrateLifecycle{state: substrate.LifecycleState{ActorTemplateReady: true}}
146142
backend := &fakeAgentHarnessBackend{ensureHandle: "actor-1", endpoint: "kagent gateway: /api/agentharnesses/kagent/claw/gateway/"}
147143
controller.SubstrateLifecycle = lifecycle
148-
controller.SubstrateBackends = map[v1alpha2.AgentHarnessBackendType]sandboxbackend.AsyncBackend{
149-
v1alpha2.AgentHarnessBackendOpenClaw: backend,
150-
}
144+
controller.OpenClawBackend = backend
151145

152146
result, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
153147
require.NoError(t, err)
@@ -163,8 +157,13 @@ func TestAgentHarnessController_SubstrateReadyCreatesActorAndRunsBootstrap(t *te
163157
requireCondition(t, latest, v1alpha2.AgentHarnessConditionTypeAccepted, metav1.ConditionTrue, "AgentHarnessAccepted")
164158
requireCondition(t, latest, v1alpha2.AgentHarnessConditionTypeActorTemplateReady, metav1.ConditionTrue, "Ready")
165159
requireCondition(t, latest, v1alpha2.AgentHarnessConditionTypeActorReady, metav1.ConditionTrue, "Running")
160+
requireCondition(t, latest, v1alpha2.AgentHarnessConditionTypeBootstrapReady, metav1.ConditionTrue, "BootstrapComplete")
166161
requireCondition(t, latest, v1alpha2.AgentHarnessConditionTypeReady, metav1.ConditionTrue, "Running")
167-
require.Equal(t, "1", latest.Annotations[annotationAgentHarnessBootstrapGeneration])
162+
163+
result, err = controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
164+
require.NoError(t, err)
165+
require.Equal(t, ctrl.Result{}, result)
166+
require.Equal(t, 1, backend.readyCalls, "bootstrap should not rerun for an already bootstrapped generation")
168167
}
169168

170169
func TestAgentHarnessController_SubstrateDeleteWaitsForActorBeforeTemplateCleanup(t *testing.T) {
@@ -175,9 +174,7 @@ func TestAgentHarnessController_SubstrateDeleteWaitsForActorBeforeTemplateCleanu
175174
lifecycle := &fakeSubstrateLifecycle{cleanupDone: true}
176175
backend := &fakeAgentHarnessBackend{deleteDone: false}
177176
controller.SubstrateLifecycle = lifecycle
178-
controller.SubstrateBackends = map[v1alpha2.AgentHarnessBackendType]sandboxbackend.AsyncBackend{
179-
v1alpha2.AgentHarnessBackendOpenClaw: backend,
180-
}
177+
controller.OpenClawBackend = backend
181178

182179
result, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
183180
require.NoError(t, err)
@@ -199,9 +196,7 @@ func TestAgentHarnessController_SubstrateDeleteWaitsForGeneratedTemplateCleanup(
199196
lifecycle := &fakeSubstrateLifecycle{cleanupDone: false}
200197
backend := &fakeAgentHarnessBackend{deleteDone: true}
201198
controller.SubstrateLifecycle = lifecycle
202-
controller.SubstrateBackends = map[v1alpha2.AgentHarnessBackendType]sandboxbackend.AsyncBackend{
203-
v1alpha2.AgentHarnessBackendOpenClaw: backend,
204-
}
199+
controller.OpenClawBackend = backend
205200

206201
result, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
207202
require.NoError(t, err)
@@ -223,9 +218,7 @@ func TestAgentHarnessController_SubstrateDeleteRemovesFinalizerAfterCleanup(t *t
223218
lifecycle := &fakeSubstrateLifecycle{cleanupDone: true}
224219
backend := &fakeAgentHarnessBackend{deleteDone: true}
225220
controller.SubstrateLifecycle = lifecycle
226-
controller.SubstrateBackends = map[v1alpha2.AgentHarnessBackendType]sandboxbackend.AsyncBackend{
227-
v1alpha2.AgentHarnessBackendOpenClaw: backend,
228-
}
221+
controller.OpenClawBackend = backend
229222

230223
result, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ah)})
231224
require.NoError(t, err)
@@ -238,7 +231,7 @@ func TestAgentHarnessController_SubstrateDeleteRemovesFinalizerAfterCleanup(t *t
238231
require.True(t, apierrors.IsNotFound(err), "fake client should complete deletion after finalizer removal")
239232
}
240233

241-
func newAgentHarnessTestController(t *testing.T, objects ...client.Object) *AgentHarnessController {
234+
func newAgentHarnessTestController(t *testing.T, objects ...client.Object) *SubstrateAgentHarnessController {
242235
t.Helper()
243236
scheme := runtime.NewScheme()
244237
utilruntime.Must(v1alpha2.AddToScheme(scheme))
@@ -247,7 +240,7 @@ func newAgentHarnessTestController(t *testing.T, objects ...client.Object) *Agen
247240
WithObjects(objects...).
248241
WithStatusSubresource(&v1alpha2.AgentHarness{}).
249242
Build()
250-
return &AgentHarnessController{Client: kube}
243+
return &SubstrateAgentHarnessController{Client: kube}
251244
}
252245

253246
func newSubstrateHarness(namespace, name string) *v1alpha2.AgentHarness {
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
*/
10+
11+
package controller
12+
13+
import (
14+
"context"
15+
"fmt"
16+
17+
"github.com/go-logr/logr"
18+
apierrors "k8s.io/apimachinery/pkg/api/errors"
19+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20+
"k8s.io/client-go/tools/events"
21+
ctrl "sigs.k8s.io/controller-runtime"
22+
"sigs.k8s.io/controller-runtime/pkg/builder"
23+
"sigs.k8s.io/controller-runtime/pkg/client"
24+
"sigs.k8s.io/controller-runtime/pkg/controller"
25+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
26+
27+
"github.com/kagent-dev/kagent/go/api/v1alpha2"
28+
"github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend"
29+
)
30+
31+
// OpenShellAgentHarnessController reconciles AgentHarness resources that use the
32+
// OpenShell runtime.
33+
type OpenShellAgentHarnessController struct {
34+
Client client.Client
35+
Recorder events.EventRecorder
36+
OpenClawBackend sandboxbackend.AsyncBackend
37+
HermesBackend sandboxbackend.AsyncBackend
38+
}
39+
40+
func (r *OpenShellAgentHarnessController) backendFor(ah *v1alpha2.AgentHarness) sandboxbackend.AsyncBackend {
41+
switch ah.Spec.Backend {
42+
case v1alpha2.AgentHarnessBackendOpenClaw, v1alpha2.AgentHarnessBackendNemoClaw:
43+
return r.OpenClawBackend
44+
case v1alpha2.AgentHarnessBackendHermes:
45+
return r.HermesBackend
46+
default:
47+
return nil
48+
}
49+
}
50+
51+
func (r *OpenShellAgentHarnessController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
52+
log := ctrl.LoggerFrom(ctx).WithValues("agentHarness", req.NamespacedName)
53+
54+
var ah v1alpha2.AgentHarness
55+
if err := r.Client.Get(ctx, req.NamespacedName, &ah); err != nil {
56+
if apierrors.IsNotFound(err) {
57+
return ctrl.Result{}, nil
58+
}
59+
return ctrl.Result{}, fmt.Errorf("get AgentHarness: %w", err)
60+
}
61+
if effectiveAgentHarnessRuntime(&ah) != v1alpha2.AgentHarnessRuntimeOpenshell {
62+
return ctrl.Result{}, nil
63+
}
64+
65+
if !ah.DeletionTimestamp.IsZero() {
66+
return r.reconcileDelete(ctx, &ah)
67+
}
68+
69+
if controllerutil.AddFinalizer(&ah, agentHarnessFinalizer) {
70+
if err := r.Client.Update(ctx, &ah); err != nil {
71+
return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err)
72+
}
73+
return ctrl.Result{Requeue: true}, nil
74+
}
75+
76+
backend := r.backendFor(&ah)
77+
if backend == nil {
78+
return reconcileBackendUnavailable(ctx, r.Client, &ah, v1alpha2.AgentHarnessRuntimeOpenshell)
79+
}
80+
81+
return r.reconcileBackend(ctx, req, &ah, backend, log)
82+
}
83+
84+
func (r *OpenShellAgentHarnessController) reconcileBackend(ctx context.Context, req ctrl.Request, ah *v1alpha2.AgentHarness, backend sandboxbackend.AsyncBackend, log logr.Logger) (ctrl.Result, error) {
85+
res, err := backend.EnsureAgentHarness(ctx, ah)
86+
if err != nil {
87+
log.Error(err, "EnsureAgentHarness failed")
88+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeAccepted, metav1.ConditionFalse,
89+
"EnsureFailed", err.Error())
90+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeReady, metav1.ConditionFalse,
91+
"EnsureFailed", err.Error())
92+
if perr := patchAgentHarnessStatus(ctx, r.Client, ah); perr != nil {
93+
return ctrl.Result{}, perr
94+
}
95+
return ctrl.Result{}, err
96+
}
97+
98+
ah.Status.BackendRef = &v1alpha2.AgentHarnessStatusRef{
99+
Backend: ah.Spec.Backend,
100+
ID: res.Handle.ID,
101+
}
102+
if res.Endpoint != "" {
103+
ah.Status.Connection = &v1alpha2.AgentHarnessConnection{Endpoint: res.Endpoint}
104+
}
105+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeAccepted, metav1.ConditionTrue,
106+
"AgentHarnessAccepted", "backend accepted sandbox request")
107+
108+
st, reason, msg := backend.GetStatus(ctx, res.Handle)
109+
pending := postReadyBootstrapPending(ah)
110+
if st == metav1.ConditionTrue && pending {
111+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeActorReady, st, reason, msg)
112+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeBootstrapReady, metav1.ConditionFalse,
113+
"BootstrapPending",
114+
"waiting for post-ready bootstrap (OnAgentHarnessReady) to finish")
115+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeReady, metav1.ConditionFalse,
116+
"BootstrapPending",
117+
"gateway sandbox is ready; waiting for post-ready bootstrap (OnAgentHarnessReady) to finish")
118+
} else {
119+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeActorReady, st, reason, msg)
120+
if pending {
121+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeBootstrapReady, metav1.ConditionFalse,
122+
"ActorNotReady", "waiting for actor before post-ready bootstrap")
123+
}
124+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeReady, st, reason, msg)
125+
}
126+
ah.Status.ObservedGeneration = ah.Generation
127+
128+
if err := patchAgentHarnessStatus(ctx, r.Client, ah); err != nil {
129+
return ctrl.Result{}, err
130+
}
131+
132+
if st != metav1.ConditionTrue {
133+
return ctrl.Result{RequeueAfter: agentHarnessNotReadyRequeue}, nil
134+
}
135+
if pending {
136+
if err := maybePostReadyBootstrap(ctx, client.ObjectKeyFromObject(ah), ah, res.Handle, backend); err != nil {
137+
log.Error(err, "post-ready sandbox bootstrap failed")
138+
return ctrl.Result{}, err
139+
}
140+
var latest v1alpha2.AgentHarness
141+
if err := r.Client.Get(ctx, req.NamespacedName, &latest); err != nil {
142+
return ctrl.Result{}, fmt.Errorf("get AgentHarness after bootstrap: %w", err)
143+
}
144+
st2, reason2, msg2 := backend.GetStatus(ctx, res.Handle)
145+
setAgentHarnessCondition(&latest, v1alpha2.AgentHarnessConditionTypeActorReady, st2, reason2, msg2)
146+
setAgentHarnessCondition(&latest, v1alpha2.AgentHarnessConditionTypeBootstrapReady, metav1.ConditionTrue,
147+
"BootstrapComplete", "post-ready bootstrap completed")
148+
setAgentHarnessCondition(&latest, v1alpha2.AgentHarnessConditionTypeReady, st2, reason2, msg2)
149+
latest.Status.ObservedGeneration = latest.Generation
150+
if err := r.Client.Status().Update(ctx, &latest); err != nil {
151+
return ctrl.Result{}, fmt.Errorf("update AgentHarness status after bootstrap: %w", err)
152+
}
153+
}
154+
return ctrl.Result{}, nil
155+
}
156+
157+
func (r *OpenShellAgentHarnessController) reconcileDelete(ctx context.Context, ah *v1alpha2.AgentHarness) (ctrl.Result, error) {
158+
if !controllerutil.ContainsFinalizer(ah, agentHarnessFinalizer) {
159+
return ctrl.Result{}, nil
160+
}
161+
162+
if ah.Status.BackendRef != nil {
163+
actorID := ah.Status.BackendRef.ID
164+
if actorID != "" {
165+
backend := r.backendFor(ah)
166+
actorDone := true
167+
var err error
168+
if backend != nil {
169+
actorDone, err = backend.DeleteAgentHarness(ctx, sandboxbackend.Handle{ID: actorID})
170+
}
171+
if err != nil {
172+
if r.Recorder != nil {
173+
r.Recorder.Eventf(ah, nil, "Warning", "AgentHarnessDeleteFailed", "DeleteAgentHarness", "%s", err.Error())
174+
}
175+
return ctrl.Result{RequeueAfter: agentHarnessNotReadyRequeue}, err
176+
}
177+
if !actorDone {
178+
setAgentHarnessCondition(ah, v1alpha2.AgentHarnessConditionTypeActorReady,
179+
metav1.ConditionFalse, "ActorDeleting", fmt.Sprintf("waiting for backend actor %q deletion", actorID))
180+
if err := patchAgentHarnessStatus(ctx, r.Client, ah); err != nil {
181+
return ctrl.Result{}, err
182+
}
183+
return ctrl.Result{RequeueAfter: agentHarnessNotReadyRequeue}, nil
184+
}
185+
}
186+
ah.Status.BackendRef = nil
187+
if err := patchAgentHarnessStatus(ctx, r.Client, ah); err != nil {
188+
return ctrl.Result{}, err
189+
}
190+
}
191+
192+
controllerutil.RemoveFinalizer(ah, agentHarnessFinalizer)
193+
if err := r.Client.Update(ctx, ah); err != nil {
194+
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)
195+
}
196+
return ctrl.Result{}, nil
197+
}
198+
199+
// SetupWithManager registers the OpenShell AgentHarness controller with the manager.
200+
func (r *OpenShellAgentHarnessController) SetupWithManager(mgr ctrl.Manager) error {
201+
b := ctrl.NewControllerManagedBy(mgr).
202+
WithOptions(controller.Options{NeedLeaderElection: new(true)}).
203+
For(&v1alpha2.AgentHarness{}, builder.WithPredicates(agentHarnessRuntimePredicate(v1alpha2.AgentHarnessRuntimeOpenshell)))
204+
return b.Named("agentharness-openshell").Complete(r)
205+
}

0 commit comments

Comments
 (0)