feat(subturn): support stateful iteration for evaluator-optimizer pattern

Add ActualSystemPrompt and InitialMessages fields to SubTurnConfig to enable
stateful worker context passing across multiple evaluation iterations.

Changes:
- Add ActualSystemPrompt field to separate system role from user task description
- Add InitialMessages field to preload ephemeral session history before agent loop starts
- Add Messages field to ToolResult for carrying session history (internal use, not serialized)
- Update runTurn to inject system prompt and preload history from InitialMessages
- Update AgentLoopSpawner to map new fields from tools.SubTurnConfig to agent.SubTurnConfig

This enables the evaluator-optimizer execution strategy in team tool to maintain
worker context across iterations while keeping SubTurn isolation intact.
This commit is contained in:
Administrator
2026-03-19 10:15:00 +08:00
parent c732e63650
commit 53404f18ca
4 changed files with 77 additions and 28 deletions
+12
View File
@@ -64,6 +64,7 @@ type processOptions struct {
SenderID string // Current sender ID for dynamic context
SenderDisplayName string // Current sender display name for dynamic context
UserMessage string // User message content (may include prefix)
SystemPromptOverride string // Override the default system prompt (Used by SubTurns)
Media []string // media:// refs from inbound message
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization
@@ -1069,6 +1070,17 @@ func (al *AgentLoop) runAgentLoop(
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
// 1.5 Override the System prompt (e.g., for Evaluator/Optimizer specific personas)
if opts.SystemPromptOverride != "" {
for i, msg := range messages {
if msg.Role == "system" {
messages[i].Content = opts.SystemPromptOverride
messages[i].SystemParts = []providers.ContentBlock{{Type: "text", Text: opts.SystemPromptOverride}}
break
}
}
}
// 2. Save user message to session
if !opts.SkipAddUserMessage && opts.UserMessage != "" {
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
+43 -17
View File
@@ -119,6 +119,14 @@ type SubTurnConfig struct {
// truncated while preserving system messages and recent context.
MaxContextRunes int
// ActualSystemPrompt is injected as the true 'system' role message for the childAgent.
// The legacy SystemPrompt field is actually used as the first 'user' message (task description).
ActualSystemPrompt string
// InitialMessages preloads the ephemeral session history before the agent loop starts.
// Used by evaluator-optimizer patterns to pass the full worker context across multiple iterations.
InitialMessages []providers.Message
// Can be extended with temperature, topP, etc.
}
@@ -186,14 +194,16 @@ func (s *AgentLoopSpawner) SpawnSubTurn(ctx context.Context, cfg tools.SubTurnCo
// Convert tools.SubTurnConfig to agent.SubTurnConfig
agentCfg := SubTurnConfig{
Model: cfg.Model,
Tools: cfg.Tools,
SystemPrompt: cfg.SystemPrompt,
MaxTokens: cfg.MaxTokens,
Async: cfg.Async,
Critical: cfg.Critical,
Timeout: cfg.Timeout,
MaxContextRunes: cfg.MaxContextRunes,
Model: cfg.Model,
Tools: cfg.Tools,
SystemPrompt: cfg.SystemPrompt,
ActualSystemPrompt: cfg.ActualSystemPrompt,
InitialMessages: cfg.InitialMessages,
MaxTokens: cfg.MaxTokens,
Async: cfg.Async,
Critical: cfg.Critical,
Timeout: cfg.Timeout,
MaxContextRunes: cfg.MaxContextRunes,
}
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
@@ -481,6 +491,19 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
childAgent.MaxTokens = parentAgent.MaxTokens
}
if cfg.ActualSystemPrompt != "" {
childAgent.Sessions.AddMessage(ts.turnID, "system", cfg.ActualSystemPrompt)
}
promptAlreadyAdded := false
// Preload ephemeral session history
if len(cfg.InitialMessages) > 0 {
existing := childAgent.Sessions.GetHistory(ts.turnID)
childAgent.Sessions.SetHistory(ts.turnID, append(existing, cfg.InitialMessages...))
promptAlreadyAdded = true // InitialMessages 中已含 user 消息,跳过再次添加
}
// Resolve MaxContextRunes configuration
maxContextRunes := utils.ResolveMaxContextRunes(cfg.MaxContextRunes, childAgent.ContextWindow)
@@ -501,7 +524,6 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
truncationRetryCount := 0
contextRetryCount := 0
currentPrompt := cfg.SystemPrompt
promptAlreadyAdded := false
for {
// Soft context limit: check and truncate before LLM call
@@ -535,12 +557,13 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
// Call the agent loop
finalContent, err := al.runAgentLoop(ctx, childAgent, processOptions{
SessionKey: ts.turnID,
UserMessage: currentPrompt,
DefaultResponse: "",
EnableSummary: false,
SendResponse: false,
SkipAddUserMessage: promptAlreadyAdded,
SessionKey: ts.turnID,
UserMessage: currentPrompt,
SystemPromptOverride: cfg.ActualSystemPrompt,
DefaultResponse: "",
EnableSummary: false,
SendResponse: false,
SkipAddUserMessage: promptAlreadyAdded,
})
// Mark the prompt as added so subsequent truncation retries
@@ -600,8 +623,11 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
continue // Retry with recovery prompt
}
// 3. Success - return result
return &tools.ToolResult{ForLLM: finalContent}, nil
// 3. Success - return result with session history
return &tools.ToolResult{
ForLLM: finalContent,
Messages: childAgent.Sessions.GetHistory(ts.turnID),
}, nil
}
}
+10 -1
View File
@@ -1,6 +1,10 @@
package tools
import "encoding/json"
import (
"encoding/json"
"github.com/sipeed/picoclaw/pkg/providers"
)
// ToolResult represents the structured return value from tool execution.
// It provides clear semantics for different types of results and supports
@@ -34,6 +38,11 @@ type ToolResult struct {
// Media contains media store refs produced by this tool.
// When non-empty, the agent will publish these as OutboundMediaMessage.
Media []string `json:"media,omitempty"`
// Messages holds the ephemeral session history after execution.
// Only populated by SubTurn executions; used by evaluator_optimizer
// to carry stateful worker context across evaluation iterations.
Messages []providers.Message `json:"-"`
}
// NewToolResult creates a basic ToolResult with content for the LLM.
+12 -10
View File
@@ -17,15 +17,17 @@ type SubTurnSpawner interface {
// SubTurnConfig holds configuration for spawning a sub-turn.
type SubTurnConfig struct {
Model string
Tools []Tool
SystemPrompt string
MaxTokens int
Temperature float64
Async bool // true for async (spawn), false for sync (subagent)
Critical bool // continue running after parent finishes gracefully
Timeout time.Duration // 0 = use default (5 minutes)
MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit
Model string
Tools []Tool
SystemPrompt string
MaxTokens int
Temperature float64
Async bool // true for async (spawn), false for sync (subagent)
Critical bool // continue running after parent finishes gracefully
Timeout time.Duration // 0 = use default (5 minutes)
MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit
ActualSystemPrompt string
InitialMessages []providers.Message
}
type SubagentTask struct {
@@ -203,7 +205,7 @@ After completing the task, provide a clear summary of what was done.`
MaxIterations: maxIter,
LLMOptions: llmOptions,
}, messages, task.OriginChannel, task.OriginChatID)
if err == nil {
result = &ToolResult{
ForLLM: fmt.Sprintf(