Skip to content

Commit fb9642a

Browse files
Merge branch 'main' into fix/go-system-prompt
2 parents bce8073 + 83c8ae9 commit fb9642a

30 files changed

Lines changed: 479 additions & 128 deletions

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ name: CI Build
22

33
on:
44
push:
5-
branches: [main, release/v0.7.x]
5+
branches: [main, "release/**"]
66
paths-ignore:
77
- "**/*.md"
88
pull_request:
9-
branches: [main, release/v0.7.x]
9+
branches: [main, "release/**"]
1010
paths-ignore:
1111
- "**/*.md"
1212
workflow_dispatch:

.github/workflows/migration-immutability.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Migration Immutability
22

33
on:
44
pull_request:
5-
branches: [main]
5+
branches: [main, "release/**"]
66
paths:
77
- "go/core/pkg/migrations/**"
88

.github/workflows/sqlc-generate-check.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: sqlc Generate Check
22

33
on:
44
pull_request:
5-
branches: [main]
5+
branches: [main, "release/**"]
66
paths:
77
- "go/core/internal/database/queries/**"
88
- "go/core/internal/database/sqlc.yaml"

.github/workflows/ui-chromatic.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ name: UI Storybook & Chromatic
55

66
on:
77
push:
8-
branches: [main, release/v0.7.x]
8+
branches: [main, "release/**"]
99
paths:
1010
- "ui/**"
1111
pull_request:
12-
branches: [main, release/v0.7.x]
12+
branches: [main, "release/**"]
1313
paths:
1414
- "ui/**"
1515
workflow_dispatch:

examples/substrate-openclaw/README.md

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
1-
# kagent agents and agentharness on substrate
2-
3-
Follow these instructions to install substrate on a kind cluster. This feature allows you to run AgentHarness (OpenClaw) and declerative Go agents in substrate.
1+
# kagent agents and AgentHarness on Substrate
42

3+
Follow these instructions to install Substrate on a kind cluster. This feature allows you to run AgentHarness (OpenClaw) and declarative Go SandboxAgents on Agent Substrate.
54

65
## 1. Install Substrate on your Kind cluster
76

8-
This assumes you've configured kind cluster using `make create-kind-cluster`.
7+
This assumes you've configured a kind cluster using `make create-kind-cluster`.
98

10-
Create the substrate-values.yaml file:
9+
Create a `substrate-values.yaml` file:
1110

1211
```yaml
1312
atelet:
1413
extraArgs:
1514
- --localhost-registry-replacement=kind-registry:5000
1615
```
1716
18-
Then install substrate and kagent:
17+
Then install the Substrate platform and kagent:
1918
2019
```bash
21-
export ATEOM_VERSION=v0.0.6
20+
export SUBSTRATE_VERSION=0.0.7
2221

2322
helm upgrade --install substrate-crds \
24-
oci://ghcr.io/kagent-dev/substrate/helm/substrate-crds
23+
oci://ghcr.io/kagent-dev/substrate/helm/substrate-crds \
24+
--version "${SUBSTRATE_VERSION}"
2525

2626
helm upgrade --install substrate \
2727
oci://ghcr.io/kagent-dev/substrate/helm/substrate \
28+
--version "${SUBSTRATE_VERSION}" \
2829
--namespace ate-system \
2930
--create-namespace -f substrate-values.yaml
3031

@@ -33,18 +34,24 @@ make helm-install KAGENT_HELM_EXTRA_ARGS="\
3334
--set controller.substrate.ateApiEndpoint=dns:///api.ate-system.svc:443 \
3435
--set controller.substrate.ateApiInsecure=true \
3536
--set substrateWorkerPool.create=true \
36-
--set substrateWorkerPool.ateomImage=ghcr.io/kagent-dev/substrate/ateom-gvisor:${ATEOM_VERSION}"
37+
--set substrateWorkerPool.ateomImage=ghcr.io/kagent-dev/substrate/ateom-gvisor:v${SUBSTRATE_VERSION}"
3738
```
3839

