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
8 changes: 8 additions & 0 deletions docs/design/agentcube-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ POST /v1/agent-runtime
{
"namespace": "string", // agentruntime CR namespace, required
"name": "string", // agentruntime CR name, required
"ttl": 3600 // requested maximum lifetime in seconds, optional
}
```

Expand Down Expand Up @@ -311,8 +312,15 @@ POST /v1/code-interpreter
{
"namespace": "string", // codeinterpreter CR namespace, required
"name": "string", // codeinterpreter CR name, required
"ttl": 3600 // requested maximum lifetime in seconds, optional
}
```

For both creation APIs, the effective maximum lifetime is the smaller of the
requested `ttl` and the workload CR's `spec.maxSessionDuration`. If `ttl` is
omitted, `spec.maxSessionDuration` (or its default) is used. The independent
`spec.sessionTimeout` idle limit may reclaim the session earlier.

Response body:

```json
Expand Down
4 changes: 2 additions & 2 deletions docs/design/router-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ The Router uses the Gin framework to provide HTTP services with the following en
- Preserves original response status and body

**Error Handling:**
- Invalid session ID → `400 Bad Request`
- Missing or expired session ID → `404 Not Found` with code `SESSION_NOT_FOUND`
- No entry points → `404 Not Found`
- Invalid endpoint → `500 Internal Server Error`
- Connection refused → `502 Bad Gateway` (SANDBOX_UNREACHABLE)
Expand Down Expand Up @@ -170,7 +170,7 @@ The Router uses the Gin framework to provide HTTP services with the following en

| Status Code | Scenario | Response Body Example |
|-------------|----------|----------------------|
| 400 Bad Request | Invalid session ID | `{"error": "Invalid session id <session-id>", "code": "BadRequest"}` |
| 404 Not Found | Missing or expired session ID | `{"error": "session <session-id> was not found", "code": "SESSION_NOT_FOUND"}` |
| 404 Not Found | No entry points found for sandbox | `{"error": "no entry points found for sandbox", "code": "Service not found"}` |
| 429 Too Many Requests | Server overloaded (concurrent request limit exceeded) | `{"error": "server overloaded, please try again later", "code": "SERVER_OVERLOADED"}` |

Expand Down
23 changes: 22 additions & 1 deletion docs/devguide/code-interpreter-python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The `CodeInterpreterClient` is the main entry point. You can initialize it direc
|-----------|------|---------|-------------|
| `name` | `str` | `"simple-codeinterpreter"` | CodeInterpreter CRD template name |
| `namespace` | `str` | `"default"` | Kubernetes namespace |
| `ttl` | `int` | `3600` | Session time-to-live (seconds) |
| `ttl` | `Optional[int]` | `None` | Optional requested maximum session lifetime in seconds, capped by `spec.maxSessionDuration` |
| `workload_manager_url` | `str` | `None` | Control Plane URL (falls back to env `WORKLOAD_MANAGER_URL`) |
| `router_url` | `str` | `None` | Data Plane Router URL (falls back to env `ROUTER_URL`) |
| `auth_token` | `str` | `None` | Auth token (falls back to K8s SA token) |
Expand Down Expand Up @@ -202,3 +202,24 @@ client2 = CodeInterpreterClient(session_id=session_id)
client2.run_code("python", "print(open('value.txt').read())")
client2.stop() # Cleanup when done
```

If a reused session has already been reclaimed, the SDK raises
`SessionNotFoundError` and clears the stale ID. It does not silently replace
the session because files and other sandbox-local state are no longer
available.

```python
from agentcube import CodeInterpreterClient, SessionNotFoundError

client = CodeInterpreterClient(session_id=saved_session_id)
try:
client.list_files()
except SessionNotFoundError:
# Decide at the application layer whether starting without old state is safe.
client = CodeInterpreterClient()
```

Session reclamation occurs when either the idle `spec.sessionTimeout` or the
effective maximum lifetime is reached. If `ttl` is omitted, the CRD's
`spec.maxSessionDuration` determines the maximum lifetime. Otherwise, the
effective maximum is the smaller of `ttl` and `spec.maxSessionDuration`.
17 changes: 17 additions & 0 deletions pkg/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const (
sessionResourceName = "sessions"
agentRuntimeResourceName = "agentruntimes"
codeInterpreterResourceName = "codeinterpreters"
// SessionNotFoundCode is returned when an invocation references a session
// that is no longer present in the session store.
SessionNotFoundCode = "SESSION_NOT_FOUND"
)

