Skip to content
Merged
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: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ KMCP_VERSION ?= $(shell $(AWK) '/github\.com\/kagent-dev\/kmcp/ { print substr($
# Substrate
SUBSTRATE_ENABLED ?= false
SUBSTRATE_VERSION ?= $(shell $(AWK) '/github\.com\/kagent-dev\/substrate/ { print substr($$5, 2) }' go/go.mod) # Substrate version defaults to the replace target in go.mod
SUBSTRATE_REPO ?= oci://ghcr.io/kagent-dev/substrate/helm # Override for local dev when consuming a locally-published chart, e.g. oci://localhost:5001/kagent-dev/substrate/helm

HELM_ACTION=upgrade --install

Expand Down Expand Up @@ -453,8 +454,8 @@ helm-tools: ## Package all tool Helm charts into the dist folder
.PHONY: helm-version
helm-version: ## Stamp chart versions, update dependencies, and package kagent + kagent-crds
helm-version: helm-cleanup helm-agents helm-tools
VERSION=$(VERSION) KMCP_VERSION=$(KMCP_VERSION) SUBSTRATE_VERSION=$(SUBSTRATE_VERSION) envsubst < helm/kagent-crds/Chart-template.yaml > helm/kagent-crds/Chart.yaml
VERSION=$(VERSION) KMCP_VERSION=$(KMCP_VERSION) SUBSTRATE_VERSION=$(SUBSTRATE_VERSION) envsubst < helm/kagent/Chart-template.yaml > helm/kagent/Chart.yaml
VERSION=$(VERSION) KMCP_VERSION=$(KMCP_VERSION) SUBSTRATE_VERSION=$(SUBSTRATE_VERSION) SUBSTRATE_REPO=$(SUBSTRATE_REPO) envsubst < helm/kagent-crds/Chart-template.yaml > helm/kagent-crds/Chart.yaml
VERSION=$(VERSION) KMCP_VERSION=$(KMCP_VERSION) SUBSTRATE_VERSION=$(SUBSTRATE_VERSION) SUBSTRATE_REPO=$(SUBSTRATE_REPO) envsubst < helm/kagent/Chart-template.yaml > helm/kagent/Chart.yaml
helm dependency update helm/kagent
helm dependency update helm/kagent-crds
helm package -d $(HELM_DIST_FOLDER) helm/kagent-crds
Expand Down
25 changes: 24 additions & 1 deletion examples/substrate-openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,37 @@ Follow these instructions to install Substrate on a kind cluster. This feature a

This assumes you've configured a kind cluster using `make create-kind-cluster`.

Create a `substrate-values.yaml` file:
### Image-pull args for a local (kind) registry

atelet is what pulls the ActorTemplate container image for each golden actor. It uses
`go-containerregistry` directly (not containerd), so containerd's registry-mirror
config on the kind node does **not** apply to atelet — you have to tell atelet how to
reach the local kind registry with its own flags:

```yaml
atelet:
# Skip the GCP application-default-credentials probe. Substrate defaults this
# to true (for GKE + Artifact Registry); on kind it just adds latency and a
# noisy log line before falling back to anonymous auth.
gcpAuthForImagePulls: false

# Rewrite `localhost:PORT/...` image refs (which is what `make helm-install`
# renders when `--set registry=localhost:5001` is in effect) to a hostname
# that atelet's puller can actually resolve from inside its pod. atelet also
# applies `name.Insecure` for any ref that was originally `localhost:*`, so
# the rewritten `kind-registry:5000` ref is fetched over HTTP (kind-registry
# is `registry:2` with no TLS by default). Without both parts of this — the
# rewrite AND the insecure flag it triggers — atelet errors out with either
# `dial tcp [::1]:5001: connect: connection refused` (no rewrite), or
# `http: server gave HTTP response to HTTPS client` (rewrite without Insecure).
extraArgs:
- --localhost-registry-replacement=kind-registry:5000
```

When installing Substrate as a **subchart** of kagent (i.e. `--set substrate.enabled=true`
on the kagent chart), prefix these keys with `substrate.` — e.g.
`--set-json 'substrate.atelet.extraArgs=["--localhost-registry-replacement=kind-registry:5000"]'`.

Then install the Substrate platform and kagent:

```bash
Expand Down
1 change: 1 addition & 0 deletions go/api/httpapi/substrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type SubstrateActorTemplateEntry struct {
// SubstrateActorEntry is runtime state from ate-api (redis).
type SubstrateActorEntry struct {
ActorID string `json:"actorId"`
Atespace string `json:"atespace,omitempty"`
Status string `json:"status"`
ActorTemplateNamespace string `json:"actorTemplateNamespace,omitempty"`
ActorTemplateName string `json:"actorTemplateName,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion go/core/internal/a2a/substrate_sandbox_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func (t *substrateSandboxSessionRoundTripper) RoundTrip(req *http.Request) (*htt

// Proxy the A2A request through atenet-router to the session actor. The router
// selects the actor by HTTP Host; actor ID comes from EnsureSessionActor above.
actorRT, err := newSubstrateAgentRoundTripper(t.routerURL, res.Handle.ID, t.base)
actorRT, err := newSubstrateAgentRoundTripper(t.routerURL, res.Handle.Atespace, res.Handle.ID, t.base)
if err != nil {
return nil, err
}
Expand Down
4 changes: 2 additions & 2 deletions go/core/internal/a2a/substrate_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ type substrateAgentRoundTripper struct {
base http.RoundTripper
}

func newSubstrateAgentRoundTripper(routerURL, actorID string, base http.RoundTripper) (http.RoundTripper, error) {
target, host, err := substrate.GatewayRouterTarget(routerURL, actorID)
func newSubstrateAgentRoundTripper(routerURL, atespace, actorID string, base http.RoundTripper) (http.RoundTripper, error) {
target, host, err := substrate.GatewayRouterTarget(routerURL, atespace, actorID)
if err != nil {
return nil, err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func (h *Handlers) HandleAgentHarnessGateway(w ErrorResponseWriter, r *http.Requ
return
}

target, upstreamHost, err := h.resolveSubstrateGatewayTarget(r.Context(), ensureRes.Handle.ID)
target, upstreamHost, err := h.resolveSubstrateGatewayTarget(r.Context(), ensureRes.Handle.Atespace, ensureRes.Handle.ID)
if err != nil {
log.Info("resolve substrate gateway target failed", "error", err)
http.Error(w, err.Error(), http.StatusServiceUnavailable)
Expand All @@ -99,19 +99,20 @@ func (h *Handlers) HandleAgentHarnessGateway(w ErrorResponseWriter, r *http.Requ
// explicitly suspends a session via the suspend endpoint.
}

func (h *Handlers) resolveSubstrateGatewayTarget(ctx context.Context, actorID string) (*url.URL, string, error) {
func (h *Handlers) resolveSubstrateGatewayTarget(ctx context.Context, atespace, actorID string) (*url.URL, string, error) {
cfg := h.AgentHarnessGateway
if cfg == nil {
return nil, "", fmt.Errorf("substrate gateway is not configured")
}

actorID = strings.TrimSpace(actorID)
target, host, err := substrate.GatewayRouterTarget(cfg.AtenetRouterURL, actorID)
target, host, err := substrate.GatewayRouterTarget(cfg.AtenetRouterURL, atespace, actorID)
if err != nil {
return nil, "", fmt.Errorf("substrate actor %q: %w", actorID, err)
}
ctrllog.FromContext(ctx).WithName("agentharness-gateway").Info(
"proxying via atenet-router",
"atespace", atespace,
"actor", actorID,
"router", target.String(),
"host", host,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

func TestACPProxyForwardsToAtenetRouterWithActorHost(t *testing.T) {
t.Parallel()
const actorHost = "ahr-kagent-my-claw.actors.resources.substrate.ate.dev"
const actorHost = "ahr-kagent-my-claw.kagent.actors.resources.substrate.ate.dev"

var gotHost, gotAuth, gotPath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -56,9 +56,9 @@ func TestACPProxyForwardsToAtenetRouterWithActorHost(t *testing.T) {

func TestACPProxyRewriteTargetsAtenetRouterHost(t *testing.T) {
t.Parallel()
const actorHost = "ahr-kagent-my-claw.actors.resources.substrate.ate.dev"
const actorHost = "ahr-kagent-my-claw.kagent.actors.resources.substrate.ate.dev"

target, host, err := substrate.GatewayRouterTarget(substrate.DefaultAtenetRouterURL, "ahr-kagent-my-claw")
target, host, err := substrate.GatewayRouterTarget(substrate.DefaultAtenetRouterURL, "kagent", "ahr-kagent-my-claw")
if err != nil {
t.Fatal(err)
}
Expand Down
1 change: 1 addition & 0 deletions go/core/internal/httpserver/handlers/substrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func (h *SubstrateHandler) listAteAPIState(ctx context.Context, namespaces []str
func actorEntryFromPB(a *ateapipb.Actor) api.SubstrateActorEntry {
return api.SubstrateActorEntry{
ActorID: a.GetActorId(),
Atespace: a.GetAtespace(),
Status: substrate.ActorStatusLabel(a.GetStatus()),
ActorTemplateNamespace: a.GetActorTemplateNamespace(),
ActorTemplateName: a.GetActorTemplateName(),
Expand Down
6 changes: 5 additions & 1 deletion go/core/pkg/sandboxbackend/async.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import (

// Handle is the opaque identifier an AsyncBackend uses to address a sandbox
// it owns on an external control plane. Persisted in AgentHarness.Status.BackendRef.
//
// For Substrate backends, Atespace scopes the ID — an actor's identity on
// substrate is (Atespace, ID). Non-substrate backends may leave Atespace empty.
type Handle struct {
ID string
ID string
Atespace string
}

// EnsureResult is returned by EnsureAgentHarness. Endpoint (if set) is surfaced
Expand Down
8 changes: 4 additions & 4 deletions go/core/pkg/sandboxbackend/substrate/actor_reachability.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const (
)

type actorGetter interface {
GetActor(ctx context.Context, actorID string) (*ateapipb.Actor, error)
GetActor(ctx context.Context, atespace, actorID string) (*ateapipb.Actor, error)
}

// waitForActorReachableViaAtenet blocks until ate-api reports the actor RUNNING and
Expand All @@ -27,7 +27,7 @@ func waitForActorReachableViaAtenet(
ctx context.Context,
actors actorGetter,
httpClient *http.Client,
routerURL, actorID string,
routerURL, atespace, actorID string,
) error {
if actors == nil {
return fmt.Errorf("substrate ate-api client is required")
Expand All @@ -43,7 +43,7 @@ func waitForActorReachableViaAtenet(
waitCtx, cancel := context.WithTimeout(ctx, defaultActorReachabilityTimeout)
defer cancel()

target, host, err := GatewayRouterTarget(routerURL, actorID)
target, host, err := GatewayRouterTarget(routerURL, atespace, actorID)
if err != nil {
return err
}
Expand All @@ -53,7 +53,7 @@ func waitForActorReachableViaAtenet(
defer ticker.Stop()

for {
actor, getErr := actors.GetActor(waitCtx, actorID)
actor, getErr := actors.GetActor(waitCtx, atespace, actorID)
if getErr == nil && actor.GetStatus() == ateapipb.Actor_STATUS_RUNNING {
statusCode, probeErr := probeActorViaAtenetRouter(waitCtx, httpClient, probeURL, host)
if probeErr == nil && statusCode < http.StatusInternalServerError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type stubActorGetter struct {
status atomic.Int32
}

func (s *stubActorGetter) GetActor(context.Context, string) (*ateapipb.Actor, error) {
func (s *stubActorGetter) GetActor(context.Context, string, string) (*ateapipb.Actor, error) {
return &ateapipb.Actor{Status: ateapipb.Actor_Status(s.status.Load())}, nil
}

Expand All @@ -33,15 +33,15 @@ func TestProbeActorViaAtenetRouterSetsActorHost(t *testing.T) {
context.Background(),
srv.Client(),
srv.URL+"/health",
"asr-kagent-demo.actors.resources.substrate.ate.dev",
"asr-kagent-demo.kagent.actors.resources.substrate.ate.dev",
)
if err != nil {
t.Fatalf("probeActorViaAtenetRouter() error = %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if gotHost != "asr-kagent-demo.actors.resources.substrate.ate.dev" {
if gotHost != "asr-kagent-demo.kagent.actors.resources.substrate.ate.dev" {
t.Fatalf("Host = %q", gotHost)
}
}
Expand Down Expand Up @@ -70,6 +70,7 @@ func TestWaitForActorReachableViaAtenetRetriesUntilHealthy(t *testing.T) {
actors,
srv.Client(),
srv.URL,
"kagent",
"asr-kagent-demo",
)
if err != nil {
Expand Down Expand Up @@ -104,6 +105,7 @@ func TestWaitForActorReachableViaAtenetWaitsForRunningStatus(t *testing.T) {
actors,
srv.Client(),
srv.URL,
"kagent",
"asr-kagent-demo",
)
if err != nil {
Expand All @@ -130,6 +132,7 @@ func TestWaitForActorReachableViaAtenetTimesOut(t *testing.T) {
actors,
srv.Client(),
srv.URL,
"kagent",
"asr-kagent-demo",
)
if err == nil {
Expand Down
31 changes: 18 additions & 13 deletions go/core/pkg/sandboxbackend/substrate/agent_actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,18 @@ func (b *SandboxAgentActorBackend) EnsureSessionActor(ctx context.Context, sa *v
return sandboxbackend.EnsureResult{}, err
}
tmplNS := sa.Namespace
atespace := sa.Namespace

created := false
actor, err := b.client.GetActor(ctx, actorID)
actor, err := b.client.GetActor(ctx, atespace, actorID)
if err != nil {
if status.Code(err) != codes.NotFound {
return sandboxbackend.EnsureResult{}, fmt.Errorf("substrate GetActor %q: %w", actorID, err)
}
actor, err = b.client.CreateActor(ctx, actorID, tmplNS, tmplName)
if err := b.client.EnsureAtespace(ctx, atespace); err != nil {
return sandboxbackend.EnsureResult{}, fmt.Errorf("substrate EnsureAtespace %q: %w", atespace, err)
}
actor, err = b.client.CreateActor(ctx, atespace, actorID, tmplNS, tmplName)
if err != nil {
return sandboxbackend.EnsureResult{}, wrapCreateActorError(actorID, err)
}
Expand All @@ -85,13 +89,13 @@ func (b *SandboxAgentActorBackend) EnsureSessionActor(ctx context.Context, sa *v
ateapipb.Actor_STATUS_PAUSED, ateapipb.Actor_STATUS_PAUSING:
// PAUSED/PAUSING keep a node-local snapshot; ResumeActor brings them back
// the same as a suspended actor.
_, err = b.client.ResumeActor(ctx, actorID)
_, err = b.client.ResumeActor(ctx, atespace, actorID)
if err != nil {
return sandboxbackend.EnsureResult{}, wrapResumeActorError(actorID, err)
}
}

if err := waitForActorReachableViaAtenet(ctx, b.client, nil, b.atenetRouterURL, actorID); err != nil {
if err := waitForActorReachableViaAtenet(ctx, b.client, nil, b.atenetRouterURL, atespace, actorID); err != nil {
return sandboxbackend.EnsureResult{}, err
}

Expand All @@ -104,9 +108,9 @@ func (b *SandboxAgentActorBackend) EnsureSessionActor(ctx context.Context, sa *v
b.scheduleReapOrphanedSessionActors(sa, actorID)
}

host := ActorHost(actorID, "")
host := ActorHost(atespace, actorID, "")
return sandboxbackend.EnsureResult{
Handle: sandboxbackend.Handle{ID: actorID},
Handle: sandboxbackend.Handle{ID: actorID, Atespace: atespace},
Endpoint: fmt.Sprintf("atenet-router Host %s", host),
}, nil
}
Expand Down Expand Up @@ -175,7 +179,7 @@ func (b *SandboxAgentActorBackend) reapOrphanedSessionActors(ctx context.Context
if actor.GetActorTemplateNamespace() == sa.Namespace && actor.GetActorTemplateName() == current.Name {
continue // under the current desired config — a live session, keep
}
if _, err := deleteActorIfSuspended(ctx, b.client, id); err != nil {
if _, err := deleteActorIfSuspended(ctx, b.client, sa.Namespace, id); err != nil {
errs = append(errs, err)
}
}
Expand All @@ -191,7 +195,8 @@ func (b *SandboxAgentActorBackend) SuspendSessionActor(ctx context.Context, sa *
if err != nil {
return err
}
actor, err := b.client.GetActor(ctx, actorID)
atespace := sa.Namespace
actor, err := b.client.GetActor(ctx, atespace, actorID)
if err != nil {
if status.Code(err) == codes.NotFound {
return nil
Expand All @@ -200,19 +205,19 @@ func (b *SandboxAgentActorBackend) SuspendSessionActor(ctx context.Context, sa *
}
switch actor.GetStatus() {
case ateapipb.Actor_STATUS_RUNNING, ateapipb.Actor_STATUS_RESUMING, ateapipb.Actor_STATUS_SUSPENDING:
if err := b.client.SuspendActor(ctx, actorID); err != nil && status.Code(err) != codes.NotFound {
if err := b.client.SuspendActor(ctx, atespace, actorID); err != nil && status.Code(err) != codes.NotFound {
return fmt.Errorf("substrate SuspendActor %q: %w", actorID, err)
}
}
return nil
}

// DeleteSandboxAgentActor deletes a substrate actor by id.
func (b *SandboxAgentActorBackend) DeleteSandboxAgentActor(ctx context.Context, actorID string) (bool, error) {
func (b *SandboxAgentActorBackend) DeleteSandboxAgentActor(ctx context.Context, atespace, actorID string) (bool, error) {
if strings.TrimSpace(actorID) == "" {
return true, nil
}
return deleteActor(ctx, b.client, actorID)
return deleteActor(ctx, b.client, atespace, actorID)
}

// DeleteSandboxAgentSessionActor deletes the actor(s) for a single chat session. Because the
Expand All @@ -236,7 +241,7 @@ func (b *SandboxAgentActorBackend) DeleteSandboxAgentSessionActor(ctx context.Co
continue
}
seen[actorID] = struct{}{}
done, err := b.DeleteSandboxAgentActor(ctx, actorID)
done, err := b.DeleteSandboxAgentActor(ctx, sa.Namespace, actorID)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -319,7 +324,7 @@ func (b *SandboxAgentActorBackend) DeleteAllSandboxAgentActors(ctx context.Conte
if !actorBelongsToSandboxAgent(sa, actor, prefix, ownedTemplates) {
continue
}
done, err := deleteActor(ctx, b.client, id)
done, err := deleteActor(ctx, b.client, sa.Namespace, id)
if err != nil {
return false, fmt.Errorf("delete substrate actor %q: %w", id, err)
}
Expand Down
6 changes: 6 additions & 0 deletions go/core/pkg/sandboxbackend/substrate/agent_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ func (p *Lifecycle) buildSandboxAgentActorTemplate(
WorkerSelector: workerSelectorForPool(wpKey),
SnapshotsConfig: atev1alpha1.SnapshotsConfig{
Location: sandboxAgentSnapshotsLocation(sa),
// Mirror substrate's CRD defaults so kagent's spec-drift check
// (apiequality.Semantic.DeepEqual) treats them as equal to the
// values the API server fills in on admission — otherwise kagent
// re-creates the ActorTemplate every reconcile in a hot loop.
OnPause: atev1alpha1.SnapshotScopeFull,
OnCommit: atev1alpha1.SnapshotScopeFull,
},
},
}
Expand Down
Loading
Loading