39-
## kagent AgentHarness with substrate runtime
40+
When `substrateWorkerPool.create=true`, the kagent chart installs a namespace-scoped `WorkerPool` with:
41+
42+
- `spec.sandboxClass: gvisor`
43+
- label `kagent.dev/worker-pool: kagent-default` (matches generated `ActorTemplate` selectors)
44+
- controller default `workerPool` name set to that pool when `create=true`
45+
46+
**Zero-downtime rollouts:** SandboxAgent config and image rollouts retain the previous `ActorTemplate` until the new golden is Ready (blue-green). Use `substrateWorkerPool.replicas: 2` or higher so a spare worker can build the new golden while the current one keeps serving chat.
4047

41-
kagent generates a per-harness `ActorTemplate` and uses an existing `WorkerPool`.
48+
## 2. AgentHarness with Substrate runtime
4249

43-
The generated `ActorTemplate` uses `controller.substrate.pauseImage`, `controller.substrate.runscAMD64URL`, `controller.substrate.runscAMD64SHA256`, `controller.substrate.runscARM64URL`, and `controller.substrate.runscARM64SHA256` from the Helm values Override them with `--set` or a values file when you need to pin a different gVisor build.
50+
kagent generates a per-harness `ActorTemplate` and schedules actors onto an existing `WorkerPool`.
4451

4552
Create a harness. If `snapshotsConfig` is omitted, kagent defaults it to `gs://ate-snapshots/<namespace>/<agentharnessname>`.
4653

47-
- **Worker pool** — reference an existing pool (`workerPoolRef`) or configure a controller default WorkerPool
54+
- **Worker pool** — reference an existing pool (`workerPoolRef`) or configure a controller default WorkerPool. The target pool must carry label `kagent.dev/worker-pool: <pool-name>`. The kagent Helm-managed pool gets this label automatically; externally owned pools must add it manually.
4855