var (
Expand All @@ -57,6 +60,20 @@ func NewSessionNotFoundError(sessionID string) error {
return apierrors.NewNotFound(sessionResource, sessionID)
}

// IsSessionNotFound reports whether err is a not-found error for the session
// resource, rather than a not-found response from a workload or template.
func IsSessionNotFound(err error) bool {
if !apierrors.IsNotFound(err) {
return false
}
var statusErr apierrors.APIStatus
if !errors.As(err, &statusErr) || statusErr.Status().Details == nil {
return false
}
details := statusErr.Status().Details
return details.Group == sessionResource.Group && details.Kind == sessionResource.Resource
}

func workloadResource(kind string) schema.GroupResource {
switch kind {
case types.CodeInterpreterKind:
Expand Down
3 changes: 3 additions & 0 deletions pkg/api/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ func TestNewSessionNotFoundError(t *testing.T) {
assert.Equal(t, sessionResource.Group, status.Details.Group)
assert.Equal(t, sessionResource.Resource, status.Details.Kind)
assert.Equal(t, sessionID, status.Details.Name)
assert.True(t, IsSessionNotFound(err))
assert.False(t, IsSessionNotFound(errors.New("not found")))
assert.False(t, IsSessionNotFound(NewSandboxTemplateNotFoundError("default", "agent", types.AgentRuntimeKind)))
}

func TestWorkloadResource(t *testing.T) {
Expand Down
21 changes: 21 additions & 0 deletions pkg/common/types/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type CreateSandboxRequest struct {
Kind string `json:"kind"`
Name string `json:"name"`
Namespace string `json:"namespace"`
// TTL is the client-requested maximum session lifetime in seconds. The
// workload's MaxSessionDuration remains the upper bound.
TTL *int64 `json:"ttl,omitempty"`
}

type CreateSandboxResponse struct {
Expand All @@ -79,5 +82,23 @@ func (car *CreateSandboxRequest) Validate() error {
if car.Name == "" {
return fmt.Errorf("name is required")
}
if car.TTL != nil {
const maxDurationSeconds = (1<<63 - 1) / int64(time.Second)
if *car.TTL <= 0 {
return fmt.Errorf("ttl must be greater than zero")
}
if *car.TTL > maxDurationSeconds {
return fmt.Errorf("ttl exceeds the maximum supported duration")
}
}
return nil
}

// RequestedTTL returns the requested session lifetime, or zero when the
// client left lifetime selection to the workload configuration.
func (car *CreateSandboxRequest) RequestedTTL() time.Duration {
if car.TTL == nil {
return 0
}
return time.Duration(*car.TTL) * time.Second
}
30 changes: 30 additions & 0 deletions pkg/common/types/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ package types

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"k8s.io/utils/ptr"
)

func TestCreateSandboxRequest_Validate(t *testing.T) {
Expand All @@ -44,9 +46,32 @@ func TestCreateSandboxRequest_Validate(t *testing.T) {
Kind: CodeInterpreterKind,
Namespace: "default",
Name: "test-ci",
TTL: ptr.To[int64](60),
},
wantError: false,
},
{
name: "zero ttl",
req: CreateSandboxRequest{
Kind: CodeInterpreterKind,
Namespace: "default",
Name: "test-ci",
TTL: ptr.To[int64](0),
},
wantError: true,
errorMsg: "ttl must be greater than zero",
},
{
name: "ttl overflows time.Duration",
req: CreateSandboxRequest{
Kind: CodeInterpreterKind,
Namespace: "default",
Name: "test-ci",
TTL: ptr.To[int64](1 << 62),
},
wantError: true,
errorMsg: "ttl exceeds the maximum supported duration",
},
{
name: "invalid kind",
req: CreateSandboxRequest{
Expand Down Expand Up @@ -171,3 +196,8 @@ func TestCreateSandboxRequest_Validate(t *testing.T) {
})
}
}

func TestCreateSandboxRequest_RequestedTTL(t *testing.T) {
assert.Zero(t, (&CreateSandboxRequest{}).RequestedTTL())
assert.Equal(t, 90*time.Second, (&CreateSandboxRequest{TTL: ptr.To[int64](90)}).RequestedTTL())
}
7 changes: 6 additions & 1 deletion pkg/router/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/klog/v2"

"github.com/volcano-sh/agentcube/pkg/api"
"github.com/volcano-sh/agentcube/pkg/common/types"
)

Expand Down Expand Up @@ -142,7 +143,11 @@ func (s *Server) handleGetSandboxError(c *gin.Context, err error) {
if code == http.StatusInternalServerError {
message = "internal server error"
}
c.JSON(code, gin.H{"error": message})
response := gin.H{"error": message}
if api.IsSessionNotFound(err) {
response["code"] = api.SessionNotFoundCode
}
c.JSON(code, response)
return
}

Expand Down
22 changes: 16 additions & 6 deletions pkg/router/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package router

import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
Expand Down Expand Up @@ -154,19 +155,21 @@ func TestHandleInvoke_ErrorPaths(t *testing.T) {
defer teardownEnv()

tests := []struct {
name string
err error
expectedCode int
name string
err error
expectedCode int
expectedErrorCode string
}{
{
name: "session manager generic error",
err: errors.New("session manager error"),
expectedCode: http.StatusInternalServerError,
},
{
name: "session not found",
err: api.NewSessionNotFoundError("missing-session"),
expectedCode: http.StatusNotFound,
name: "session not found",
err: api.NewSessionNotFoundError("missing-session"),
expectedCode: http.StatusNotFound,
expectedErrorCode: api.SessionNotFoundCode,
},
{
name: "agent runtime not found",
Expand Down Expand Up @@ -202,6 +205,13 @@ func TestHandleInvoke_ErrorPaths(t *testing.T) {
if w.Code != tt.expectedCode {
t.Fatalf("expected status %d, got %d", tt.expectedCode, w.Code)
}
var response map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response["code"] != tt.expectedErrorCode {
t.Fatalf("expected error code %q, got %q", tt.expectedErrorCode, response["code"])
}
t.Logf("Response body: %s", w.Body.String())
})
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/workloadmanager/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,9 @@ func (s *Server) handleSandboxCreate(c *gin.Context, kind string) {

switch sandboxReq.Kind {
case types.AgentRuntimeKind:
sandbox, sandboxEntry, err = buildSandboxByAgentRuntime(sandboxReq.Namespace, sandboxReq.Name, ownerID, s.informers)
sandbox, sandboxEntry, err = buildSandboxByAgentRuntime(sandboxReq.Namespace, sandboxReq.Name, ownerID, sandboxReq.RequestedTTL(), s.informers)
case types.CodeInterpreterKind:
sandbox, sandboxClaim, sandboxEntry, err = buildSandboxByCodeInterpreter(sandboxReq.Namespace, sandboxReq.Name, ownerID, s.informers)
sandbox, sandboxClaim, sandboxEntry, err = buildSandboxByCodeInterpreter(sandboxReq.Namespace, sandboxReq.Name, ownerID, sandboxReq.RequestedTTL(), s.informers)
}

if err != nil {
Expand Down
10 changes: 7 additions & 3 deletions pkg/workloadmanager/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ func TestHandleSandboxCreate(t *testing.T) {
expectStatus int
expectMessage string
expectCreateCalls int
expectTTL time.Duration
}{
{
name: "invalid json",
Expand Down Expand Up @@ -829,10 +830,11 @@ func TestHandleSandboxCreate(t *testing.T) {
{
name: "create sandbox success code interpreter",
kind: types.CodeInterpreterKind,
body: `{"name":"workload","namespace":"ns"}`,
body: `{"name":"workload","namespace":"ns","ttl":60}`,
createResp: &types.CreateSandboxResponse{SessionID: "sess-1", SandboxID: "id-2", SandboxName: "sandbox-2"},
expectStatus: http.StatusOK,
expectCreateCalls: 1,
expectTTL: time.Minute,
},
}

Expand All @@ -853,20 +855,22 @@ func TestHandleSandboxCreate(t *testing.T) {
patches := gomonkey.NewPatches()
defer patches.Reset()

patches.ApplyFunc(buildSandboxByAgentRuntime, func(_, _, _ string, _ *Informers) (*sandboxv1alpha1.Sandbox, *sandboxEntry, error) {
patches.ApplyFunc(buildSandboxByAgentRuntime, func(_, _, _ string, requestedTTL time.Duration, _ *Informers) (*sandboxv1alpha1.Sandbox, *sandboxEntry, error) {
if tc.kind != types.AgentRuntimeKind {
return nil, nil, errors.New("unexpected kind")
}
require.Equal(t, tc.expectTTL, requestedTTL)
if tc.buildErr != nil {
return nil, nil, tc.buildErr
}
return sb, entry, nil
})

patches.ApplyFunc(buildSandboxByCodeInterpreter, func(_, _, _ string, _ *Informers) (*sandboxv1alpha1.Sandbox, *extensionsv1alpha1.SandboxClaim, *sandboxEntry, error) {
patches.ApplyFunc(buildSandboxByCodeInterpreter, func(_, _, _ string, requestedTTL time.Duration, _ *Informers) (*sandboxv1alpha1.Sandbox, *extensionsv1alpha1.SandboxClaim, *sandboxEntry, error) {
if tc.kind != types.CodeInterpreterKind {
return nil, nil, nil, errors.New("unexpected kind")
}
require.Equal(t, tc.expectTTL, requestedTTL)
if tc.buildErr != nil {
return nil, nil, nil, tc.buildErr
}
Expand Down
Loading