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
18 changes: 17 additions & 1 deletion internal/core/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions internal/core/subagent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package core

import (
"context"
"strings"
"testing"
"time"

"charm.land/fantasy"
)

func TestValuesContext_StripsDeadlineAndCancellation(t *testing.T) {
Expand Down Expand Up @@ -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")
}
}
14 changes: 8 additions & 6 deletions internal/extbridge/extbridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion internal/extensions/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
198 changes: 198 additions & 0 deletions internal/session/parent_link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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)
}

// 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)
}

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())
}
}
Loading
Loading