4956
```yaml
5057
apiVersion: kagent.dev/v1alpha2
@@ -83,23 +90,15 @@ metadata:
8390
kagent.dev/agent-harness: peterj-claw
8491
spec:
8592
pauseImage: gcr.io/gke-release/pause@sha256:bcbd57ba5653580ec647b16d8163cdd1112df3609129b01f912a8032e48265da
86-
runsc:
87-
amd64:
88-
url: gs://gvisor/releases/nightly/2026-06-02/x86_64/runsc
89-
sha256Hash: efd12935f6654c91a1389710eb8dfa4d12b6b9be00db87526dc2eb584ad00119
90-
arm64:
91-
url: gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc
92-
sha256Hash: 1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9
93-
workerPoolRef:
94-
name: peterj-claw-wp
95-
namespace: kagent
93+
sandboxClass: gvisor
94+
workerSelector:
95+
matchLabels:
96+
kagent.dev/worker-pool: kagent-default
9697
snapshotsConfig:
9798
location: gs://ate-snapshots/kagent/peterj-claw
9899
containers:
99100
- name: openclaw
100101
image: ghcr.io/kagent-dev/nemoclaw/sandbox-base@sha256:d52bee415dc4c0dba7164f9eabe727574c056d4f211781f20af249707883a3b4
101-
ports:
102-
- containerPort: 80
103102
command:
104103
- /bin/sh
105104
- -c

go/api/httpapi/substrate.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ type SubstrateActorTemplateEntry struct {
2828
Phase string `json:"phase,omitempty"`
2929
GoldenActorID string `json:"goldenActorId,omitempty"`
3030
GoldenSnapshot string `json:"goldenSnapshot,omitempty"`
31-
WorkerPoolRef string `json:"workerPoolRef,omitempty"`
31+
SandboxClass string `json:"sandboxClass,omitempty"`
32+
WorkerSelector string `json:"workerSelector,omitempty"`
3233
HarnessName string `json:"harnessName,omitempty"`
3334
ManagedByKagent bool `json:"managedByKagent"`
3435
}
@@ -42,7 +43,8 @@ type SubstrateActorEntry struct {
4243
AteomPodNamespace string `json:"ateomPodNamespace,omitempty"`
4344
AteomPodName string `json:"ateomPodName,omitempty"`
4445
AteomPodIP string `json:"ateomPodIp,omitempty"`
45-
LastSnapshot string `json:"lastSnapshot,omitempty"`
46+
LatestSnapshot string `json:"latestSnapshot,omitempty"`
47+
WorkerPoolName string `json:"workerPoolName,omitempty"`
4648
InProgressSnapshot string `json:"inProgressSnapshot,omitempty"`
4749
Version int64 `json:"version,omitempty"`
4850
}

go/core/internal/controller/reconciler/reconciler.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import (
1414
"strings"
1515
"time"
1616

17+
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
1718
"github.com/hashicorp/go-multierror"
1819
reconcilerutils "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler/utils"
1920
"github.com/kagent-dev/kagent/go/core/internal/controller/translator"
2021
"github.com/kagent-dev/kagent/go/core/pkg/egress"
2122
"github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend"
23+
"github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend/substrate"
2224
"github.com/kagent-dev/kmcp/api/v1alpha1"
2325
appsv1 "k8s.io/api/apps/v1"
2426
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -150,11 +152,19 @@ func (a *kagentReconciler) ReconcileKagentSandboxAgent(ctx context.Context, req
150152
}
151153

152154
err := a.reconcileSandboxAgent(ctx, sandboxAgent)
155+
if errors.Is(err, substrate.ErrActorTemplateReconcilePending) {
156+
// A spec-drift recreate is mid-flight; report not-ready without failing
157+
// the resource and return the sentinel so the controller requeues.
158+
if statusErr := a.reconcileSandboxAgentStatus(ctx, sandboxAgent, nil, true); statusErr != nil {
159+
return statusErr
160+
}
161+
return err
162+
}
153163
if err != nil {
154164
reconcileLog.Error(err, "failed to reconcile sandboxagent", "sandboxagent", req.NamespacedName)
155165
}
156166

157-
return a.reconcileSandboxAgentStatus(ctx, sandboxAgent, err)
167+
return a.reconcileSandboxAgentStatus(ctx, sandboxAgent, err, false)
158168
}
159169

160170
func (a *kagentReconciler) handleDeletedAgentResource(ctx context.Context, req ctrl.Request, resourceName string) error {
@@ -239,14 +249,18 @@ func (a *kagentReconciler) reconcileSandboxAgent(ctx context.Context, sa *v1alph
239249
})
240250
}
241251

242-
func (a *kagentReconciler) reconcileSandboxAgentStatus(ctx context.Context, sa *v1alpha2.SandboxAgent, reconcileErr error) error {
252+
func (a *kagentReconciler) reconcileSandboxAgentStatus(ctx context.Context, sa *v1alpha2.SandboxAgent, reconcileErr error, actorTemplatePending bool) error {
243253
deployedCondition := metav1.Condition{
244254
Type: v1alpha2.AgentConditionTypeReady,
245255
Status: metav1.ConditionUnknown,
246256
ObservedGeneration: sa.Generation,
247257
}
248258

249-
if a.sandboxBackend == nil {
259+
if actorTemplatePending {
260+
deployedCondition.Status = metav1.ConditionFalse
261+
deployedCondition.Reason = "ActorTemplateRecreating"
262+
deployedCondition.Message = "waiting for ActorTemplate golden actor deletion and recreate"
263+
} else if a.sandboxBackend == nil {
250264
deployedCondition.Status = metav1.ConditionUnknown
251265
deployedCondition.Reason = "SandboxBackendNotConfigured"
252266
deployedCondition.Message = "Sandbox backend is not configured"
@@ -924,12 +938,31 @@ func (r *kagentReconciler) GetOwnedResourceTypes() []client.Object {
924938
// Function initially copied from https://github.com/open-telemetry/opentelemetry-operator/blob/e6d96f006f05cff0bc3808da1af69b6b636fbe88/internal/controllers/common.go#L141-L192
925939
func (a *kagentReconciler) reconcileDesiredObjects(ctx context.Context, owner metav1.Object, desiredObjects []client.Object, ownedObjects map[types.UID]client.Object) error {
926940
var errs []error
941+
actorTemplatePending := false
927942
for _, desired := range desiredObjects {
928943
l := reconcileLog.WithValues(
929944
"object_name", desired.GetName(),
930945
"object_kind", desired.GetObjectKind(),
931946
)
932947

948+
// Substrate ActorTemplate.spec is immutable, delegate to the sandbox backend to handle spec drift.
949+
if _, ok := desired.(*atev1alpha1.ActorTemplate); ok {
950+
if r, ok := a.sandboxBackend.(actorTemplateReconciler); ok {
951+
if err := r.ReconcileActorTemplate(ctx, desired); err != nil {
952+
if errors.Is(err, substrate.ErrActorTemplateReconcilePending) {
953+
actorTemplatePending = true
954+
pruneOwnedActorTemplate(ownedObjects, desired)
955+
continue
956+
}
957+
l.Error(err, "failed to reconcile ActorTemplate")
958+
errs = append(errs, err)
959+
continue
960+
}
961+
pruneOwnedActorTemplate(ownedObjects, desired)
962+
continue
963+
}
964+
}
965+
933966
// existing is an object the controller runtime will hydrate for us
934967
// we obtain the existing object by deep copying the desired object because it's the most convenient way
935968
existing := desired.DeepCopyObject().(client.Object)
@@ -951,6 +984,9 @@ func (a *kagentReconciler) reconcileDesiredObjects(ctx context.Context, owner me
951984
if len(errs) > 0 {
952985
return fmt.Errorf("failed to create objects for %s: %w", owner.GetName(), errors.Join(errs...))
953986
}
987+
if actorTemplatePending {
988+
return substrate.ErrActorTemplateReconcilePending
989+
}
954990

955991
// Pruning owned objects in the cluster which are not should not be present after the reconciliation.
956992
err := a.deleteObjects(ctx, ownedObjects)
@@ -961,6 +997,25 @@ func (a *kagentReconciler) reconcileDesiredObjects(ctx context.Context, owner me
961997
return nil
962998
}
963999

1000+
// actorTemplateReconciler is implemented by sandbox backends that own substrate
1001+
// ActorTemplate objects and need immutable-spec aware create/recreate semantics.
1002+
type actorTemplateReconciler interface {
1003+
ReconcileActorTemplate(ctx context.Context, desired client.Object) error
1004+
}
1005+
1006+
// pruneOwnedActorTemplate removes the live ActorTemplate matching desired from the
1007+
// prune set so it is not garbage collected.
1008+
func pruneOwnedActorTemplate(owned map[types.UID]client.Object, desired client.Object) {
1009+
for uid, obj := range owned {
1010+
if _, ok := obj.(*atev1alpha1.ActorTemplate); !ok {
1011+
continue
1012+
}
1013+
if obj.GetName() == desired.GetName() && obj.GetNamespace() == desired.GetNamespace() {
1014+
delete(owned, uid)
1015+
}
1016+
}
1017+
}
1018+
9641019
// modified version of controllerutil.CreateOrUpdate to support proto based objects like istio
9651020
func createOrUpdate(ctx context.Context, c client.Client, obj client.Object, f controllerutil.MutateFn) (controllerutil.OperationResult, error) {
9661021
key := client.ObjectKeyFromObject(obj)

go/core/internal/controller/sandboxagent_controller.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package controller
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"reflect"
2324

@@ -86,6 +87,9 @@ func (r *SandboxAgentController) Reconcile(ctx context.Context, req ctrl.Request
8687
}
8788

8889
if err := r.Reconciler.ReconcileKagentSandboxAgent(ctx, req); err != nil {
90+
if errors.Is(err, substrate.ErrActorTemplateReconcilePending) {
91+
return ctrl.Result{RequeueAfter: agentHarnessNotReadyRequeue}, nil
92+
}
8993
return ctrl.Result{}, err
9094
}
9195
return ctrl.Result{}, nil

go/core/internal/httpserver/handlers/substrate.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/kagent-dev/kagent/go/core/internal/httpserver/errors"
1414
"github.com/kagent-dev/kagent/go/core/pkg/auth"
1515
"github.com/kagent-dev/kagent/go/core/pkg/sandboxbackend/substrate"
16+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1617
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
1718
"sigs.k8s.io/controller-runtime/pkg/client"
1819
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
@@ -137,20 +138,15 @@ func (h *SubstrateHandler) listSubstrateCRs(ctx context.Context, namespace strin
137138
Phase: string(tmpl.Status.Phase),
138139
GoldenActorID: tmpl.Status.GoldenActorID,
139140
GoldenSnapshot: tmpl.Status.GoldenSnapshot,
141+
SandboxClass: string(tmpl.Spec.SandboxClass),
142+
WorkerSelector: labelSelectorString(ctx, tmpl.Spec.WorkerSelector),
140143
ManagedByKagent: tmpl.Labels["app.kubernetes.io/managed-by"] == "kagent",
141144
}
142145
if harness := strings.TrimSpace(tmpl.Labels[substrate.HarnessLabelKey]); harness != "" {
143146
entry.HarnessName = harness
144147
} else if agentName := substrate.SandboxAgentNameFromLabels(tmpl.Labels); agentName != "" {
145148
entry.HarnessName = agentName
146149
}
147-
if ref := tmpl.Spec.WorkerPoolRef; ref.Name != "" {
148-
wpNS := ref.Namespace
149-
if wpNS == "" {
150-
wpNS = tmpl.Namespace
151-
}
152-
entry.WorkerPoolRef = wpNS + "/" + ref.Name
153-
}
154150
templates = append(templates, entry)
155151
}
156152

@@ -215,12 +211,41 @@ func actorEntryFromPB(a *ateapipb.Actor) api.SubstrateActorEntry {
215211
AteomPodNamespace: a.GetAteomPodNamespace(),
216212
AteomPodName: a.GetAteomPodName(),
217213
AteomPodIP: a.GetAteomPodIp(),
218-
LastSnapshot: a.GetLastSnapshot(),
214+
LatestSnapshot: snapshotInfoString(a.GetLatestSnapshotInfo()),
215+
WorkerPoolName: a.GetWorkerPoolName(),
219216
InProgressSnapshot: a.GetInProgressSnapshot(),
220217
Version: a.GetVersion(),
221218
}
222219
}
223220

221+
// snapshotInfoString renders a SnapshotInfo as a single location string.
222+
func snapshotInfoString(s *ateapipb.SnapshotInfo) string {
223+
if s == nil {
224+
return ""
225+
}
226+
if ext := s.GetExternal(); ext != nil {
227+
return ext.GetSnapshotUriPrefix()
228+
}
229+
if loc := s.GetLocal(); loc != nil {
230+
return loc.GetSnapshotPrefix()
231+
}
232+
return ""
233+
}
234+
235+
// labelSelectorString renders a metav1.LabelSelector as a compact human-readable
236+
// string (e.g. "kagent.dev/worker-pool=kagent-default") for UI display.
237+
func labelSelectorString(ctx context.Context, sel *metav1.LabelSelector) string {
238+
if sel == nil {
239+
return ""
240+
}
241+
s, err := metav1.LabelSelectorAsSelector(sel)
242+
if err != nil {
243+
ctrllog.FromContext(ctx).Info("invalid ActorTemplate workerSelector", "error", err)
244+
return "<invalid selector>"
245+
}
246+
return s.String()
247+
}
248+
224249
func workerEntryFromPB(w *ateapipb.Worker) api.SubstrateWorkerEntry {
225250
return api.SubstrateWorkerEntry{
226251
WorkerNamespace: w.GetWorkerNamespace(),

0 commit comments

Comments
 (0)