Merge pull request #8 from dj-oyu/fix/rate-limit-and-heartbeat-lifecycle
fix: add LLM rate limiting and fix heartbeat subagent lifecycle
This commit is contained in:
commit
0033e621ff
6 changed files with 124 additions and 26 deletions
|
|
@ -40,6 +40,10 @@ type AgentInstance struct {
|
||||||
PlanFallbacks []string
|
PlanFallbacks []string
|
||||||
PlanCandidates []providers.FallbackCandidate
|
PlanCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
|
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
|
||||||
|
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
|
||||||
|
SubagentMgr *tools.SubagentManager
|
||||||
|
|
||||||
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
||||||
interviewStaleCount int
|
interviewStaleCount int
|
||||||
interviewMemoryLen int
|
interviewMemoryLen int
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@ func registerSharedTools(
|
||||||
webSearchOpts,
|
webSearchOpts,
|
||||||
)
|
)
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
|
agent.SubagentMgr = subagentManager
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
|
|
@ -942,19 +943,25 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guarantee heartbeat worktree cleanup on ALL exit paths (error, panic, normal).
|
// Guarantee heartbeat worktree cleanup on ALL exit paths (error, panic, normal).
|
||||||
|
// Wait for spawned subagents first so they aren't killed mid-flight.
|
||||||
defer func() {
|
defer func() {
|
||||||
if opts.Background && agent.IsInWorktree(opts.SessionKey) {
|
if opts.Background {
|
||||||
commitMsg := "heartbeat: auto-save"
|
if agent.SubagentMgr != nil {
|
||||||
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
|
agent.SubagentMgr.WaitAll(35 * time.Minute) // slightly above spawnTimeout
|
||||||
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) {
|
}
|
||||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
if agent.IsInWorktree(opts.SessionKey) {
|
||||||
_ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{
|
commitMsg := "heartbeat: auto-save"
|
||||||
Channel: opts.Channel,
|
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
|
||||||
ChatID: opts.ChatID,
|
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) {
|
||||||
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).",
|
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
wtResult.Branch, wtResult.CommitsAhead),
|
_ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{
|
||||||
})
|
Channel: opts.Channel,
|
||||||
cleanupCancel()
|
ChatID: opts.ChatID,
|
||||||
|
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).",
|
||||||
|
wtResult.Branch, wtResult.CommitsAhead),
|
||||||
|
})
|
||||||
|
cleanupCancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
||||||
openai_compat.WithStream(boolDefault(cfg.Stream, false)),
|
openai_compat.WithStream(boolDefault(cfg.Stream, false)),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
||||||
|
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "minimax":
|
case "minimax":
|
||||||
|
|
@ -106,6 +107,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
||||||
openai_compat.WithStream(boolDefault(cfg.Stream, true)),
|
openai_compat.WithStream(boolDefault(cfg.Stream, true)),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
||||||
|
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
|
|
@ -123,6 +125,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
||||||
openai_compat.WithStream(boolDefault(cfg.Stream, false)),
|
openai_compat.WithStream(boolDefault(cfg.Stream, false)),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
||||||
|
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
@ -142,12 +145,10 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy,
|
||||||
cfg.APIKey,
|
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
||||||
apiBase,
|
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
||||||
cfg.Proxy,
|
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
||||||
cfg.MaxTokensField,
|
|
||||||
cfg.RequestTimeout,
|
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
|
|
@ -227,6 +228,15 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rpmToMinInterval converts a requests-per-minute limit to a minimum interval
|
||||||
|
// between consecutive requests. Returns 0 (no throttle) when rpm <= 0.
|
||||||
|
func rpmToMinInterval(rpm int) time.Duration {
|
||||||
|
if rpm <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Minute / time.Duration(rpm)
|
||||||
|
}
|
||||||
|
|
||||||
// boolDefault dereferences a *bool, returning def when nil.
|
// boolDefault dereferences a *bool, returning def when nil.
|
||||||
func boolDefault(p *bool, def bool) bool {
|
func boolDefault(p *bool, def bool) bool {
|
||||||
if p != nil {
|
if p != nil {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
|
@ -36,6 +37,12 @@ type Provider struct {
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
stream bool // Use SSE streaming internally (accumulates into a single LLMResponse)
|
stream bool // Use SSE streaming internally (accumulates into a single LLMResponse)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
|
||||||
|
// Rate limiting: minimum interval between consecutive API requests.
|
||||||
|
// Shared across all goroutines using this provider instance.
|
||||||
|
mu sync.Mutex
|
||||||
|
lastRequestAt time.Time
|
||||||
|
minInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option is a functional option for configuring a Provider.
|
// Option is a functional option for configuring a Provider.
|
||||||
|
|
@ -69,6 +76,14 @@ func WithStream(stream bool) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithMinInterval sets the minimum interval between consecutive API requests.
|
||||||
|
// This prevents rate limit errors when many subagents share the same provider.
|
||||||
|
func WithMinInterval(d time.Duration) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.minInterval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithEndpointPath sets the API path appended to apiBase (default: "/chat/completions").
|
// WithEndpointPath sets the API path appended to apiBase (default: "/chat/completions").
|
||||||
func WithEndpointPath(path string) Option {
|
func WithEndpointPath(path string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
|
|
@ -211,6 +226,25 @@ func (p *Provider) buildHTTPRequest(
|
||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForInterval enforces the minimum interval between consecutive API requests.
|
||||||
|
// It sleeps if needed, then records the current time as the last request time.
|
||||||
|
func (p *Provider) waitForInterval() {
|
||||||
|
if p.minInterval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if !p.lastRequestAt.IsZero() {
|
||||||
|
elapsed := time.Since(p.lastRequestAt)
|
||||||
|
if wait := p.minInterval - elapsed; wait > 0 {
|
||||||
|
p.mu.Unlock()
|
||||||
|
time.Sleep(wait)
|
||||||
|
p.mu.Lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.lastRequestAt = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) Chat(
|
func (p *Provider) Chat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
messages []Message,
|
messages []Message,
|
||||||
|
|
@ -233,6 +267,8 @@ func (p *Provider) Chat(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.waitForInterval()
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
|
@ -272,6 +308,8 @@ func (p *Provider) ChatStream(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.waitForInterval()
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req) //nolint:bodyclose // closed in goroutine or error path below
|
resp, err := p.httpClient.Do(req) //nolint:bodyclose // closed in goroutine or error path below
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// spawnTimeout is the hard upper bound for a single spawn goroutine.
|
||||||
|
// MaxIterations × HTTP timeout provides the soft limit; this is a safety net.
|
||||||
|
const spawnTimeout = 30 * time.Minute
|
||||||
|
|
||||||
type SubagentTask struct {
|
type SubagentTask struct {
|
||||||
ID string
|
ID string
|
||||||
Task string
|
Task string
|
||||||
|
|
@ -28,11 +32,13 @@ type SubagentTask struct {
|
||||||
Iterations int `json:"-"`
|
Iterations int `json:"-"`
|
||||||
ToolCalls int `json:"-"`
|
ToolCalls int `json:"-"`
|
||||||
ToolStats map[string]int `json:"-"`
|
ToolStats map[string]int `json:"-"`
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubagentManager struct {
|
type SubagentManager struct {
|
||||||
tasks map[string]*SubagentTask
|
tasks map[string]*SubagentTask
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
wg sync.WaitGroup // tracks running spawn goroutines
|
||||||
provider providers.LLMProvider
|
provider providers.LLMProvider
|
||||||
defaultModel string
|
defaultModel string
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
|
|
@ -122,8 +128,17 @@ func (sm *SubagentManager) Spawn(
|
||||||
|
|
||||||
sm.reporter.ReportSpawn(taskID, label, task)
|
sm.reporter.ReportSpawn(taskID, label, task)
|
||||||
|
|
||||||
// Start task in background with context cancellation support
|
// Start task in background with a detached context that has a hard timeout.
|
||||||
go sm.runTask(ctx, subagentTask, preset, callback)
|
// The spawned goroutine must outlive the parent (e.g. heartbeat session)
|
||||||
|
// which may finish before the subagent completes.
|
||||||
|
// The cancel func is stored on the task so CancelTask() can stop it.
|
||||||
|
spawnCtx, spawnCancel := context.WithTimeout(context.Background(), spawnTimeout)
|
||||||
|
subagentTask.cancel = spawnCancel
|
||||||
|
sm.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer sm.wg.Done()
|
||||||
|
sm.runTask(spawnCtx, subagentTask, preset, callback)
|
||||||
|
}()
|
||||||
|
|
||||||
if label != "" {
|
if label != "" {
|
||||||
return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil
|
return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil
|
||||||
|
|
@ -366,6 +381,33 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string)
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaitAll blocks until all spawned subagent goroutines have finished
|
||||||
|
// or the timeout expires. Returns true if all goroutines finished,
|
||||||
|
// false on timeout.
|
||||||
|
func (sm *SubagentManager) WaitAll(timeout time.Duration) bool {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
sm.wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelTask cancels the context for a running subagent task.
|
||||||
|
func (sm *SubagentManager) CancelTask(taskID string) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
task, ok := sm.tasks[taskID]
|
||||||
|
sm.mu.RUnlock()
|
||||||
|
if ok && task.cancel != nil {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,7 @@ func TestSubagentManager_Spawn_CancelledDuringExecution(t *testing.T) {
|
||||||
bp := newBlockingProvider()
|
bp := newBlockingProvider()
|
||||||
mgr := NewSubagentManager(bp, "test-model", "/tmp/test", nil, b, WebSearchToolOptions{})
|
mgr := NewSubagentManager(bp, "test-model", "/tmp/test", nil, b, WebSearchToolOptions{})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
_, err := mgr.Spawn(context.Background(), "long task", "cancel-me", "", "cli", "direct", "", nil)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := mgr.Spawn(ctx, "long task", "cancel-me", "", "cli", "direct", "", nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Spawn() error: %v", err)
|
t.Fatalf("Spawn() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -211,8 +208,8 @@ func TestSubagentManager_Spawn_CancelledDuringExecution(t *testing.T) {
|
||||||
t.Fatal("timed out waiting for blockingProvider to enter Chat")
|
t.Fatal("timed out waiting for blockingProvider to enter Chat")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now cancel — the LLM call unblocks with ctx.Err().
|
// Cancel via CancelTask — the spawned goroutine's detached context is canceled.
|
||||||
cancel()
|
mgr.CancelTask("subagent-1")
|
||||||
|
|
||||||
// Collect events until agent_gc.
|
// Collect events until agent_gc.
|
||||||
var events []orch.Event
|
var events []orch.Event
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue