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
14 changes: 10 additions & 4 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address")
cmd.PersistentFlags().BoolVar(&flags.connectRPC, "connect-rpc", false, "Use Connect-RPC protocol for remote communication (requires --remote)")
cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetHomeDir(), ".cagent", "session.db"), "Path to the session database")
cmd.PersistentFlags().StringVar(&flags.sessionID, "session", "", "Continue from a previous session by ID")
cmd.PersistentFlags().StringVar(&flags.sessionID, "session", "", "Continue from a previous session by ID or relative offset (e.g., -1 for last session)")
cmd.PersistentFlags().StringVar(&flags.fakeResponses, "fake", "", "Replay AI responses from cassette file (for testing)")
cmd.PersistentFlags().IntVar(&flags.fakeStreamDelay, "fake-stream", 0, "Simulate streaming with delay in ms between chunks (default 15ms if no value given)")
cmd.Flag("fake-stream").NoOptDefVal = "15" // --fake-stream without value uses 15ms
Expand Down Expand Up @@ -356,10 +356,16 @@ func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadRes

var sess *session.Session
if f.sessionID != "" {
// Resolve relative session references (e.g., "-1" for last session)
resolvedID, err := session.ResolveSessionID(ctx, sessStore, f.sessionID)
if err != nil {
return nil, nil, fmt.Errorf("resolving session %q: %w", f.sessionID, err)
}

// Load existing session
sess, err = sessStore.GetSession(ctx, f.sessionID)
sess, err = sessStore.GetSession(ctx, resolvedID)
if err != nil {
return nil, nil, fmt.Errorf("loading session %q: %w", f.sessionID, err)
return nil, nil, fmt.Errorf("loading session %q: %w", resolvedID, err)
}
sess.ToolsApproved = f.autoApprove
sess.HideToolResults = f.hideToolResults
Expand All @@ -375,7 +381,7 @@ func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadRes
}
}

slog.Debug("Loaded existing session", "session_id", f.sessionID, "agent", f.agentName)
slog.Debug("Loaded existing session", "session_id", resolvedID, "session_ref", f.sessionID, "agent", f.agentName)
} else {
sess = session.New(
session.WithMaxIterations(agent.MaxIterations()),
Expand Down
89 changes: 89 additions & 0 deletions pkg/session/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"fmt"
"log/slog"
"os"
"sort"
"strconv"
"strings"
"time"

"github.com/docker/cagent/pkg/chat"
Expand All @@ -21,6 +23,35 @@ var (
ErrNotFound = errors.New("session not found")
)

// parseRelativeSessionRef checks if ref is a relative session reference (e.g., "-1", "-2")
// and returns the offset and whether it's a relative reference.
// Returns (1, true) for "-1", (2, true) for "-2", etc.
// Returns (0, false) if not a relative reference.
func parseRelativeSessionRef(ref string) (offset int, isRelative bool) {
if !strings.HasPrefix(ref, "-") {
return 0, false
}

// Try to parse as negative integer
n, err := strconv.Atoi(ref)
if err != nil || n >= 0 {
return 0, false
}

return -n, true
}

// ResolveSessionID resolves a session reference to an actual session ID.
// Supports relative references like "-1" (last session), "-2" (second to last), etc.
// If the reference is not relative, it returns the input unchanged.
func ResolveSessionID(ctx context.Context, store Store, ref string) (string, error) {
offset, isRelative := parseRelativeSessionRef(ref)
if !isRelative {
return ref, nil
}
return store.GetSessionByOffset(ctx, offset)
}

// Summary contains lightweight session metadata for listing purposes.
// This is used instead of loading full Session objects with all messages.
type Summary struct {
Expand All @@ -41,6 +72,11 @@ type Store interface {
UpdateSession(ctx context.Context, session *Session) error // Updates metadata only (not messages/items)
SetSessionStarred(ctx context.Context, id string, starred bool) error

// GetSessionByOffset returns the session ID at the given offset from the most recent.
// Offset 1 returns the most recent session, 2 returns the second most recent, etc.
// Only root sessions are considered (sub-sessions are excluded).
GetSessionByOffset(ctx context.Context, offset int) (string, error)

// === Granular item operations ===

// AddMessage adds a message to a session at the next position.
Expand Down Expand Up @@ -300,6 +336,34 @@ func (s *InMemorySessionStore) UpdateSessionTitle(_ context.Context, sessionID,
return nil
}

// GetSessionByOffset returns the session ID at the given offset from the most recent.
func (s *InMemorySessionStore) GetSessionByOffset(_ context.Context, offset int) (string, error) {
if offset < 1 {
return "", fmt.Errorf("offset must be >= 1, got %d", offset)
}

// Collect and sort sessions by creation time (newest first)
var sessions []*Session
s.sessions.Range(func(_ string, value *Session) bool {
// Only include root sessions (not sub-sessions)
if value.ParentID == "" {
sessions = append(sessions, value)
}
return true
})

sort.Slice(sessions, func(i, j int) bool {
return sessions[i].CreatedAt.After(sessions[j].CreatedAt)
})

index := offset - 1 // offset 1 means index 0 (most recent session)
if index >= len(sessions) {
return "", fmt.Errorf("session offset %d out of range (have %d sessions)", offset, len(sessions))
}

return sessions[index].ID, nil
}

// NewSQLiteSessionStore creates a new SQLite session store
func NewSQLiteSessionStore(path string) (Store, error) {
store, err := openAndMigrateSQLiteStore(path)
Expand Down Expand Up @@ -1179,3 +1243,28 @@ func (s *SQLiteSessionStore) UpdateSessionTitle(ctx context.Context, sessionID,
title, sessionID)
return err
}

// GetSessionByOffset returns the session ID at the given offset from the most recent.
func (s *SQLiteSessionStore) GetSessionByOffset(ctx context.Context, offset int) (string, error) {
if offset < 1 {
return "", fmt.Errorf("offset must be >= 1, got %d", offset)
}

// Query sessions ordered by creation time (newest first), limited to offset
// Only include root sessions (not sub-sessions)
var sessionID string
err := s.db.QueryRowContext(ctx,
`SELECT id FROM sessions
WHERE parent_id IS NULL OR parent_id = ''
ORDER BY created_at DESC
LIMIT 1 OFFSET ?`,
offset-1).Scan(&sessionID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("session offset %d out of range", offset)
}
return "", err
}

return sessionID, nil
}
Loading