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
40 changes: 39 additions & 1 deletion cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func main() {
enableSubagentsReq := flag.Bool("enable-subagents", true, "Enable subagent usage")
gemmaThinkingReq := flag.Bool("gemma-thinking", false, "Prepend <|think|> token to system prompt for Gemma 4 models")
subagentMaxTurns := flag.Int("subagent-max-turns", 500, "Maximum number of turns for subagents (default: 500)")
saveSubagentHistoriesReq := flag.Bool("save-subagent-histories", false, "Persist subagent conversation histories to disk (default: off)")
enableSqzReq := flag.Bool("enable-sqz", false, "Enable sqz context compression (if available)")
appendSystemPromptReq := flag.String("append-system-prompt", "", "Append text to the system prompt after processing")
versionReq := flag.Bool("version", false, "Show version")
Expand Down Expand Up @@ -174,6 +175,14 @@ func main() {
historyPath = loadedHistoryPath
}

// Effective session ID for this run — derived from the FINAL history path so
// resumed sessions keep their original ID (the sessionID var above is a fresh
// timestamp even on resume). Used to place subagent histories under the right
// per-session folder. The helper falls back to "" for empty or unsafe IDs,
// which disables subagent history persistence (in-memory fallback) instead of
// writing files outside the session folder.
effectiveSessionID := deriveEffectiveSessionID(historyPath)

// Load existing history
history, err := session.LoadHistory(historyPath)
if err != nil {
Expand Down Expand Up @@ -209,6 +218,15 @@ func main() {
}
}

// Resolve subagent history persistence opt-in (explicit CLI flag > config file).
saveSubagentHistoriesCLI := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "save-subagent-histories" {
saveSubagentHistoriesCLI = true
}
})
saveSubagentHistories := appconfig.ResolveSaveSubagentHistories(appConfig, saveSubagentHistoriesCLI, *saveSubagentHistoriesReq)

// Initialize Core Components
resolvedOpenAIConfig := appconfig.ResolveOpenAISettings(appConfig)
resolvedClientConfig := client.Config{
Expand Down Expand Up @@ -361,7 +379,7 @@ func main() {
currentSubagentClient = subagentClient
}

child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, rootAgent, p)
child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, effectiveSessionID, saveSubagentHistories, rootAgent, p)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -389,6 +407,19 @@ func main() {
}
}

// deriveEffectiveSessionID derives this run's session ID from the FINAL
// history path so resumed sessions keep their original ID. It returns ""
// for empty or unsafe results (a crafted meta file could claim an ID like
// ".."), which disables subagent history persistence for the run
// (in-memory fallback) instead of writing files outside the session folder.
func deriveEffectiveSessionID(historyPath string) string {
id := strings.TrimSuffix(filepath.Base(historyPath), ".json")
if id == "" || id == "." || id == ".." {
return ""
}
return id
}

func mcpToolEnabled(t tool.Tool, enabledTools map[string]bool) bool {
if enabled, exists := enabledTools[t.Name()]; exists {
return enabled
Expand Down Expand Up @@ -547,6 +578,13 @@ func handleSessionDelete(id string) {
os.Exit(1)
}

// Delete the session's subagent history folder (hierarchical layout). No-op for
// legacy flat sessions without a folder. Non-fatal: the session itself is already
// gone, so don't block the success message on leftover artifacts.
if err := session.RemoveSessionFolder(meta.ID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to delete subagent history folder: %v\n", err)
}

fmt.Printf("Deleted session: %s\n", meta.Title)
}

Expand Down
143 changes: 143 additions & 0 deletions cmd/late/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"late/internal/client"
"late/internal/session"
)

type mcpToolStub struct {
Expand All @@ -29,3 +35,140 @@ func TestMCPToolEnabledSupportsBareNames(t *testing.T) {
t.Fatal("namespaced setting did not override bare-name setting")
}
}

