Files
thotam 3f90057c24 fix(pipeline): stop aborting runs on heuristic context budget estimates (#1587)
Runs on models without a registered tokenizer (e.g. 9router brand models)
ended with the generic "Agent couldn't generate a response" fallback even
though the real request used about 55% of the context window.

PruneStage counted history with TokenCounter, which falls back to a
chars/2 heuristic for unregistered models and overcounted about 1.8x.
Once over budget it ran memory flush (~35s, invisible in traces), then
mid-loop compaction, which cannot summarize a history made only of tool
call/result pairs. The callback reported the untouched history as
compacted, PruneStage still saw it over budget and returned AbortRun
before any LLM call, and FinalizeStage replaced the empty reply with the
fallback.

- PruneStage and ContextStage overhead count with the request guard's
  BudgetCounter. PruneStage no longer controls loop flow; the final
  request guard in ThinkStage decides.
- CompactMessages returns ErrNotCompacted when history is unchanged.
  Callers stop counting it as a compaction and do not retry it in the
  same run, while post-run summarization still sees the pressure.
- When the guard exhausts every reduction step, ThinkStage stops the run
  with a localized chat.context_budget_exceeded notice instead of an
  error, so the run's tool results are still persisted. The stop reason
  marks the trace and agent span as error; team tasks, cron and
  heartbeat treat it as a failure via RunOutcome.Failure().
- Memory flush and mid-loop compaction emit event spans.
- Web and desktop UIs treat an unset context_pruning as enabled (the
  backend default since 7639a8c0), keep it unset when untouched, and can
  re-enable pruning after it was turned off.
2026-09-29 18:04:08 +07:00

146 lines
4.5 KiB
Go

package pipeline
import (
"context"
"sync"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
)
// RunState is the shared mutable state for a single pipeline run.
// Passed by pointer through all stages.
type RunState struct {
// Identity (set once at pipeline start, immutable during run)
Input *RunInput
Workspace *workspace.WorkspaceContext
Model string
Provider providers.Provider
// Ctx holds enriched context from ContextStage (agent/user/workspace values).
// Pipeline.Run uses this for all stages after setup completes.
Ctx context.Context
// Message buffer (read/write by multiple stages)
Messages *MessageBuffer
// Per-stage substates
Context ContextState
Think ThinkState
Prune PruneState
Tool ToolState
Observe ObserveState
Compact CompactState
Evolution EvolutionState
// Cross-cutting concerns
Iteration int
RunID string
ExitCode StageResult
// StopReason explains a run stopped by the pipeline instead of the model (empty otherwise).
StopReason string
// CurrentLLMSpanID is the most recent LLM-call span in this run; tool spans parent to it.
CurrentLLMSpanID *uuid.UUID
// CurrentToolSpanID is the most recent tool-call span; post-tool-use hook spans parent to it.
CurrentToolSpanID *uuid.UUID
// Calls is the per-call usage breakdown (LLM calls + tool-internal LLM calls),
// appended during the run. Guarded by callsMu for the parallel tool path.
Calls []providers.CallUsage
callsMu sync.Mutex
}
// NewRunState creates a RunState with identity fields set.
func NewRunState(input *RunInput, ws *workspace.WorkspaceContext, model string, provider providers.Provider) *RunState {
return &RunState{
Input: input,
Workspace: ws,
Model: model,
Provider: provider,
RunID: input.RunID,
Messages: NewMessageBuffer(providers.Message{}),
}
}
// AppendCall records one call's usage in the run breakdown (thread-safe).
func (rs *RunState) AppendCall(c providers.CallUsage) {
rs.callsMu.Lock()
rs.Calls = append(rs.Calls, c)
rs.callsMu.Unlock()
}
// BuildResult converts final RunState into a RunResult.
func (rs *RunState) BuildResult() *RunResult {
return &RunResult{
RunID: rs.RunID,
Content: rs.Observe.FinalContent,
Thinking: rs.Observe.FinalThinking,
TotalUsage: rs.Think.TotalUsage,
LastUsage: rs.Think.LastUsage,
Iterations: rs.Iteration,
ToolCalls: rs.Tool.TotalToolCalls,
LoopKilled: rs.Tool.LoopKilled,
AsyncToolCalls: rs.Tool.AsyncToolCalls,
MediaResults: rs.Tool.MediaResults,
Deliverables: rs.Tool.Deliverables,
BlockReplies: rs.Observe.BlockReplies,
LastBlockReply: rs.Observe.LastBlockReply,
Calls: rs.Calls,
StopReason: rs.StopReason,
}
}
// RunInput is the pipeline's view of a run request.
// Converted from agent.RunRequest by the adapter in Phase 8.
type RunInput struct {
SessionKey string
Message string
Media []bus.MediaFile
ForwardMedia []bus.MediaFile
Channel string
ChannelType string
BitrixPortalDomain string // bitrix24-only: portal domain for entity URL construction
ChatTitle string
ChatID string
PeerKind string
RunID string
UserID string
SenderID string
SenderName string
Stream bool
ExtraSystemPrompt string
SkillFilter []string
HistoryLimit int
ToolAllow []string
TelegramManagerPermissions []string
LightContext bool
RunKind string
DelegationID string
TeamID string
TeamTaskID string
ParentAgentID string
MaxIterations int
ModelOverride string
HideInput bool
ContentSuffix string
LeaderAgentID string
WorkspaceChannel string
WorkspaceChatID string
TeamWorkspace string
}
// MediaResult represents a media file produced during tool execution.
type MediaResult struct {
Path string
ContentType string
Size int64
AsVoice bool
// Prompt is the generation prompt for AI-generated media (e.g. create_image).
// Empty for user-uploaded or non-generated files.
Prompt string
}