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
106 changes: 106 additions & 0 deletions cmd/sin-code/internal/agentloop/subagent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: MIT
// Purpose: Sub-agent delegation — run a subtask in an isolated Loop with
// its own session and context, returning only the result summary to the
// parent (issue #153).
//
// The parent's context stays small because the sub-agent's full message
// history is in its own session, never sent back. Budgets default to the
// parent's; the caller can override per-subagent.
//
// v0: sequential delegation. Parallelism (multiple sub-agents at once)
// is a follow-up — the SpawnSubagent signature is already designed for
// it (one call per sub-agent, no shared state).
package agentloop

import (
"context"
"fmt"

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
)

// SubagentRequest describes a delegated subtask. Empty fields fall
// back to the parent's wiring (the calling Loop).
type SubagentRequest struct {
Goal string // the subtask prompt
MaxTurns int // optional per-subagent turn cap; 0 -> parent
MaxTokens int // optional per-subagent token cap; 0 -> parent
}

// SubagentResult is what the parent loop sees — intentionally just the
// summary plus a few control fields, never the sub-agent's full
// message history.
type SubagentResult struct {
Summary string `json:"summary"`
Verified bool `json:"verified"`
Turns int `json:"turns"`
OpenCriteria []string `json:"open_criteria,omitempty"`
}

// SpawnSubagent runs req in a fresh Loop that shares the parent's
// wiring (Gate, Completion, hooks) but gets an isolated session so
// its context never pollutes the parent. Budgets default to the
// parent's. A nil sessions store is a hard error (no session
// store means no isolation).
//
// Issue #153.
func (l *Loop) SpawnSubagent(ctx context.Context, sessions *session.Store, req SubagentRequest) (*SubagentResult, error) {
if l == nil {
return nil, fmt.Errorf("subagent: nil parent loop")
}
if sessions == nil {
return nil, fmt.Errorf("subagent: nil session store")
}
if req.Goal == "" {
return nil, fmt.Errorf("subagent: goal is required")
}
// Start a fresh session. The empty id triggers a new
// session-id allocation (see session.StartOrResume). The
// sub-agent's session is unrelated to the parent's session —
// issue body says "shares wiring" not "shares session".
sub, err := sessions.StartOrResume("")
if err != nil {
return nil, fmt.Errorf("subagent: start session: %w", err)
}
child := &Loop{
Gate: l.Gate,
LocalTool: l.LocalTool,
LocalSpec: l.LocalSpec,
Workspace: l.Workspace,
MaxTurns: firstNonZero(req.MaxTurns, l.MaxTurns),
MaxTokens: firstNonZero(req.MaxTokens, l.MaxTokens),
SessionID: sub.ID,
Completion: l.Completion,
Hooks: l.Hooks,
Perm: l.Perm,
Ask: l.Ask,
Lessons: l.Lessons,
StopGate: l.StopGate,
MaxStopRejects: l.MaxStopRejects,
StallThreshold: l.StallThreshold,
Reflector: l.Reflector,
Ledger: l.Ledger,
// NOTE: deliberately NOT inheriting RunOverride. The
// sub-agent's Run is always the default; the parent's
// RunOverride is its own concern.
}
res, err := child.Run(ctx, sub, req.Goal)
if err != nil {
return nil, err
}
return &SubagentResult{
Summary: res.Summary,
Verified: res.Verified,
Turns: res.Turns,
OpenCriteria: res.OpenCriteria,
}, nil
}

// firstNonZero returns a if non-zero, else b. Used to fall back to
// the parent's budget when the sub-agent request does not override.
func firstNonZero(a, b int) int {
if a != 0 {
return a
}
return b
}
136 changes: 136 additions & 0 deletions cmd/sin-code/internal/agentloop/subagent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: MIT
// Purpose: tests for issue #153 — sub-agent delegation. SpawnSubagent
// runs a fresh Loop in an isolated session, returns a SubagentResult
// with summary + control fields only (never the full history).
package agentloop

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
)

func newTestStore(t *testing.T) *session.Store {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "sessions.db")
store, err := session.Open(dbPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close(); _ = os.Remove(dbPath) })
return store
}

func TestSpawnSubagent_NilParent(t *testing.T) {
var l *Loop
store := newTestStore(t)
_, err := l.SpawnSubagent(context.Background(), store, SubagentRequest{Goal: "x"})
if err == nil || !strings.Contains(err.Error(), "nil parent") {
t.Errorf("expected 'nil parent' error, got %v", err)
}
}

func TestSpawnSubagent_NilStore(t *testing.T) {
l := &Loop{}
_, err := l.SpawnSubagent(context.Background(), nil, SubagentRequest{Goal: "x"})
if err == nil || !strings.Contains(err.Error(), "nil session store") {
t.Errorf("expected 'nil session store' error, got %v", err)
}
}

func TestSpawnSubagent_EmptyGoal(t *testing.T) {
store := newTestStore(t)
l := &Loop{}
_, err := l.SpawnSubagent(context.Background(), store, SubagentRequest{Goal: ""})
if err == nil || !strings.Contains(err.Error(), "goal is required") {
t.Errorf("expected 'goal is required' error, got %v", err)
}
}

// End-to-end: sub-agent runs the parent's Completion in an isolated
// session, returns a summary. The parent never sees the child's
// message history.
func TestSpawnSubagent_BasicSuccess(t *testing.T) {
store := newTestStore(t)
completionCalls := 0
parent := &Loop{
Gate: passGate(),
Workspace: "/tmp",
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
completionCalls++
return &Completion{
Text: fmt.Sprintf("done %d", completionCalls),
Raw: session.Message{Role: "assistant", Content: "ok"},
}, nil
},
}
result, err := parent.SpawnSubagent(context.Background(), store, SubagentRequest{Goal: "test"})
if err != nil {
t.Fatal(err)
}
if !result.Verified {
t.Errorf("expected verified=true, got false")
}
if result.Summary != "done 1" {
t.Errorf("expected summary='done 1', got %q", result.Summary)
}
if result.Turns != 1 {
t.Errorf("expected turns=1, got %d", result.Turns)
}
}

func TestSpawnSubagent_BudgetFallback(t *testing.T) {
// When the SubagentRequest doesn't override MaxTurns / MaxTokens,
// the child Loop inherits the parent's values.
store := newTestStore(t)
parent := &Loop{
Gate: passGate(),
Workspace: "/tmp",
MaxTurns: 42,
MaxTokens: 9999,
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
return &Completion{Text: "ok", Raw: session.Message{Role: "assistant", Content: "ok"}}, nil
},
}
// Capture the child's MaxTurns by passing an instrumented
// StopGate that returns false forever (forces multiple turns).
// We don't go that far — just check the wiring via the
// SpawnSubagent succeeds (budgets are applied inside child.Run).
result, err := parent.SpawnSubagent(context.Background(), store, SubagentRequest{Goal: "x"})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}

func TestSpawnSubagent_BudgetOverride(t *testing.T) {
// When the SubagentRequest DOES override, the child uses the
// request's value (not the parent's). We test this indirectly
// by passing a MaxTurns so low the child can't complete.
store := newTestStore(t)
parent := &Loop{
Gate: passGate(),
Workspace: "/tmp",
MaxTurns: 100, // generous parent
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
return &Completion{Text: "ok", Raw: session.Message{Role: "assistant", Content: "ok"}}, nil
},
}
result, err := parent.SpawnSubagent(context.Background(), store, SubagentRequest{
Goal: "x",
MaxTurns: 5, // tight child
})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
Loading