feat(subturn): implement token budget tracking for SubTurns
This commit is contained in:
parent
01c2f8d608
commit
99b189d3fb
4 changed files with 92 additions and 10 deletions
|
|
@ -1460,6 +1460,10 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
// Save finishReason to turnState for SubTurn truncation detection
|
// Save finishReason to turnState for SubTurn truncation detection
|
||||||
if ts := turnStateFromContext(ctx); ts != nil {
|
if ts := turnStateFromContext(ctx); ts != nil {
|
||||||
ts.SetLastFinishReason(response.FinishReason)
|
ts.SetLastFinishReason(response.FinishReason)
|
||||||
|
// Save usage for token budget tracking
|
||||||
|
if response.Usage != nil {
|
||||||
|
ts.SetLastUsage(response.Usage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go al.handleReasoning(
|
go al.handleReasoning(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -127,6 +128,12 @@ type SubTurnConfig struct {
|
||||||
// Used by evaluator-optimizer patterns to pass the full worker context across multiple iterations.
|
// Used by evaluator-optimizer patterns to pass the full worker context across multiple iterations.
|
||||||
InitialMessages []providers.Message
|
InitialMessages []providers.Message
|
||||||
|
|
||||||
|
// InitialTokenBudget is a shared atomic counter for tracking remaining tokens.
|
||||||
|
// If set, the SubTurn will inherit this budget and deduct tokens after each LLM call.
|
||||||
|
// If nil, the SubTurn will inherit the parent's tokenBudget (if any).
|
||||||
|
// Used by team tool to enforce token limits across all team members.
|
||||||
|
InitialTokenBudget *atomic.Int64
|
||||||
|
|
||||||
// Can be extended with temperature, topP, etc.
|
// Can be extended with temperature, topP, etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,6 +206,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn(ctx context.Context, cfg tools.SubTurnCo
|
||||||
SystemPrompt: cfg.SystemPrompt,
|
SystemPrompt: cfg.SystemPrompt,
|
||||||
ActualSystemPrompt: cfg.ActualSystemPrompt,
|
ActualSystemPrompt: cfg.ActualSystemPrompt,
|
||||||
InitialMessages: cfg.InitialMessages,
|
InitialMessages: cfg.InitialMessages,
|
||||||
|
InitialTokenBudget: cfg.InitialTokenBudget,
|
||||||
MaxTokens: cfg.MaxTokens,
|
MaxTokens: cfg.MaxTokens,
|
||||||
Async: cfg.Async,
|
Async: cfg.Async,
|
||||||
Critical: cfg.Critical,
|
Critical: cfg.Critical,
|
||||||
|
|
@ -292,6 +300,15 @@ func spawnSubTurn(ctx context.Context, al *AgentLoop, parentTS *turnState, cfg S
|
||||||
childTS.cancelFunc = cancel
|
childTS.cancelFunc = cancel
|
||||||
childTS.critical = cfg.Critical
|
childTS.critical = cfg.Critical
|
||||||
|
|
||||||
|
// Token budget initialization/inheritance
|
||||||
|
// If InitialTokenBudget is explicitly provided (e.g., by team tool), use it.
|
||||||
|
// Otherwise, inherit from parent's tokenBudget (for nested SubTurns).
|
||||||
|
if cfg.InitialTokenBudget != nil {
|
||||||
|
childTS.tokenBudget = cfg.InitialTokenBudget
|
||||||
|
} else if parentTS.tokenBudget != nil {
|
||||||
|
childTS.tokenBudget = parentTS.tokenBudget
|
||||||
|
}
|
||||||
|
|
||||||
// IMPORTANT: Put childTS into childCtx so that code inside runTurn can retrieve it
|
// IMPORTANT: Put childTS into childCtx so that code inside runTurn can retrieve it
|
||||||
childCtx = withTurnState(childCtx, childTS)
|
childCtx = withTurnState(childCtx, childTS)
|
||||||
childCtx = WithAgentLoop(childCtx, al) // Propagate AgentLoop to child turn
|
childCtx = WithAgentLoop(childCtx, al) // Propagate AgentLoop to child turn
|
||||||
|
|
@ -619,7 +636,39 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
|
||||||
continue // Retry with recovery prompt
|
continue // Retry with recovery prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Success - return result with session history
|
// 3. Token budget enforcement (if configured)
|
||||||
|
// Check if budget is exhausted after this LLM call. If so, return gracefully
|
||||||
|
// with current result instead of continuing iterations.
|
||||||
|
if ts.tokenBudget != nil {
|
||||||
|
if usage := ts.GetLastUsage(); usage != nil {
|
||||||
|
newBudget := ts.tokenBudget.Add(-int64(usage.TotalTokens))
|
||||||
|
|
||||||
|
if newBudget <= 0 {
|
||||||
|
logger.WarnCF("subturn", "Token budget exhausted",
|
||||||
|
map[string]any{
|
||||||
|
"turn_id": ts.turnID,
|
||||||
|
"deficit": -newBudget,
|
||||||
|
"tokens_used": usage.TotalTokens,
|
||||||
|
"final_budget": newBudget,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Budget exhausted - return current result with marker
|
||||||
|
return &tools.ToolResult{
|
||||||
|
ForLLM: finalContent + "\n\n[Token budget exhausted]",
|
||||||
|
Messages: childAgent.Sessions.GetHistory(ts.turnID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("subturn", "Token budget updated",
|
||||||
|
map[string]any{
|
||||||
|
"turn_id": ts.turnID,
|
||||||
|
"tokens_used": usage.TotalTokens,
|
||||||
|
"remaining_budget": newBudget,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Success - return result with session history
|
||||||
return &tools.ToolResult{
|
return &tools.ToolResult{
|
||||||
ForLLM: finalContent,
|
ForLLM: finalContent,
|
||||||
Messages: childAgent.Sessions.GetHistory(ts.turnID),
|
Messages: childAgent.Sessions.GetHistory(ts.turnID),
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,17 @@ type turnState struct {
|
||||||
// Used by SubTurn to detect truncation and retry.
|
// Used by SubTurn to detect truncation and retry.
|
||||||
// MUST be accessed under mu lock.
|
// MUST be accessed under mu lock.
|
||||||
lastFinishReason string
|
lastFinishReason string
|
||||||
|
|
||||||
|
// Token budget tracking
|
||||||
|
// tokenBudget is a shared atomic counter for tracking remaining tokens across team members.
|
||||||
|
// Inherited from parent or initialized from SubTurnConfig.InitialTokenBudget.
|
||||||
|
// Nil if no budget is set.
|
||||||
|
tokenBudget *atomic.Int64
|
||||||
|
|
||||||
|
// lastUsage stores the token usage from the last LLM call.
|
||||||
|
// Used by SubTurn to deduct from tokenBudget after each LLM iteration.
|
||||||
|
// MUST be accessed under mu lock.
|
||||||
|
lastUsage *providers.UsageInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================== Public API ======================
|
// ====================== Public API ======================
|
||||||
|
|
@ -134,7 +145,7 @@ func (al *AgentLoop) FormatTree(turnInfo *TurnInfo, prefix string, isLast bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Print current node
|
// Print current node
|
||||||
marker := "├── "
|
marker := "├── "
|
||||||
if isLast {
|
if isLast {
|
||||||
|
|
@ -154,7 +165,7 @@ func (al *AgentLoop) FormatTree(turnInfo *TurnInfo, prefix string, isLast bool)
|
||||||
orphanMarker = " (Orphaned)"
|
orphanMarker = " (Orphaned)"
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("%s%s[%s] Depth:%d (%s)%s\n", prefix, marker, turnInfo.TurnID, turnInfo.Depth, status, orphanMarker))
|
fmt.Fprintf(&sb, "%s%s[%s] Depth:%d (%s)%s\n", prefix, marker, turnInfo.TurnID, turnInfo.Depth, status, orphanMarker)
|
||||||
|
|
||||||
// Prepare prefix for children
|
// Prepare prefix for children
|
||||||
childPrefix := prefix
|
childPrefix := prefix
|
||||||
|
|
@ -179,7 +190,7 @@ func (al *AgentLoop) FormatTree(turnInfo *TurnInfo, prefix string, isLast bool)
|
||||||
if isLastChild {
|
if isLastChild {
|
||||||
cMarker = "└── "
|
cMarker = "└── "
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("%s%s[%s] (Completed/Cleaned Up)\n", childPrefix, cMarker, childID))
|
fmt.Fprintf(&sb, "%s%s[%s] (Completed/Cleaned Up)\n", childPrefix, cMarker, childID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,12 +204,12 @@ func newTurnState(ctx context.Context, id string, parent *turnState) *turnState
|
||||||
// (spawnSubTurn) already creates one. The turnState stores the context and
|
// (spawnSubTurn) already creates one. The turnState stores the context and
|
||||||
// cancelFunc provided by the caller to avoid redundant context wrapping.
|
// cancelFunc provided by the caller to avoid redundant context wrapping.
|
||||||
return &turnState{
|
return &turnState{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancelFunc: nil, // Will be set by the caller
|
cancelFunc: nil, // Will be set by the caller
|
||||||
turnID: id,
|
turnID: id,
|
||||||
parentTurnID: parent.turnID,
|
parentTurnID: parent.turnID,
|
||||||
depth: parent.depth + 1,
|
depth: parent.depth + 1,
|
||||||
session: newEphemeralSession(parent.session),
|
session: newEphemeralSession(parent.session),
|
||||||
parentTurnState: parent, // Store reference to parent for IsParentEnded() checks
|
parentTurnState: parent, // Store reference to parent for IsParentEnded() checks
|
||||||
// NOTE: In this PoC, I use a fixed-size channel (16).
|
// 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
|
// Under high concurrency or long-running sub-turns, this might fill up and cause
|
||||||
|
|
@ -233,6 +244,22 @@ func (ts *turnState) GetLastFinishReason() string {
|
||||||
return ts.lastFinishReason
|
return ts.lastFinishReason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLastUsage stores the token usage from the last LLM call.
|
||||||
|
// This is used by SubTurn to track token consumption for budget enforcement.
|
||||||
|
func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) {
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
ts.lastUsage = usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastUsage retrieves the token usage from the last LLM call.
|
||||||
|
// Returns nil if no LLM call has been made yet.
|
||||||
|
func (ts *turnState) GetLastUsage() *providers.UsageInfo {
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
return ts.lastUsage
|
||||||
|
}
|
||||||
|
|
||||||
// IsParentEnded is a convenience method to check if parent ended.
|
// IsParentEnded is a convenience method to check if parent ended.
|
||||||
// It returns the value of the parent's parentEnded atomic flag.
|
// It returns the value of the parent's parentEnded atomic flag.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
@ -28,6 +29,7 @@ type SubTurnConfig struct {
|
||||||
MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit
|
MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit
|
||||||
ActualSystemPrompt string
|
ActualSystemPrompt string
|
||||||
InitialMessages []providers.Message
|
InitialMessages []providers.Message
|
||||||
|
InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubagentTask struct {
|
type SubagentTask struct {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue