From a81d2cf87dea234b05153417e8025fa6707b2311 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Wed, 8 Jul 2026 15:57:55 +0300 Subject: [PATCH 1/3] feat(subagent): link child sessions to parent and support resume (#87) - Stamp parent_session_id, parent_session, and subagent_task into new subagent session headers when the parent is session-backed, enabling bidirectional session-tree navigation of delegated work - Add SubagentConfig.SessionID to resume an existing subagent session so follow-up tasks reuse the child's accumulated context; expose it as an optional session_id parameter on the subagent tool - Add SubagentConfig.ParentSessionID override and propagate the previously-unused extension ParentSessionID through extbridge - Add TreeManager.SetParentLink (in-place header rewrite) and FindSessionPathByID (header-only session lookup by UUID) - Document the resume workflow in the tool description, SDK README, extension skill, and docs site Fixes #87 --- internal/core/subagent.go | 18 ++- internal/core/subagent_test.go | 42 ++++++ internal/extbridge/extbridge.go | 14 +- internal/extensions/subagent.go | 10 +- internal/session/parent_link_test.go | 193 +++++++++++++++++++++++++++ internal/session/store.go | 94 +++++++++++++ internal/session/tree_manager.go | 50 +++++++ pkg/kit/README.md | 6 +- pkg/kit/kit.go | 56 ++++++++ pkg/kit/subagent_session_test.go | 42 ++++++ skills/kit-extensions/SKILL.md | 3 +- www/pages/advanced/subagents.md | 39 +++++- www/pages/extensions/capabilities.md | 2 + www/pages/sdk/overview.md | 5 + www/pages/sessions.md | 2 + 15 files changed, 565 insertions(+), 11 deletions(-) create mode 100644 internal/session/parent_link_test.go create mode 100644 pkg/kit/subagent_session_test.go diff --git a/internal/core/subagent.go b/internal/core/subagent.go index e84581d0..247d5bda 100644 --- a/internal/core/subagent.go +++ b/internal/core/subagent.go @@ -47,6 +47,10 @@ type SubagentSpawnRequest struct { // Timeout bounds execution. Zero means "unset": the spawner applies // the named agent's timeout (if any) or the default. Timeout time.Duration + // SessionID optionally resumes an existing subagent session (the + // subagent_session_id returned by a previous run) instead of starting + // fresh, so follow-up tasks reuse the subagent's accumulated context. + SessionID string } // SubagentSpawnFunc is a callback that spawns an in-process subagent. The @@ -80,6 +84,7 @@ type subagentArgs struct { Model string `json:"model,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` + SessionID string `json:"session_id,omitempty"` } // NamedAgentSpec summarises a named agent definition for advertisement in @@ -106,6 +111,11 @@ The subagent runs as a separate in-process Kit instance with full tool access The subagent result is returned when it completes. For long-running tasks, consider breaking them into smaller focused subtasks. +Each successful run returns a subagent_session_id. Pass it back via the +optional session_id parameter to resume that subagent for follow-up tasks — +the subagent keeps its accumulated context (files read, findings, state), so +follow-ups are cheaper than starting a fresh subagent. + Example use cases: - "Research the authentication patterns in this codebase" - "Write unit tests for the UserService class" @@ -160,6 +170,10 @@ func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool { "type": "number", "description": "Maximum execution time in seconds (default: 300, max: 1800, minimum recommended: 240)", }, + "session_id": map[string]any{ + "type": "string", + "description": "Optional session ID from a previous subagent run (returned as subagent_session_id). Resumes that subagent's session so the follow-up task reuses its accumulated context instead of starting fresh.", + }, }, Required: []string{"task"}, Parallel: true, @@ -220,6 +234,7 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe Model: args.Model, SystemPrompt: args.SystemPrompt, Timeout: timeout, + SessionID: args.SessionID, }) if err != nil || result.Error != nil { spawnErr := err @@ -243,7 +258,8 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe resp := fantasy.NewTextResponse(response) - // Attach subagent session ID as metadata when available. + // Attach subagent session ID as metadata when available. The LLM can + // pass this back via session_id to resume the subagent. if result.SessionID != "" { resp = fantasy.WithResponseMetadata(resp, map[string]any{ "subagent_session_id": result.SessionID, diff --git a/internal/core/subagent_test.go b/internal/core/subagent_test.go index 04aac248..90252439 100644 --- a/internal/core/subagent_test.go +++ b/internal/core/subagent_test.go @@ -2,8 +2,11 @@ package core import ( "context" + "strings" "testing" "time" + + "charm.land/fantasy" ) func TestValuesContext_StripsDeadlineAndCancellation(t *testing.T) { @@ -113,3 +116,42 @@ func TestSpawnContext_PreservesSpawnerValue(t *testing.T) { t.Errorf("expected 'ok', got %q", result.Response) } } + +func TestExecuteSubagent_ForwardsSessionID(t *testing.T) { + // Issue #87: the optional session_id argument must reach the spawner so + // the parent Kit can resume the existing subagent session. + var captured SubagentSpawnRequest + spawner := SubagentSpawnFunc(func(ctx context.Context, req SubagentSpawnRequest) (*SubagentSpawnResult, error) { + captured = req + return &SubagentSpawnResult{Response: "ok", SessionID: req.SessionID}, nil + }) + + ctx := WithSubagentSpawner(context.Background(), spawner) + resp, err := executeSubagent(ctx, fantasy.ToolCall{ + ID: "tc1", + Input: `{"task":"follow-up question","session_id":"sess-123"}`, + }) + if err != nil { + t.Fatalf("executeSubagent: %v", err) + } + if resp.IsError { + t.Fatalf("unexpected error response: %s", resp.Content) + } + if captured.SessionID != "sess-123" { + t.Errorf("spawner SessionID = %q, want %q", captured.SessionID, "sess-123") + } + if captured.Prompt != "follow-up question" { + t.Errorf("spawner Prompt = %q, want %q", captured.Prompt, "follow-up question") + } +} + +func TestSubagentToolDescription_MentionsResume(t *testing.T) { + tool := NewSubagentTool() + info := tool.Info() + if !strings.Contains(info.Description, "session_id") { + t.Error("tool description should document the session_id resume workflow") + } + if _, ok := info.Parameters["session_id"]; !ok { + t.Error("tool parameters should include session_id") + } +} diff --git a/internal/extbridge/extbridge.go b/internal/extbridge/extbridge.go index bf15c42d..c6fd0457 100644 --- a/internal/extbridge/extbridge.go +++ b/internal/extbridge/extbridge.go @@ -115,12 +115,14 @@ func completionResult(result *extensions.SubagentResult, err error) extensions.S // are set in addition to the returned error. func runSubagent(ctx context.Context, k *kit.Kit, cfg extensions.SubagentConfig) (*extensions.SubagentResult, error) { sdkCfg := kit.SubagentConfig{ - Prompt: cfg.Prompt, - Model: cfg.Model, - SystemPrompt: cfg.SystemPrompt, - Timeout: cfg.Timeout, - NoSession: cfg.NoSession, - Tools: k.GetToolsForSubagent(), + Prompt: cfg.Prompt, + Model: cfg.Model, + SystemPrompt: cfg.SystemPrompt, + Timeout: cfg.Timeout, + NoSession: cfg.NoSession, + SessionID: cfg.SessionID, + ParentSessionID: cfg.ParentSessionID, + Tools: k.GetToolsForSubagent(), } if cfg.OnEvent != nil || cfg.OnOutput != nil { sdkCfg.OnEvent = func(e kit.Event) { diff --git a/internal/extensions/subagent.go b/internal/extensions/subagent.go index f9f7ba8f..42690ddc 100644 --- a/internal/extensions/subagent.go +++ b/internal/extensions/subagent.go @@ -58,8 +58,16 @@ type SubagentConfig struct { // ParentSessionID links the subagent's session to the parent (optional). // When set, the subagent's session header includes a parent reference - // so viewers can navigate the session tree. + // so viewers can navigate the session tree. When empty, the host Kit's + // active persisted session ID is recorded automatically. ParentSessionID string + + // SessionID resumes an existing subagent session instead of creating a + // new one. Set it to the SessionID of a previous SubagentResult so + // follow-up prompts reuse the subagent's accumulated context instead of + // re-establishing it from scratch. Mutually exclusive with NoSession. + // An unknown ID is an error. + SessionID string } // SubagentEvent carries a real-time event from a running subagent. Extensions diff --git a/internal/session/parent_link_test.go b/internal/session/parent_link_test.go new file mode 100644 index 00000000..73da377c --- /dev/null +++ b/internal/session/parent_link_test.go @@ -0,0 +1,193 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/mark3labs/kit/internal/message" +) + +// newTestMessage builds a minimal user message for session tests. +func newTestMessage(text string) message.Message { + return message.Message{ + Role: message.RoleUser, + Parts: []message.ContentPart{ + message.TextContent{Text: text}, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func TestSetParentLink_PersistsAcrossReopen(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cwd := t.TempDir() + + tm, err := CreateTreeSession(cwd) + if err != nil { + t.Fatalf("CreateTreeSession: %v", err) + } + + // Stamp the parent link on a fresh session (the Kit.Subagent flow), + // then append messages afterwards. + if err := tm.SetParentLink("/parent/session.jsonl", "parent-uuid", "research the auth flow"); err != nil { + t.Fatalf("SetParentLink: %v", err) + } + if _, err := tm.AppendMessage(newTestMessage("hello")); err != nil { + t.Fatalf("AppendMessage: %v", err) + } + path := tm.GetFilePath() + if err := tm.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + reopened, err := OpenTreeSession(path) + if err != nil { + t.Fatalf("OpenTreeSession: %v", err) + } + defer func() { _ = reopened.Close() }() + + header := reopened.GetHeader() + if header.ParentSession != "/parent/session.jsonl" { + t.Errorf("ParentSession = %q, want %q", header.ParentSession, "/parent/session.jsonl") + } + if header.ParentSessionID != "parent-uuid" { + t.Errorf("ParentSessionID = %q, want %q", header.ParentSessionID, "parent-uuid") + } + if header.SubagentTask != "research the auth flow" { + t.Errorf("SubagentTask = %q, want %q", header.SubagentTask, "research the auth flow") + } + if got := reopened.MessageCount(); got != 1 { + t.Errorf("MessageCount = %d, want 1 (entries must survive header rewrite)", got) + } +} + +func TestSetParentLink_RewritesExistingEntries(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cwd := t.TempDir() + + tm, err := CreateTreeSession(cwd) + if err != nil { + t.Fatalf("CreateTreeSession: %v", err) + } + + // Append entries BEFORE stamping — the rewrite must preserve them. + if _, err := tm.AppendMessage(newTestMessage("first")); err != nil { + t.Fatalf("AppendMessage: %v", err) + } + if _, err := tm.AppendMessage(newTestMessage("second")); err != nil { + t.Fatalf("AppendMessage: %v", err) + } + leafBefore := tm.GetLeafID() + + if err := tm.SetParentLink("", "parent-uuid", ""); err != nil { + t.Fatalf("SetParentLink: %v", err) + } + + // Appends after the rewrite must still work. + if _, err := tm.AppendMessage(newTestMessage("third")); err != nil { + t.Fatalf("AppendMessage after SetParentLink: %v", err) + } + + path := tm.GetFilePath() + if err := tm.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + reopened, err := OpenTreeSession(path) + if err != nil { + t.Fatalf("OpenTreeSession: %v", err) + } + defer func() { _ = reopened.Close() }() + + if got := reopened.MessageCount(); got != 3 { + t.Errorf("MessageCount = %d, want 3", got) + } + if reopened.GetHeader().ParentSessionID != "parent-uuid" { + t.Errorf("ParentSessionID = %q, want %q", reopened.GetHeader().ParentSessionID, "parent-uuid") + } + if reopened.GetHeader().SubagentTask != "" { + t.Errorf("SubagentTask = %q, want empty (not provided)", reopened.GetHeader().SubagentTask) + } + if entry := reopened.GetEntry(leafBefore); entry == nil { + t.Error("pre-rewrite entry ID no longer resolvable after reopen") + } +} + +func TestSetParentLink_InMemorySession(t *testing.T) { + tm := InMemoryTreeSession(t.TempDir()) + + if err := tm.SetParentLink("", "parent-uuid", "task"); err != nil { + t.Fatalf("SetParentLink on in-memory session: %v", err) + } + if got := tm.GetHeader().ParentSessionID; got != "parent-uuid" { + t.Errorf("ParentSessionID = %q, want %q", got, "parent-uuid") + } +} + +func TestFindSessionPathByID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cwd := t.TempDir() + + first, err := CreateTreeSession(cwd) + if err != nil { + t.Fatalf("CreateTreeSession: %v", err) + } + defer func() { _ = first.Close() }() + second, err := CreateTreeSession(cwd) + if err != nil { + t.Fatalf("CreateTreeSession: %v", err) + } + defer func() { _ = second.Close() }() + + path, err := FindSessionPathByID(cwd, second.GetSessionID()) + if err != nil { + t.Fatalf("FindSessionPathByID: %v", err) + } + if path != second.GetFilePath() { + t.Errorf("path = %q, want %q", path, second.GetFilePath()) + } + + // Lookup from a different cwd must fall back to the global scan. + path, err = FindSessionPathByID(t.TempDir(), first.GetSessionID()) + if err != nil { + t.Fatalf("FindSessionPathByID (fallback scan): %v", err) + } + if path != first.GetFilePath() { + t.Errorf("fallback path = %q, want %q", path, first.GetFilePath()) + } + + if _, err := FindSessionPathByID(cwd, "no-such-session"); err == nil { + t.Error("expected error for unknown session ID") + } + if _, err := FindSessionPathByID(cwd, ""); err == nil { + t.Error("expected error for empty session ID") + } +} + +func TestFindSessionPathByID_SkipsMalformedFiles(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cwd := t.TempDir() + + tm, err := CreateTreeSession(cwd) + if err != nil { + t.Fatalf("CreateTreeSession: %v", err) + } + defer func() { _ = tm.Close() }() + + // Drop a malformed .jsonl next to the real session. + dir := filepath.Dir(tm.GetFilePath()) + if err := os.WriteFile(filepath.Join(dir, "broken.jsonl"), []byte("not json\n"), 0644); err != nil { + t.Fatalf("write malformed file: %v", err) + } + + path, err := FindSessionPathByID(cwd, tm.GetSessionID()) + if err != nil { + t.Fatalf("FindSessionPathByID: %v", err) + } + if path != tm.GetFilePath() { + t.Errorf("path = %q, want %q", path, tm.GetFilePath()) + } +} diff --git a/internal/session/store.go b/internal/session/store.go index 61547f91..dec5eaa3 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -246,6 +246,9 @@ func extractSessionInfo(path string) (*SessionInfo, error) { } } } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to scan session file: %w", err) + } if !lastTimestamp.IsZero() { info.Modified = lastTimestamp @@ -293,3 +296,94 @@ func extractTextPreview(partsJSON json.RawMessage) string { func DeleteSession(path string) error { return os.Remove(path) } + +// FindSessionPathByID locates the JSONL session file whose header ID matches +// the given session UUID. The session directory for cwd is searched first +// (the common case for subagent sessions, which live alongside the parent's +// sessions), then all session directories under ~/.kit/sessions. Only file +// headers (first line) are read, so the scan is cheap even with many +// sessions. Returns an error when no session with that ID exists. +func FindSessionPathByID(cwd, id string) (string, error) { + if id == "" { + return "", fmt.Errorf("session ID is required") + } + + // Fast path: the session directory for the working directory. + if path, ok := findSessionInDir(DefaultSessionDir(cwd), id); ok { + return path, nil + } + + // Fall back to scanning all session directories. + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("session %q not found", id) + } + sessionsRoot := filepath.Join(home, ".kit", "sessions") + dirs, err := os.ReadDir(sessionsRoot) + if err != nil { + return "", fmt.Errorf("session %q not found", id) + } + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + if path, ok := findSessionInDir(filepath.Join(sessionsRoot, dir.Name()), id); ok { + return path, nil + } + } + return "", fmt.Errorf("session %q not found", id) +} + +// findSessionInDir scans a single session directory for a session file whose +// header ID matches id. Malformed files are skipped. +func findSessionInDir(dir, id string) (string, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + path := filepath.Join(dir, entry.Name()) + header, err := readSessionHeader(path) + if err != nil { + continue + } + if header.ID == id { + return path, true + } + } + return "", false +} + +// readSessionHeader reads and parses only the first line (the session header) +// of a JSONL session file. +func readSessionHeader(path string) (*SessionHeader, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + var h SessionHeader + if err := json.Unmarshal([]byte(line), &h); err != nil { + return nil, fmt.Errorf("failed to parse header: %w", err) + } + if h.Type != EntryTypeSession { + return nil, fmt.Errorf("first line is not a session header") + } + return &h, nil + } + if err := scanner.Err(); err != nil { + return nil, err + } + return nil, fmt.Errorf("empty session file") +} diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index 366f388c..252ede13 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -312,6 +312,56 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag return newTm, nil } +// SetParentLink records a parent session reference (and, optionally, the +// originating subagent task) in the session header. It is used when a +// subagent-backed session is created from a session-backed parent so viewers +// can navigate the parent/child session tree. +// +// For persisted sessions the JSONL file is rewritten in place — the header is +// the first line of the file, so an update requires rewriting the header +// followed by all existing entries. Sessions are typically freshly created +// when this is called, so the entry list is small (usually empty). For +// in-memory sessions the header is updated in memory only. +func (tm *TreeManager) SetParentLink(parentSessionPath, parentSessionID, subagentTask string) error { + tm.mu.Lock() + defer tm.mu.Unlock() + + tm.header.ParentSession = parentSessionPath + tm.header.ParentSessionID = parentSessionID + if subagentTask != "" { + tm.header.SubagentTask = subagentTask + } + + if tm.file == nil { + return nil // in-memory session: header updated in place + } + + // Flush anything buffered, then rewrite the whole file with the updated + // header followed by all existing entries. + if err := tm.flushLocked(); err != nil { + return fmt.Errorf("failed to flush session before header rewrite: %w", err) + } + if err := tm.file.Close(); err != nil { + return fmt.Errorf("failed to close session file for header rewrite: %w", err) + } + f, err := os.Create(tm.filePath) + if err != nil { + return fmt.Errorf("failed to recreate session file: %w", err) + } + tm.file = f + tm.writer = bufio.NewWriter(f) + + if err := tm.writeEntry(&tm.header); err != nil { + return fmt.Errorf("failed to write session header: %w", err) + } + for _, entry := range tm.entries { + if err := tm.writeEntry(entry); err != nil { + return fmt.Errorf("failed to rewrite session entry: %w", err) + } + } + return tm.flushLocked() +} + // OpenTreeSession opens an existing JSONL session file. func OpenTreeSession(path string) (*TreeManager, error) { data, err := os.ReadFile(path) diff --git a/pkg/kit/README.md b/pkg/kit/README.md index d42b7970..dce82b6a 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -401,7 +401,11 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message - `GetSessionID()` - Get session UUID - `AddSkill(*Skill)` / `LoadAndAddSkill(path)` / `RemoveSkill(name)` / `SetSkills([])` - Manage skills at runtime - `Subagent(ctx, SubagentConfig)` - Spawn an in-process child Kit instance; - set `SubagentConfig.Agent` to apply a named agent definition's presets + set `SubagentConfig.Agent` to apply a named agent definition's presets. + New child sessions record the parent's session ID in their header + (`parent_session_id`), and `SubagentConfig.SessionID` resumes a previous + subagent session (from `SubagentResult.SessionID`) for multi-turn + follow-ups that reuse the subagent's accumulated context - `GetAgents()` / `GetAgent(name)` - Query named agent definitions discovered at construction (built-ins plus `.agents/agents/` / `.kit/agents/` / `~/.config/kit/agents/` files) diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 9f96b059..f69b315c 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -2219,6 +2219,22 @@ type SubagentConfig struct { // replay/inspection. NoSession bool + // SessionID resumes an existing subagent session instead of creating a + // new one. Set it to the SessionID returned by a previous Subagent call + // (SubagentResult.SessionID, also surfaced as subagent_session_id in the + // subagent tool's response metadata) so follow-up prompts reuse the + // subagent's accumulated context instead of re-establishing it from + // scratch. Mutually exclusive with NoSession. An unknown ID is an error. + SessionID string + + // ParentSessionID overrides the parent session UUID recorded in the + // child session's header. Empty (default) uses the calling Kit's active + // session ID when one exists. The recorded link enables session-tree + // navigation of delegated work (subagent runs nested under the parent). + // Only applied when a new child session is created; resumed sessions + // (SessionID set) keep their original parent link. + ParentSessionID string + // Timeout limits execution time. Zero means 5 minute default. Timeout time.Duration @@ -2322,9 +2338,26 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult if cfg.Prompt == "" { return nil, fmt.Errorf("subagent prompt is required") } + if cfg.SessionID != "" && cfg.NoSession { + return nil, fmt.Errorf("subagent SessionID and NoSession are mutually exclusive") + } start := time.Now() + // Resolve a resumable session up front: map the session UUID to its + // JSONL file so the child opens the existing session instead of creating + // a fresh one. Failing fast here gives the calling agent an actionable + // error (e.g. a mistyped ID) before any expensive child init. + var resumePath string + if cfg.SessionID != "" { + cwd, _ := os.Getwd() + var err error + resumePath, err = session.FindSessionPathByID(cwd, cfg.SessionID) + if err != nil { + return nil, fmt.Errorf("cannot resume subagent session: %w", err) + } + } + // Resolve a named agent definition: its model, system prompt, tool // allowlist, temperature, and timeout act as defaults that explicitly // set cfg fields override. agentRestricted records whether an allowlist @@ -2430,6 +2463,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult SystemPrompt: systemPrompt, Tools: tools, NoSession: cfg.NoSession, + SessionPath: resumePath, Quiet: true, Streaming: &streamOn, MCPConfig: childMCPConfig, @@ -2459,6 +2493,27 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult } defer func() { _ = child.Close() }() + // Link the child session to the parent so delegated work can be traced + // from either direction: the parent receives the child's session ID in + // the result (and as subagent_session_id tool metadata), and the child's + // header records the parent session. Only newly created sessions are + // stamped — resumed sessions keep their original parent link. + if cfg.SessionID == "" { + parentID := cfg.ParentSessionID + if parentID == "" && m.GetSessionPath() != "" { + // Only auto-link when the parent session is persisted — a link + // to an ephemeral in-memory parent session would never resolve. + parentID = m.GetSessionID() + } + if parentID != "" { + if ts := child.GetTreeSession(); ts != nil { + if err := ts.SetParentLink(m.GetSessionPath(), parentID, cfg.Prompt); err != nil { + log.Printf("Warning: failed to link subagent session to parent: %v", err) + } + } + } + } + // Forward events to parent if requested. if cfg.OnEvent != nil { child.Subscribe(cfg.OnEvent) @@ -2543,6 +2598,7 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent. Model: req.Model, SystemPrompt: req.SystemPrompt, Timeout: req.Timeout, + SessionID: req.SessionID, OnEvent: onEvent, Tools: m.GetToolsForSubagent(), }) diff --git a/pkg/kit/subagent_session_test.go b/pkg/kit/subagent_session_test.go new file mode 100644 index 00000000..39305bba --- /dev/null +++ b/pkg/kit/subagent_session_test.go @@ -0,0 +1,42 @@ +package kit + +import ( + "context" + "strings" + "testing" +) + +// Tests for issue #87: resumable subagent sessions and parent-child session +// linking. These validate the fast-fail paths in Kit.Subagent, which run +// before any provider/child initialization. + +func TestSubagent_SessionIDConflictsWithNoSession(t *testing.T) { + m := &Kit{} + _, err := m.Subagent(context.Background(), SubagentConfig{ + Prompt: "task", + SessionID: "some-session", + NoSession: true, + }) + if err == nil { + t.Fatal("expected error when both SessionID and NoSession are set") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("error = %v, want mention of mutual exclusivity", err) + } +} + +func TestSubagent_UnknownSessionIDFailsFast(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // isolate from real ~/.kit/sessions + + m := &Kit{} + _, err := m.Subagent(context.Background(), SubagentConfig{ + Prompt: "task", + SessionID: "does-not-exist", + }) + if err == nil { + t.Fatal("expected error for unknown subagent session ID") + } + if !strings.Contains(err.Error(), "cannot resume subagent session") { + t.Errorf("error = %v, want resume-failure context", err) + } +} diff --git a/skills/kit-extensions/SKILL.md b/skills/kit-extensions/SKILL.md index f9f8dc24..8d197cce 100644 --- a/skills/kit-extensions/SKILL.md +++ b/skills/kit-extensions/SKILL.md @@ -987,7 +987,8 @@ handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{ | `Timeout` | time.Duration | Execution limit, 0 = 5 minutes | | `Blocking` | bool | true = wait and return result; false (default) = background goroutine + handle | | `NoSession` | bool | Don't persist subagent session file | -| `ParentSessionID` | string | Link to parent session (optional) | +| `SessionID` | string | Resume an existing subagent session (from a previous `SubagentResult.SessionID`) for multi-turn follow-ups | +| `ParentSessionID` | string | Override the parent session link (optional; defaults to the host's active persisted session) | | `OnOutput` | func(string) | Live assistant text chunk callback | | `OnEvent` | func(SubagentEvent) | Real-time event callback | | `OnComplete` | func(SubagentResult) | Completion callback | diff --git a/www/pages/advanced/subagents.md b/www/pages/advanced/subagents.md index 210a7bf0..71ff67c1 100644 --- a/www/pages/advanced/subagents.md +++ b/www/pages/advanced/subagents.md @@ -42,12 +42,32 @@ subagent( agent: "explore", // optional named agent model: "anthropic/claude-haiku-latest", // optional system_prompt: "You are a test analysis expert.", // optional - timeout_seconds: 300 // optional, max 1800 + timeout_seconds: 300, // optional, max 1800 + session_id: "..." // optional, resume a previous subagent ) ``` Subagents run as separate in-process Kit instances with full tool access (except spawning further subagents, to prevent infinite recursion). They can run in parallel. +## Session linking and resuming + +Subagent runs are session-backed by default, and their sessions are linked to the parent in both directions: + +- **Parent → child**: every successful `subagent` tool call returns the child's session ID as `subagent_session_id` in the tool-response metadata (also `SubagentResult.SessionID` in the SDK). +- **Child → parent**: when the parent is running with a persisted session, the child session's header records `parent_session_id` (the parent's session UUID), `parent_session` (the parent's file path), and `subagent_task` (the original task prompt), so viewers can navigate delegated work as a session tree. + +Passing a previous run's `subagent_session_id` back via the `session_id` parameter resumes that child session instead of starting fresh — the subagent keeps its accumulated context (files read, findings, state), making iterative delegation cheap: + +``` +subagent(task: "Research how session persistence works") +→ "Subagent completed successfully..." (subagent_session_id: "abc123...") + +subagent(task: "Now check how it handles errors", session_id: "abc123...") +→ follow-up runs in the same child session, reusing its context +``` + +Resumed sessions keep their original parent link; an unknown `session_id` is an error. Resuming is incompatible with ephemeral (`NoSession`) runs. + ## Named agents Named agents are reusable subagent presets defined in markdown files. They are advertised in the `subagent` tool description, so the LLM can delegate to the right specialist by name — with a preset system prompt, model, tool allowlist, temperature, and timeout. @@ -128,6 +148,8 @@ handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{ Background subagents run in-process (no subprocess): they get their own session, event bus, and agent loop, inherit the parent's active tools minus the `subagent` tool, and do not load extensions. Sessions are persisted by default; set `NoSession: true` for ephemeral runs. +Set `SessionID` to a previous run's `SubagentResult.SessionID` to resume that subagent's session for follow-up prompts, and `ParentSessionID` to override the parent link recorded in the child session's header (it defaults to the host's active persisted session — see [Session linking and resuming](#session-linking-and-resuming)). + ### Monitoring subagents from extensions When the LLM (not the extension itself) spawns a subagent using the `subagent` tool, extensions can monitor its activity in real-time using three lifecycle event handlers: @@ -223,6 +245,21 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{ }) ``` +Set `SessionID` to a previous run's `SubagentResult.SessionID` to resume that subagent's session, so follow-ups reuse its accumulated context instead of re-establishing it from scratch: + +```go +first, err := host.Subagent(ctx, kit.SubagentConfig{ + Prompt: "Research how session persistence works", +}) + +followUp, err := host.Subagent(ctx, kit.SubagentConfig{ + Prompt: "Now check how it handles errors", + SessionID: first.SessionID, // resume the same child session +}) +``` + +New child sessions automatically record the parent's session ID in their header when the parent is session-backed (see [Session linking and resuming](#session-linking-and-resuming)); set `ParentSessionID` to override the recorded link. + Inspect the discovered definitions: ```go diff --git a/www/pages/extensions/capabilities.md b/www/pages/extensions/capabilities.md index 8f3ac1e8..a8a06022 100644 --- a/www/pages/extensions/capabilities.md +++ b/www/pages/extensions/capabilities.md @@ -285,6 +285,8 @@ _, result, err := ctx.SpawnSubagent(ext.SubagentConfig{ With `Blocking: false` (the default), the subagent runs in a background goroutine and `SpawnSubagent` returns immediately with a non-nil handle (`handle.Wait()`, `handle.Done()`, `handle.Kill()`); use `OnComplete`/`OnEvent` callbacks for results. See [Subagents](/advanced/subagents) for a full background-mode example. +Subagent sessions are persisted and linked to the host session by default. Set `SessionID` to a previous run's `SubagentResult.SessionID` to resume that subagent for follow-up prompts; see [Session linking and resuming](/advanced/subagents#session-linking-and-resuming). + ### Monitoring subagents spawned by the main agent When the LLM uses the built-in `subagent` tool, extensions can monitor the subagent's activity in real-time using three lifecycle events: diff --git a/www/pages/sdk/overview.md b/www/pages/sdk/overview.md index ef7ebf3b..b0b09623 100644 --- a/www/pages/sdk/overview.md +++ b/www/pages/sdk/overview.md @@ -639,6 +639,11 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{ }) ``` +Session-backed runs are linked to the parent: new child sessions record the +parent's session ID in their header, and `result.SessionID` can be passed back +as `SubagentConfig.SessionID` to resume the child session for follow-up +prompts that reuse its accumulated context. + See [Subagents](/advanced/subagents#named-agents) for definition file format and discovery precedence. diff --git a/www/pages/sessions.md b/www/pages/sessions.md index 2616ef72..e2316790 100644 --- a/www/pages/sessions.md +++ b/www/pages/sessions.md @@ -19,6 +19,8 @@ Path separators in the working directory are replaced with `--`. For example, `/ Each line in the session file is a JSON entry representing a message, tool call, model change, or extension data. The tree structure allows branching from any message to explore alternate paths. +Sessions created by [subagents](/advanced/subagents) record their parent in the file header (`parent_session_id`, `parent_session`, and the originating `subagent_task`), so delegated work can be traced back to the session that spawned it. + ## Compaction When conversations grow long, Kit can compact them to free up context window space. The compaction system: From 98e5df092cc98043bfd7d1c6d5d8419e5592f7c7 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Wed, 8 Jul 2026 16:12:57 +0300 Subject: [PATCH 2/3] fix(session): address CodeRabbit review on subagent session linking (#87) - Make SetParentLink's header rewrite atomic: write to a temp file and rename over the original so a partial write can never truncate or corrupt the session (Major); extend test to assert no temp file leaks - Check and wrap the os.Getwd error in Kit.Subagent's resume path instead of discarding it (Minor) - Wrap scanner.Err in readSessionHeader per error-handling guidelines (Minor) - Docs: qualify tool-access wording on the subagents page and the parent-header claim in sessions.md to match actual behavior (Minor) --- internal/session/parent_link_test.go | 5 ++ internal/session/store.go | 2 +- internal/session/tree_manager.go | 82 +++++++++++++++++++++------- pkg/kit/kit.go | 6 +- www/pages/advanced/subagents.md | 2 +- www/pages/sessions.md | 2 +- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/internal/session/parent_link_test.go b/internal/session/parent_link_test.go index 73da377c..d123d57d 100644 --- a/internal/session/parent_link_test.go +++ b/internal/session/parent_link_test.go @@ -86,6 +86,11 @@ func TestSetParentLink_RewritesExistingEntries(t *testing.T) { t.Fatalf("SetParentLink: %v", err) } + // The atomic rewrite must not leave its temp file behind. + if _, err := os.Stat(tm.GetFilePath() + ".tmp"); !os.IsNotExist(err) { + t.Errorf("temp file left behind after SetParentLink (stat err: %v)", err) + } + // Appends after the rewrite must still work. if _, err := tm.AppendMessage(newTestMessage("third")); err != nil { t.Fatalf("AppendMessage after SetParentLink: %v", err) diff --git a/internal/session/store.go b/internal/session/store.go index dec5eaa3..e1f14257 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -383,7 +383,7 @@ func readSessionHeader(path string) (*SessionHeader, error) { return &h, nil } if err := scanner.Err(); err != nil { - return nil, err + return nil, fmt.Errorf("failed to scan session header: %w", err) } return nil, fmt.Errorf("empty session file") } diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index 252ede13..ab82ee2e 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -317,11 +317,12 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag // subagent-backed session is created from a session-backed parent so viewers // can navigate the parent/child session tree. // -// For persisted sessions the JSONL file is rewritten in place — the header is -// the first line of the file, so an update requires rewriting the header -// followed by all existing entries. Sessions are typically freshly created -// when this is called, so the entry list is small (usually empty). For -// in-memory sessions the header is updated in memory only. +// For persisted sessions the header rewrite is atomic: the updated header +// plus all existing entries are written to a temp file which then replaces +// the original via rename, so a partial write can never corrupt the session. +// Sessions are typically freshly created when this is called, so the entry +// list is small (usually empty). For in-memory sessions the header is +// updated in memory only. func (tm *TreeManager) SetParentLink(parentSessionPath, parentSessionID, subagentTask string) error { tm.mu.Lock() defer tm.mu.Unlock() @@ -336,30 +337,73 @@ func (tm *TreeManager) SetParentLink(parentSessionPath, parentSessionID, subagen return nil // in-memory session: header updated in place } - // Flush anything buffered, then rewrite the whole file with the updated - // header followed by all existing entries. + // Flush anything buffered so the on-disk file is complete before rewrite. if err := tm.flushLocked(); err != nil { return fmt.Errorf("failed to flush session before header rewrite: %w", err) } + + // Write the updated header plus all existing entries to a temp file + // first. The original file is never truncated, so a failure at any point + // before the final rename leaves it fully intact. + tmpPath := tm.filePath + ".tmp" + tmpFile, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("failed to create temp session file: %w", err) + } + w := bufio.NewWriter(tmpFile) + writeLine := func(v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal entry: %w", err) + } + if _, err := w.Write(data); err != nil { + return err + } + return w.WriteByte('\n') + } + rewriteErr := writeLine(&tm.header) + for _, entry := range tm.entries { + if rewriteErr != nil { + break + } + rewriteErr = writeLine(entry) + } + if rewriteErr == nil { + rewriteErr = w.Flush() + } + if closeErr := tmpFile.Close(); rewriteErr == nil && closeErr != nil { + rewriteErr = closeErr + } + if rewriteErr != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("failed to write temp session file: %w", rewriteErr) + } + + // Close the current handle before the rename (required on Windows), then + // swap in the rewritten file and reopen for appending. The original file + // stays intact on disk until the rename succeeds. if err := tm.file.Close(); err != nil { + _ = os.Remove(tmpPath) return fmt.Errorf("failed to close session file for header rewrite: %w", err) } - f, err := os.Create(tm.filePath) + tm.file = nil + tm.writer = nil + if err := os.Rename(tmpPath, tm.filePath); err != nil { + _ = os.Remove(tmpPath) + // Best effort: reopen the original (still intact) for appending. + if f, reopenErr := os.OpenFile(tm.filePath, os.O_WRONLY|os.O_APPEND, 0644); reopenErr == nil { + tm.file = f + tm.writer = bufio.NewWriter(f) + } + return fmt.Errorf("failed to replace session file: %w", err) + } + f, err := os.OpenFile(tm.filePath, os.O_WRONLY|os.O_APPEND, 0644) if err != nil { - return fmt.Errorf("failed to recreate session file: %w", err) + return fmt.Errorf("failed to reopen session file after header rewrite: %w", err) } tm.file = f tm.writer = bufio.NewWriter(f) - - if err := tm.writeEntry(&tm.header); err != nil { - return fmt.Errorf("failed to write session header: %w", err) - } - for _, entry := range tm.entries { - if err := tm.writeEntry(entry); err != nil { - return fmt.Errorf("failed to rewrite session entry: %w", err) - } - } - return tm.flushLocked() + return nil } // OpenTreeSession opens an existing JSONL session file. diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index f69b315c..971b4ba8 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -2350,8 +2350,10 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult // error (e.g. a mistyped ID) before any expensive child init. var resumePath string if cfg.SessionID != "" { - cwd, _ := os.Getwd() - var err error + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("cannot resume subagent session: %w", err) + } resumePath, err = session.FindSessionPathByID(cwd, cfg.SessionID) if err != nil { return nil, fmt.Errorf("cannot resume subagent session: %w", err) diff --git a/www/pages/advanced/subagents.md b/www/pages/advanced/subagents.md index 71ff67c1..8121edda 100644 --- a/www/pages/advanced/subagents.md +++ b/www/pages/advanced/subagents.md @@ -47,7 +47,7 @@ subagent( ) ``` -Subagents run as separate in-process Kit instances with full tool access (except spawning further subagents, to prevent infinite recursion). They can run in parallel. +Subagents run as separate in-process Kit instances and inherit the parent's active tools minus `subagent` (to prevent recursion); named-agent presets and tool allowlists can narrow that set further. They can run in parallel. ## Session linking and resuming diff --git a/www/pages/sessions.md b/www/pages/sessions.md index e2316790..081e3710 100644 --- a/www/pages/sessions.md +++ b/www/pages/sessions.md @@ -19,7 +19,7 @@ Path separators in the working directory are replaced with `--`. For example, `/ Each line in the session file is a JSON entry representing a message, tool call, model change, or extension data. The tree structure allows branching from any message to explore alternate paths. -Sessions created by [subagents](/advanced/subagents) record their parent in the file header (`parent_session_id`, `parent_session`, and the originating `subagent_task`), so delegated work can be traced back to the session that spawned it. +When a [subagent](/advanced/subagents) is spawned from a persisted parent session, the child records its parent in the file header (`parent_session_id`, `parent_session`, and the originating `subagent_task`), so delegated work can be traced back to the session that spawned it. ## Compaction From b4e847f8ad026de65d1aedf5873e69cd0b941c43 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Wed, 8 Jul 2026 16:20:33 +0300 Subject: [PATCH 3/3] fix(session): fsync temp file before rename in SetParentLink - Address CodeRabbit nitpick: w.Flush() reaches the OS cache only; a crash between rename and writeback could leave a renamed-but-empty session file on some filesystems. Sync before close closes the window. --- internal/session/tree_manager.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index ab82ee2e..a38df64e 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -371,6 +371,11 @@ func (tm *TreeManager) SetParentLink(parentSessionPath, parentSessionID, subagen if rewriteErr == nil { rewriteErr = w.Flush() } + if rewriteErr == nil { + // Force data to stable storage before the rename so a host crash + // cannot leave a renamed-but-empty file on some filesystems. + rewriteErr = tmpFile.Sync() + } if closeErr := tmpFile.Close(); rewriteErr == nil && closeErr != nil { rewriteErr = closeErr }