// writeTestSession creates a flat session in the injected sessions directory:
// <dir>/<id>.json (history) and <dir>/<id>.meta.json. It returns both paths.
func writeTestSession(t *testing.T, sessionsDir, id string) (metaPath, historyPath string) {
t.Helper()

historyPath = filepath.Join(sessionsDir, id+".json")
if err := session.SaveHistory(historyPath, []client.ChatMessage{
{Role: "user", Content: client.TextContent("hello")},
}); err != nil {
t.Fatalf("SaveHistory(%s): %v", id, err)
}

if err := session.SaveSessionMeta(session.SessionMeta{
ID: id,
Title: "Test session " + id,
CreatedAt: time.Now(),
LastUpdated: time.Now(),
HistoryPath: historyPath,
MessageCount: 1,
}); err != nil {
t.Fatalf("SaveSessionMeta(%s): %v", id, err)
}

return filepath.Join(sessionsDir, id+".meta.json"), historyPath
}

// injectSessionDir points session.SessionDir at a temp dir for the test's duration.
func injectSessionDir(t *testing.T) string {
t.Helper()

tmp := t.TempDir()
oldDir := session.SessionDir
session.SessionDir = func() (string, error) { return tmp, nil }
t.Cleanup(func() { session.SessionDir = oldDir })

return tmp
}

func assertFileGone(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be removed, but it still exists", path)
}
}

func assertFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %s to still exist: %v", path, err)
}
}

func TestHandleSessionDelete_RemovesSubagentFolder(t *testing.T) {
tmp := injectSessionDir(t)

metaA, historyA := writeTestSession(t, tmp, "session-20250101-123456")
// Session A also has the hierarchical subagent history folder.
folderA := filepath.Join(tmp, "session-20250101-123456")
subagentsDir := filepath.Join(folderA, "subagents")
if err := os.MkdirAll(subagentsDir, 0700); err != nil {
t.Fatalf("creating subagent dir: %v", err)
}
if err := os.WriteFile(filepath.Join(subagentsDir, "researcher-subagent-0.json"), []byte("[]"), 0600); err != nil {
t.Fatalf("writing subagent history: %v", err)
}

metaB, historyB := writeTestSession(t, tmp, "session-20250102-999999")

handleSessionDelete("session-20250101-123456")

// Session A: meta, history, and the entire subagent folder are all gone.
assertFileGone(t, metaA)
assertFileGone(t, historyA)
assertFileGone(t, folderA)

// Session B is untouched.
assertFileExists(t, metaB)
assertFileExists(t, historyB)
}

func TestHandleSessionDelete_LegacyFlatSession(t *testing.T) {
tmp := injectSessionDir(t)

metaC, historyC := writeTestSession(t, tmp, "session-20250103-000000")

handleSessionDelete("session-20250103-000000")

assertFileGone(t, metaC)
assertFileGone(t, historyC)
}

func TestDeriveEffectiveSessionID(t *testing.T) {
tests := []struct {
name string
historyPath string
want string
}{
{
name: "plain session history file",
historyPath: "session-20260815-123456.json",
want: "session-20260815-123456",
},
{
name: "full path uses base name",
historyPath: "/tmp/sessions/session-abc.json",
want: "session-abc",
},
{
name: "parent directory reference",
historyPath: "..",
want: "",
},
{
name: "full path ending in parent directory",
historyPath: "/some/dir/..",
want: "",
},
{
name: "current directory reference",
historyPath: ".",
want: "",
},
{
name: "json suffix only",
historyPath: ".json",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := deriveEffectiveSessionID(tt.historyPath); got != tt.want {
t.Errorf("deriveEffectiveSessionID(%q) = %q, want %q", tt.historyPath, got, tt.want)
}
})
}
}
3 changes: 3 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ You can also create an `.llmignore` file alongside your `.gitignore` to specific
| `--subagent-max-turns <n>` | Set max turns per subagent (default: 500) |
| `--append-system-prompt "..."` | Append text to the system prompt (e.g. further instructions) |
| `--enable-images` | Treat models as supporting images (for none llama.cpp servers) |
| `--save-subagent-histories` | Persist subagent conversation histories to disk. Off by default (subagent transcripts are large); can also be enabled via `save_subagent_histories` in the config file |

## Sessions

Expand All @@ -254,6 +255,8 @@ late session load <id> # Resume a previous session
late session delete <id> # Delete a session
```

By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions/<session-id>/subagents/<type>-subagent-<N>.json` — while the parent session files stay in place. `late session delete <id>` removes the subagent folder along with the session. Note that saved transcripts may contain sensitive content (file contents, tool outputs); they are written with user-only permissions.

Comment on lines +258 to +259

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this is TMI for the quickstart. I could remove it if it's unnecessary.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a short explanation of the flag is enough inside the table.

## Git Worktrees

Late is designed for parallel development. You can manage Git worktrees directly to run separate agent instances in isolated environments:
Expand Down
3 changes: 3 additions & 0 deletions docs/quickstart.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ Late 的原生搜索工具会自动遵循你的项目 `.gitignore`,通过排
| `--subagent-max-turns <n>` | 设置每个子智能体的最大交互轮数 (默认:500) |
| `--append-system-prompt "..."` | 向系统提示词的末尾追加文本(例如自定义的补充说明) |
| `--enable-images` | 将模型视为支持图像(适用于非 llama.cpp 的服务器) |
| `--save-subagent-histories` | 将子智能体的对话历史记录持久化保存到磁盘。默认关闭(子智能体转录内容较大);也可以在配置文件中通过 `save_subagent_histories` 开启 |

## 会话管理 (Sessions)

Expand All @@ -252,6 +253,8 @@ late session load <id> # 恢复指定的历史会话
late session delete <id> # 删除指定的会话
```

默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions/<session-id>/subagents/<type>-subagent-<N>.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete <id>` 删除会话时,子智能体文件夹也会随之被一并移除。请注意,保存的对话记录可能包含敏感内容(文件内容、工具输出);这些文件以仅限用户读取的权限写入。

## Git 工作树 (Git Worktrees)

Late 是专为并行开发设计的。你可以直接管理 Git 工作树,从而在隔离的环境中运行多个独立的智能体实例:
Expand Down
24 changes: 21 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func NewSubagentOrchestrator(
injectCWD bool,
gemmaThinking bool,
maxTurns int,
parentSessionID string,
saveSubagentHistory bool,
parent common.Orchestrator,
messenger tui.Messenger,
) (common.Orchestrator, error) {
Expand Down Expand Up @@ -60,8 +62,25 @@ func NewSubagentOrchestrator(
systemPrompt = "<|think|>" + systemPrompt
}

// 2. Setup Subagent Session (Isolated History)
sess := session.New(c, "", []client.ChatMessage{}, systemPrompt, true)
// Mint the child ID up-front so it can be embedded in the subagent history
// path. The parent must be a *BaseOrchestrator: its mutex-protected counter
// is the only ID source that cannot collide under concurrent spawns.
baseParent, ok := parent.(*orchestrator.BaseOrchestrator)
if !ok {
return nil, fmt.Errorf("subagent parent must be a *orchestrator.BaseOrchestrator")
}
id := baseParent.NextChildID(agentType)

// 2. Setup Subagent Session (Isolated History; persisted only when opted in)
var subagentHistoryPath string
if saveSubagentHistory && parentSessionID != "" {
path, err := session.SubagentHistoryPath(parentSessionID, id)
if err != nil {
return nil, fmt.Errorf("failed to resolve subagent history path: %w", err)
}
subagentHistoryPath = path
}
sess := session.NewSubagentSession(c, subagentHistoryPath, []client.ChatMessage{}, systemPrompt)

// Inherit all tools from parent (including MCP tools)
if parent != nil && parent.Registry() != nil {
Expand Down Expand Up @@ -101,7 +120,6 @@ func NewSubagentOrchestrator(
}

// 4. Create Orchestrator
id := fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children()))
mws := parent.Middlewares()

if messenger != nil {
Expand Down
Loading