feat(agent): implement event-driven sub-turn PoC with context cancellation (#1316)

This introduces the initial Proof of Concept for hierarchical agent execution (Sub-turns) to enable multi-agent steering and interruptability as designed in Issue #1316.

Key implementations:
- Core Lifecycle: Added spawnSubTurn to manage nested turn executions with depth limits.
- Ephemeral Sessions: Sub-turns now use isolated memory (newEphemeralSession) to prevent polluting the parent's history.
- EventBus Integration: Implemented SubTurnSpawnEvent, SubTurnResultDeliveredEvent, and SubTurnEndEvent for full observability.
- Context & Interrupts: Sub-turns inherit a cancelable context (context.WithCancel(ctx)) from the parent. When a parent finishes (ts.Finish()), all running children are automatically aborted to prevent goroutine leaks.
- Concurrency & Robustness:
  - Added deferred panic recovery to guarantee the emission of SubTurnEndEvent(Err).
  - Implemented Orphan Result Routing (SubTurnOrphanResultEvent) to handle edge cases where a delayed child result arrives after the parent has finished, preventing history pollution.

Ref: #1316
This commit is contained in:
Administrator 2026-03-15 14:15:41 +08:00
parent 96fd4e0519
commit 15f5742cad
3 changed files with 476 additions and 0 deletions

View file

@ -0,0 +1,12 @@
package agent
import "fmt"
// MockEventBus - for POC
var MockEventBus = struct {
Emit func(event any)
}{
Emit: func(event any) {
fmt.Printf("[Mock EventBus] %T %+v\n", event, event)
},
}

221
pkg/agent/subturn.go Normal file
View file

@ -0,0 +1,221 @@
package agent
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/tools"
)
// ====================== Config & Constants ======================
const maxSubTurnDepth = 3
var (
ErrDepthLimitExceeded = errors.New("sub-turn depth limit exceeded")
ErrInvalidSubTurnConfig = errors.New("invalid sub-turn config")
)
// ====================== SubTurn Config ======================
type SubTurnConfig struct {
Model string
Tools []tools.Tool
SystemPrompt string
MaxTokens int
// Can be extended with temperature, topP, etc.
}
// ====================== Sub-turn Events (Aligned with EventBus) ======================
type SubTurnSpawnEvent struct {
ParentID string
ChildID string
Config SubTurnConfig
}
type SubTurnEndEvent struct {
ChildID string
Result *ToolResult
Err error
}
type SubTurnResultDeliveredEvent struct {
ParentID string
ChildID string
Result *ToolResult
}
type SubTurnOrphanResultEvent struct {
ParentID string
ChildID string
Result *ToolResult
}
// ====================== turnState (Simplified, reusable with existing structs) ======================
type turnState struct {
ctx context.Context
cancelFunc context.CancelFunc // Used to cancel all children when this turn finishes
turnID string
parentTurnID string
depth int
childTurnIDs []string
pendingResults chan *ToolResult
session *Session
mu sync.Mutex
isFinished bool // Marks if the parent Turn has ended
}
// ====================== Helper Functions ======================
var globalTurnCounter int64
func generateTurnID() string {
return fmt.Sprintf("subturn-%d", atomic.AddInt64(&globalTurnCounter, 1))
}
func newTurnState(ctx context.Context, id string, parent *turnState) *turnState {
turnCtx, cancel := context.WithCancel(ctx)
return &turnState{
ctx: turnCtx,
cancelFunc: cancel,
turnID: id,
parentTurnID: parent.turnID,
depth: parent.depth + 1,
session: newEphemeralSession(parent.session),
// NOTE: In this PoC, I use a fixed-size channel (16).
// Under high concurrency or long-running sub-turns, this might fill up and cause
// intermediate results to be discarded in deliverSubTurnResult.
// For production, consider an unbounded queue or a blocking strategy with backpressure.
pendingResults: make(chan *ToolResult, 16),
}
}
// Finish marks the turn as finished and cancels its context, aborting any running sub-turns.
func (ts *turnState) Finish() {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.isFinished = true
if ts.cancelFunc != nil {
ts.cancelFunc()
}
}
// newEphemeralSession - Pure in-memory temporary Session (avoids polluting the main session)
func newEphemeralSession(parent *Session) *Session {
// In a real project, it's recommended to copy only necessary fields; simplified here.
return &Session{
History: make([]Message, 0, len(parent.History)),
}
}
// ====================== Core Function: spawnSubTurn ======================
func spawnSubTurn(ctx context.Context, parentTS *turnState, cfg SubTurnConfig) (result *ToolResult, err error) {
// 1. Depth limit check
if parentTS.depth >= maxSubTurnDepth {
return nil, ErrDepthLimitExceeded
}
// 2. Config validation
if cfg.Model == "" {
return nil, ErrInvalidSubTurnConfig
}
// Create a sub-context for the child turn to support cancellation
childCtx, cancel := context.WithCancel(ctx)
defer cancel()
// 3. Create child Turn state
childID := generateTurnID()
childTS := newTurnState(childCtx, childID, parentTS)
// 4. Establish parent-child relationship (thread-safe)
parentTS.mu.Lock()
parentTS.childTurnIDs = append(parentTS.childTurnIDs, childID)
parentTS.mu.Unlock()
// 5. Emit Spawn event (currently using Mock, will be replaced by real EventBus)
MockEventBus.Emit(SubTurnSpawnEvent{
ParentID: parentTS.turnID,
ChildID: childID,
Config: cfg,
})
// 6. Defer emitting End event, and recover from panics to ensure it's always fired
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("subturn panicked: %v", r)
}
MockEventBus.Emit(SubTurnEndEvent{
ChildID: childID,
Result: result,
Err: err,
})
}()
// 7. Execute full runTurn (follows the main execution path, all hooks, steering, and interrupts are effective!)
// Pass the childCtx so the sub-turn can be interrupted if the parent is cancelled.
result, err = runTurn(childCtx, childTS, childTS.session, cfg)
// 8. Deliver result back to parent Turn
deliverSubTurnResult(parentTS, childID, result)
return result, err
}
// ====================== Result Delivery ======================
func deliverSubTurnResult(parentTS *turnState, childID string, result *ToolResult) {
parentTS.mu.Lock()
defer parentTS.mu.Unlock()
// Emit ResultDelivered event
MockEventBus.Emit(SubTurnResultDeliveredEvent{
ParentID: parentTS.turnID,
ChildID: childID,
Result: result,
})
if !parentTS.isFinished {
// Parent Turn is still running → Place in pending queue (handled automatically by parent loop in next round)
select {
case parentTS.pendingResults <- result:
default:
fmt.Println("[SubTurn] warning: pendingResults channel full")
}
return
}
// Parent Turn has ended
// emit an OrphanResultEvent so the system/UI can handle this late arrival.
if result != nil {
MockEventBus.Emit(SubTurnOrphanResultEvent{
ParentID: parentTS.turnID,
ChildID: childID,
Result: result,
})
}
}
// ====================== Placeholder Function (Actually reuses runTurn in loop.go) ======================
func runTurn(ctx context.Context, ts *turnState, session *Session, cfg SubTurnConfig) (*ToolResult, error) {
// TODO: Directly call the existing runTurn implementation in your project here
// Ensure the existing runTurn respects the context for cancellation.
return &ToolResult{Content: "Sub-turn executed successfully"}, nil
}
// ====================== Other Types (Reused or simplified from existing code) ======================
type ToolResult struct {
Content string
}
func (r *ToolResult) ToMessage() Message {
return Message{Content: r.Content}
}
type Session struct {
History []Message
}
type Message struct {
Content string
}

243
pkg/agent/subturn_test.go Normal file
View file

@ -0,0 +1,243 @@
package agent
import (
"context"
"reflect"
"testing"
"github.com/sipeed/picoclaw/pkg/tools"
)
// ====================== Test Helper: Event Collector ======================
type eventCollector struct {
events []any
}
func (c *eventCollector) collect(e any) {
c.events = append(c.events, e)
}
func (c *eventCollector) hasEventOfType(typ any) bool {
targetType := reflect.TypeOf(typ)
for _, e := range c.events {
if reflect.TypeOf(e) == targetType {
return true
}
}
return false
}
func (c *eventCollector) countOfType(typ any) int {
targetType := reflect.TypeOf(typ)
count := 0
for _, e := range c.events {
if reflect.TypeOf(e) == targetType {
count++
}
}
return count
}
// ====================== Main Test Function ======================
func TestSpawnSubTurn(t *testing.T) {
tests := []struct {
name string
parentDepth int
config SubTurnConfig
wantErr error
wantSpawn bool
wantEnd bool
wantDepthFail bool
}{
{
name: "Basic success path - Single layer sub-turn",
parentDepth: 0,
config: SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{}, // At least one tool
},
wantErr: nil,
wantSpawn: true,
wantEnd: true,
},
{
name: "Nested 2 layers - Normal",
parentDepth: 1,
config: SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{},
},
wantErr: nil,
wantSpawn: true,
wantEnd: true,
},
{
name: "Depth limit triggered - 4th layer fails",
parentDepth: 3,
config: SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{},
},
wantErr: ErrDepthLimitExceeded,
wantSpawn: false,
wantEnd: false,
wantDepthFail: true,
},
{
name: "Invalid config - Empty Model",
parentDepth: 0,
config: SubTurnConfig{
Model: "",
Tools: []tools.Tool{},
},
wantErr: ErrInvalidSubTurnConfig,
wantSpawn: false,
wantEnd: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Prepare parent Turn
parent := &turnState{
ctx: context.Background(),
turnID: "parent-1",
depth: tt.parentDepth,
childTurnIDs: []string{},
pendingResults: make(chan *ToolResult, 10),
session: &Session{History: []Message{}},
}
// Replace mock with test collector
collector := &eventCollector{}
originalEmit := MockEventBus.Emit
MockEventBus.Emit = collector.collect
defer func() { MockEventBus.Emit = originalEmit }()
// Execute spawnSubTurn
result, err := spawnSubTurn(context.Background(), parent, tt.config)
// Assert errors
if tt.wantErr != nil {
if err == nil || err != tt.wantErr {
t.Errorf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Verify result
if result == nil {
t.Error("expected non-nil result")
}
// Verify event emission
if tt.wantSpawn {
if !collector.hasEventOfType(SubTurnSpawnEvent{}) {
t.Error("SubTurnSpawnEvent not emitted")
}
}
if tt.wantEnd {
if !collector.hasEventOfType(SubTurnEndEvent{}) {
t.Error("SubTurnEndEvent not emitted")
}
}
// Verify turn tree
if len(parent.childTurnIDs) == 0 && !tt.wantDepthFail {
t.Error("child Turn not added to parent.childTurnIDs")
}
// Verify result delivery (pendingResults or history)
if len(parent.pendingResults) > 0 || len(parent.session.History) > 0 {
// Result delivered via at least one path
} else {
t.Error("child result not delivered")
}
})
}
}
// ====================== Extra Independent Test: Ephemeral Session Isolation ======================
func TestSpawnSubTurn_EphemeralSessionIsolation(t *testing.T) {
parent := &turnState{
ctx: context.Background(),
turnID: "parent-1",
depth: 0,
session: &Session{History: []Message{{Content: "parent msg"}}},
}
cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}}
// Record main session length before execution
originalLen := len(parent.session.History)
_, _ = spawnSubTurn(context.Background(), parent, cfg)
// After sub-turn ends, main session must remain unchanged
if len(parent.session.History) != originalLen {
t.Error("ephemeral session polluted the main session")
}
}
// ====================== Extra Independent Test: Result Delivery Path ======================
func TestSpawnSubTurn_ResultDelivery(t *testing.T) {
parent := &turnState{
ctx: context.Background(),
turnID: "parent-1",
depth: 0,
pendingResults: make(chan *ToolResult, 1),
session: &Session{},
}
cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}}
_, _ = spawnSubTurn(context.Background(), parent, cfg)
// Check if pendingResults received the result
select {
case res := <-parent.pendingResults:
if res == nil {
t.Error("received nil result in pendingResults")
}
default:
t.Error("result did not enter pendingResults")
}
}
// ====================== Extra Independent Test: Orphan Result Routing ======================
func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) {
parentCtx, cancelParent := context.WithCancel(context.Background())
parent := &turnState{
ctx: parentCtx,
cancelFunc: cancelParent,
turnID: "parent-1",
depth: 0,
pendingResults: make(chan *ToolResult, 1),
session: &Session{History: []Message{}},
}
collector := &eventCollector{}
originalEmit := MockEventBus.Emit
MockEventBus.Emit = collector.collect
defer func() { MockEventBus.Emit = originalEmit }()
// Simulate parent finishing before child delivers result
parent.Finish()
// Call deliverSubTurnResult directly to simulate a delayed child
deliverSubTurnResult(parent, "delayed-child", &ToolResult{Content: "late result"})
// Verify Orphan event is emitted
if !collector.hasEventOfType(SubTurnOrphanResultEvent{}) {
t.Error("SubTurnOrphanResultEvent not emitted for finished parent")
}
// Verify history is NOT polluted
if len(parent.session.History) != 0 {
t.Error("Parent history was polluted by orphan result")
}
}