Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ public KubernetesFilesystemSpec workspaceSpec(WorkspaceSpec workspaceSpec) {

@Override
protected SandboxClient<?> createClient() {
return client != null ? client : options.createClient();
if (client != null) {
return client;
}
SandboxSnapshotSpec effective =
getSnapshotSpecOverride() != null ? getSnapshotSpecOverride() : snapshotSpec();
return new KubernetesSandboxClient(options, null, effective);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ public class KubernetesSandboxClient implements SandboxClient<KubernetesSandboxC

private final ObjectMapper objectMapper;
private final KubernetesSandboxClientOptions defaultOptions;
private final SandboxSnapshotSpec snapshotSpec;

public KubernetesSandboxClient() {
this(new KubernetesSandboxClientOptions(), null);
this(new KubernetesSandboxClientOptions(), null, null);
}

public KubernetesSandboxClient(KubernetesSandboxClientOptions defaultOptions) {
this(defaultOptions, null);
this(defaultOptions, null, null);
}

/**
Expand All @@ -52,6 +53,19 @@ public KubernetesSandboxClient(KubernetesSandboxClientOptions defaultOptions) {
*/
public KubernetesSandboxClient(
KubernetesSandboxClientOptions defaultOptions, ObjectMapper objectMapper) {
this(defaultOptions, objectMapper, null);
}

/**
* @param defaultOptions template options merged into each {@link #create} call
* @param objectMapper optional; when null a default mapper is created
* @param snapshotSpec used in {@link #resume} to re-inject the snapshot client after
* deserialization. When null, the snapshot field is left as-is (backward-compatible).
*/
public KubernetesSandboxClient(
KubernetesSandboxClientOptions defaultOptions,
ObjectMapper objectMapper,
SandboxSnapshotSpec snapshotSpec) {
this.defaultOptions =
defaultOptions != null ? defaultOptions : new KubernetesSandboxClientOptions();
this.objectMapper =
Expand All @@ -61,6 +75,7 @@ public KubernetesSandboxClient(
.findAndRegisterModules()
.registerModule(new HarnessSandboxJacksonModule())
.registerModule(new KubernetesHarnessSandboxJacksonModule());
this.snapshotSpec = snapshotSpec;
}

@Override
Expand Down Expand Up @@ -100,6 +115,10 @@ public Sandbox resume(SandboxState state) {
throw new IllegalArgumentException(
"Expected KubernetesSandboxState but got: " + state.getClass().getName());
}
// Re-inject snapshot client lost during JSON serialization
if (snapshotSpec != null && k8s.getSnapshot() != null) {
k8s.setSnapshot(snapshotSpec.build(k8s.getSnapshot().getId()));
}
KubernetesSandboxClientOptions merged = merge(null);
KubernetesClient kc = resolveClient(merged);
Fabric8KubernetesPodRuntime runtime = new Fabric8KubernetesPodRuntime(kc, merged);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.extensions.sandbox.kubernetes;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;

import io.agentscope.harness.agent.sandbox.SandboxClient;
import io.agentscope.harness.agent.sandbox.snapshot.NoopSnapshotSpec;
import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec;
import org.junit.jupiter.api.Test;

class KubernetesFilesystemSpecTest {

@Test
void createClient_returnsExternallySetClient() {
KubernetesFilesystemSpec spec = new KubernetesFilesystemSpec();
SandboxClient<?> external = new KubernetesSandboxClient();
spec.client(external);
assertSame(external, spec.createClient());
}

@Test
void createClient_constructsDefaultClientWithSnapshotSpec() {
KubernetesFilesystemSpec spec = new KubernetesFilesystemSpec();
SandboxSnapshotSpec snapshotSpec = new NoopSnapshotSpec();
spec.snapshotSpec(snapshotSpec);
SandboxClient<?> client = spec.createClient();
assertNotNull(client);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@
*/
package io.agentscope.extensions.sandbox.kubernetes;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.harness.agent.sandbox.SandboxState;
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
import io.agentscope.harness.agent.sandbox.json.HarnessSandboxJacksonModule;
import io.agentscope.harness.agent.sandbox.snapshot.RemoteSandboxSnapshot;
import io.agentscope.harness.agent.sandbox.snapshot.RemoteSnapshotClient;
import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshot;
import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -49,4 +58,38 @@ void roundTripKubernetesState() throws Exception {
Assertions.assertEquals("ns1", k.getNamespace());
Assertions.assertEquals("p1", k.getPodName());
}

@Test
void resumeReInjectsSnapshotClient() {
// Build a state with RemoteSandboxSnapshot that has id but client=null (simulating
// deserialization)
KubernetesSandboxState state = new KubernetesSandboxState();
state.setSessionId("test-session");
state.setNamespace("default");
state.setContainerName("agent");
state.setWorkspaceRoot("/workspace");
state.setImage("ubuntu:24.04");
WorkspaceSpec spec = new WorkspaceSpec();
spec.setRoot("/workspace");
state.setWorkspaceSpec(spec);
state.setWorkspaceRootReady(false);

RemoteSandboxSnapshot snapshotWithNullClient =
new RemoteSandboxSnapshot(null, "snap-id-123");
state.setSnapshot(snapshotWithNullClient);

// Client with snapshotSpec that can rebuild the client
RemoteSnapshotClient mockClient = mock(RemoteSnapshotClient.class);
SandboxSnapshotSpec snapshotSpec = id -> new RemoteSandboxSnapshot(mockClient, id);
KubernetesSandboxClient client =
new KubernetesSandboxClient(
new KubernetesSandboxClientOptions(), null, snapshotSpec);

KubernetesSandbox sandbox = (KubernetesSandbox) client.resume(state);

SandboxSnapshot rebuilt = sandbox.getState().getSnapshot();
assertNotNull(rebuilt);
assertEquals("snap-id-123", rebuilt.getId());
assertInstanceOf(RemoteSandboxSnapshot.class, rebuilt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public abstract class SandboxFilesystemSpec {
private SandboxExecutionGuard executionGuard;
private boolean workspaceProjectionEnabled = true;
private List<String> workspaceProjectionRoots = DEFAULT_WORKSPACE_PROJECTION_ROOTS;
private boolean keepAlive = false;

protected abstract SandboxClient<?> createClient();

Expand Down Expand Up @@ -107,6 +108,24 @@ public SandboxFilesystemSpec workspaceProjectionRoots(List<String> includeRoots)
return this;
}

/**
* When {@code true}, {@link io.agentscope.harness.agent.sandbox.SandboxManager#release}
* calls {@link io.agentscope.harness.agent.sandbox.Sandbox#stop()} (persisting the
* snapshot) but skips {@link io.agentscope.harness.agent.sandbox.Sandbox#shutdown()},
* leaving the underlying resource (e.g. Pod) alive for reuse in the next call.
*
* @param keepAlive true to preserve the sandbox resource across calls
* @return this spec
*/
public SandboxFilesystemSpec keepAlive(boolean keepAlive) {
this.keepAlive = keepAlive;
return this;
}

public boolean isKeepAlive() {
return keepAlive;
}

public final SandboxContext toSandboxContext(Path hostWorkspaceRoot) {
SandboxClient<?> client =
Objects.requireNonNull(createClient(), "sandbox client is required");
Expand All @@ -117,6 +136,7 @@ public final SandboxContext toSandboxContext(Path hostWorkspaceRoot) {
.snapshotSpec(snapshotSpecOverride != null ? snapshotSpecOverride : snapshotSpec())
.workspaceSpec(withProjection)
.isolationScope(isolationScope)
.keepAlive(keepAlive)
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public void acquireForCall(RuntimeContext ctx) {
} catch (Exception e) {
filesystemProxy.setSandbox(null);
try {
sandboxManager.release(result);
sandboxManager.release(result, sandboxContext);
} catch (Exception releaseErr) {
log.warn(
"[sandbox-mw] Failed to release session after pre-call failure: {}",
Expand Down Expand Up @@ -125,7 +125,7 @@ public void releaseForCall(RuntimeContext ctx) {
log.warn("[sandbox-mw] Failed to persist sandbox state: {}", e.getMessage(), e);
}
try {
sandboxManager.release(result);
sandboxManager.release(result, sandboxContext);
} catch (Exception e) {
log.warn("[sandbox-mw] Failed to release sandbox session: {}", e.getMessage(), e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public final class SandboxContext {
private final Sandbox externalSandbox;
private final SandboxState externalSandboxState;
private final IsolationScope isolationScope;
private final boolean keepAlive;

private SandboxContext(Builder builder) {
this.client = builder.client;
Expand All @@ -42,6 +43,7 @@ private SandboxContext(Builder builder) {
this.externalSandbox = builder.externalSandbox;
this.externalSandboxState = builder.externalSandboxState;
this.isolationScope = builder.isolationScope;
this.keepAlive = builder.keepAlive;
}

public SandboxClient<?> getClient() {
Expand Down Expand Up @@ -72,6 +74,10 @@ public IsolationScope getIsolationScope() {
return isolationScope;
}

public boolean isKeepAlive() {
return keepAlive;
}

public static Builder builder() {
return new Builder();
}
Expand All @@ -85,6 +91,7 @@ public static final class Builder {
private Sandbox externalSandbox;
private SandboxState externalSandboxState;
private IsolationScope isolationScope;
private boolean keepAlive = false;

private Builder() {}

Expand Down Expand Up @@ -123,6 +130,11 @@ public Builder isolationScope(IsolationScope isolationScope) {
return this;
}

public Builder keepAlive(boolean keepAlive) {
this.keepAlive = keepAlive;
return this;
}

public SandboxContext build() {
return new SandboxContext(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,26 @@ public final class SandboxIsolationKey {
private final IsolationScope scope;
private final String value;

private SandboxIsolationKey(IsolationScope scope, String value) {
SandboxIsolationKey(IsolationScope scope, String value) {
this.scope = scope;
this.value = value;
}

/**
* Creates an isolation key directly from a scope and its discriminating value.
*
* <p>Unlike {@link #resolve}, this factory does not require a {@link RuntimeContext} and
* produces the key unconditionally. Use it in out-of-band operations (archive, restore,
* scavenger) where no agent call context is available.
*
* @param scope the isolation scope (must not be null)
* @param value the discriminating value within the scope (e.g. user id, session id)
* @return a new isolation key
*/
public static SandboxIsolationKey of(IsolationScope scope, String value) {
return new SandboxIsolationKey(scope, value);
}

/**
* Resolves an isolation key from the given scope, runtime context, and agent ID.
*
Expand Down
Loading
Loading