Files
goclaw/internal/agent/loop_pipeline_callbacks.go
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

890 lines
35 KiB
Go

package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// pipelineCallbacks creates all callback closures that capture *Loop.
// Each callback bridges a pipeline.PipelineDeps function to an existing Loop method.
func (l *Loop) pipelineCallbacks(req *RunRequest, bridgeRS *runState) pipelineCallbackSet {
// Shared emitRun enriches events with routing context (matching v2 pattern).
emitRun := func(event AgentEvent) {
event.RunKind = req.RunKind
event.DelegationID = req.DelegationID
event.TeamID = req.TeamID
event.TeamTaskID = req.TeamTaskID
event.ParentAgentID = req.ParentAgentID
event.SenderID = req.SenderID
event.UserID = req.UserID
event.Channel = req.Channel
event.ChatID = req.ChatID
event.SessionKey = req.SessionKey
event.TenantID = l.tenantID
l.emit(redactDelegationAgentEvent(req, event))
}
return pipelineCallbackSet{
emitRun: emitRun,
injectContext: l.makeInjectContext(req),
loadSessionHistory: l.makeLoadSessionHistory(),
resolveWorkspace: l.makeResolveWorkspace(req),
loadContextFiles: l.makeLoadContextFiles(),
buildMessages: l.makeBuildMessages(req),
enrichMedia: l.makeEnrichMedia(req),
injectReminders: l.makeInjectReminders(req),
buildFilteredTools: l.makeBuildFilteredTools(req),
callLLM: l.makeCallLLM(req, emitRun),
pruneMessages: l.makePruneMessages(),
sanitizeHistory: sanitizeHistory,
compactMessages: l.makeCompactMessages(req),
runMemoryFlush: l.makeRunMemoryFlush(),
executeToolCall: l.makeExecuteToolCall(req, bridgeRS),
executeToolRaw: l.makeExecuteToolRaw(req),
processToolResult: l.makeProcessToolResult(req, bridgeRS),
authorizeToolCall: l.makeAuthorizeToolCall(),
checkReadOnly: l.makeCheckReadOnly(req, bridgeRS),
sanitizeContent: SanitizeAssistantContent,
flushMessages: l.makeFlushMessages(req),
updateMetadata: l.makeUpdateMetadata(req),
bootstrapCleanup: l.makeBootstrapCleanup(),
maybeSummarize: l.maybeSummarize,
}
}
// pipelineCallbackSet groups all typed callbacks for PipelineDeps.
type pipelineCallbackSet struct {
emitRun func(AgentEvent)
injectContext func(ctx context.Context, input *pipeline.RunInput) (context.Context, error)
loadSessionHistory func(ctx context.Context, sessionKey string) ([]providers.Message, string)
resolveWorkspace func(ctx context.Context, input *pipeline.RunInput) (*workspace.WorkspaceContext, error)
loadContextFiles func(ctx context.Context, userID string) ([]bootstrap.ContextFile, bool)
buildMessages func(ctx context.Context, input *pipeline.RunInput, history []providers.Message, summary string) ([]providers.Message, error)
enrichMedia func(ctx context.Context, state *pipeline.RunState) error
injectReminders func(ctx context.Context, input *pipeline.RunInput, msgs []providers.Message) []providers.Message
buildFilteredTools func(state *pipeline.RunState) ([]providers.ToolDefinition, error)
callLLM func(ctx context.Context, state *pipeline.RunState, req providers.ChatRequest) (*providers.ChatResponse, error)
pruneMessages func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats)
sanitizeHistory func(msgs []providers.Message) ([]providers.Message, int)
compactMessages func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error)
runMemoryFlush func(ctx context.Context, state *pipeline.RunState) error
executeToolCall func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall) ([]providers.Message, error)
executeToolRaw func(ctx context.Context, tc providers.ToolCall) (providers.Message, any, error)
processToolResult func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall, rawMsg providers.Message, rawData any) []providers.Message
authorizeToolCall func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall) (bool, string)
checkReadOnly func(state *pipeline.RunState) (*providers.Message, bool)
sanitizeContent func(string) string
flushMessages func(ctx context.Context, sessionKey string, msgs []providers.Message) error
updateMetadata func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error
bootstrapCleanup func(ctx context.Context, state *pipeline.RunState) error
maybeSummarize func(ctx context.Context, sessionKey string, midLoopCompacted bool)
}
func (l *Loop) makeResolveWorkspace(req *RunRequest) func(ctx context.Context, input *pipeline.RunInput) (*workspace.WorkspaceContext, error) {
resolver := workspace.NewResolver()
return func(ctx context.Context, input *pipeline.RunInput) (*workspace.WorkspaceContext, error) {
var teamID *string
if input.TeamID != "" {
teamID = &input.TeamID
}
return resolver.Resolve(ctx, workspace.ResolveParams{
AgentID: l.id,
AgentType: l.agentType,
UserID: input.UserID,
ChatID: input.ChatID,
TenantID: l.tenantID.String(),
PeerKind: input.PeerKind,
TeamID: teamID,
BaseDir: l.workspace,
})
}
}
func (l *Loop) makeLoadContextFiles() func(ctx context.Context, userID string) ([]bootstrap.ContextFile, bool) {
return func(ctx context.Context, userID string) ([]bootstrap.ContextFile, bool) {
files := l.resolveContextFiles(ctx, userID)
hadBootstrap := false
for _, f := range files {
if strings.HasSuffix(f.Path, "BOOTSTRAP.md") {
hadBootstrap = true
break
}
}
return files, hadBootstrap
}
}
func (l *Loop) makeBuildMessages(req *RunRequest) func(ctx context.Context, input *pipeline.RunInput, history []providers.Message, summary string) ([]providers.Message, error) {
return func(ctx context.Context, input *pipeline.RunInput, history []providers.Message, summary string) ([]providers.Message, error) {
if prompt := buildTeamWorkDirectivePrompt(req.TeamWorkDirective); prompt != "" {
if input.ExtraSystemPrompt != "" {
input.ExtraSystemPrompt += "\n\n"
}
input.ExtraSystemPrompt += prompt
}
msgs, _ := l.buildMessages(ctx, history, summary,
input.Message, input.ExtraSystemPrompt,
input.SessionKey, input.Channel, input.ChannelType,
input.BitrixPortalDomain,
input.ChatTitle, input.ChatID, input.PeerKind, input.UserID, input.SenderName,
input.HistoryLimit, input.SkillFilter, input.LightContext, input.TelegramManagerPermissions)
return msgs, nil
}
}
// makeInjectContext wraps injectContext() for the v3 pipeline.
// Reuses the existing injectContext() to avoid logic duplication.
// NOTE: injectContext() and this callback must stay in sync when new context values are added.
func (l *Loop) makeInjectContext(req *RunRequest) func(ctx context.Context, input *pipeline.RunInput) (context.Context, error) {
return func(ctx context.Context, input *pipeline.RunInput) (context.Context, error) {
result, err := l.injectContext(ctx, req)
if err != nil {
return ctx, err
}
// Sync message truncation from req back to pipeline input.
input.Message = req.Message
// Cache context window on session (first run only).
if l.sessions.GetContextWindow(result.ctx, req.SessionKey) <= 0 {
l.sessions.SetContextWindow(result.ctx, req.SessionKey, l.contextWindow)
}
return result.ctx, nil
}
}
// makeLoadSessionHistory loads session history + summary before BuildMessages.
func (l *Loop) makeLoadSessionHistory() func(ctx context.Context, sessionKey string) ([]providers.Message, string) {
return func(ctx context.Context, sessionKey string) ([]providers.Message, string) {
history := l.sessions.GetHistory(ctx, sessionKey)
summary := l.sessions.GetSummary(ctx, sessionKey)
return history, summary
}
}
func (l *Loop) makeEnrichMedia(req *RunRequest) func(ctx context.Context, state *pipeline.RunState) error {
return func(ctx context.Context, state *pipeline.RunState) error {
// enrichInputMedia enriches messages in-place: attaches inline images,
// reloads historical media, enriches <media:*> tags, populates context
// with refs for tool access. Must receive actual messages (not nil) to
// avoid index-out-of-range panic on inline image attachment.
msgs := state.Messages.All()
if len(msgs) == 0 {
return nil
}
enrichedCtx, enrichedMsgs, currentRefs := l.enrichInputMedia(ctx, req, msgs)
// Propagate enriched context (media images/docs/audio/video refs for tools).
state.Ctx = enrichedCtx
// Update history with enriched messages (media tags, inline images).
// Skip system message (index 0) — only history + user messages are enriched.
if len(enrichedMsgs) > 1 {
state.Messages.SetHistory(enrichedMsgs[1:])
}
// Preserve the enriched current input for the first session checkpoint.
// Inline image bytes stay request-local; durable history stores only the
// logical tags plus absolute MediaRefs used internally for exact lookup.
if len(currentRefs) > 0 {
for i := len(enrichedMsgs) - 1; i >= 0; i-- {
if enrichedMsgs[i].Role != "user" {
continue
}
req.enrichedInputMessage = providers.Message{
Role: "user",
Content: enrichedMsgs[i].Content,
MediaRefs: append([]providers.MediaRef(nil), currentRefs...),
}
req.hasEnrichedInputMessage = true
break
}
}
return nil
}
}
func (l *Loop) makeInjectReminders(req *RunRequest) func(ctx context.Context, input *pipeline.RunInput, msgs []providers.Message) []providers.Message {
return func(ctx context.Context, input *pipeline.RunInput, msgs []providers.Message) []providers.Message {
updated, _ := l.injectTeamTaskReminders(ctx, req, msgs)
return updated
}
}
func (l *Loop) makeBuildFilteredTools(req *RunRequest) func(state *pipeline.RunState) ([]providers.ToolDefinition, error) {
// Per-run cache: all filtering inputs (policy, disabled tools, bootstrap,
// channel, orchestration mode, user MCP tools) are fixed for the lifetime
// of a run. Only the final iteration differs (strips all tools). Cache the
// result after the first call and reuse for iterations 0..maxIter-1.
var (
cachedToolDefs []providers.ToolDefinition
cacheValid bool
)
return func(state *pipeline.RunState) ([]providers.ToolDefinition, error) {
maxIter := l.maxIterations
if req.MaxIterations > 0 && req.MaxIterations < maxIter {
maxIter = req.MaxIterations
}
// Cache hit: reuse tool defs from first call for non-final iterations.
if cacheValid && state.Iteration != maxIter {
return cachedToolDefs, nil
}
// Load per-user MCP tools (Notion, etc.) into registry before filtering.
// Servers with require_user_credentials are deferred at startup and
// connected per-request here with the actual user's credentials.
//
// Prefer CredentialUserID from context — resolveCredentialUserID already
// resolved the merged tenant_user identity (e.g. Telegram group merged
// to a tenant_user via UI). Using the raw SenderID or group composite
// as the cache key would miss the credential row keyed by the merged
// tenant_user UUID. Fall back to resolveActorUserID for channels without
// merge resolution (backward compat). See resolveActorUserID docstring
// for the group-rewrite recovery rationale.
actorUserID := store.CredentialUserIDFromContext(state.Ctx)
if actorUserID == "" {
actorUserID = resolveActorUserID(
state.Input.UserID,
state.Input.SenderID,
state.Input.PeerKind,
state.Input.ChannelType,
)
}
userTools := l.getUserMCPTools(state.Ctx, actorUserID)
slog.Debug("mcp.user_tools_context",
"peer_kind", state.Input.PeerKind,
"input_user_id", state.Input.UserID,
"sender_id", state.Input.SenderID,
"actor_user_id", actorUserID,
"user_tools_count", len(userTools))
allMsgs := state.Messages.All()
toolDefs, _, returnedMsgs := l.buildFilteredTools(req, state.Context.HadBootstrap,
state.Iteration, maxIter, allMsgs, userTools)
// buildFilteredTools returns the full messages slice; only messages appended
// beyond the original length are injections (e.g. final-iteration hint).
// Appending the entire slice would duplicate system+history into pending.
if len(returnedMsgs) > len(allMsgs) {
for _, msg := range returnedMsgs[len(allMsgs):] {
state.Messages.AppendPending(msg)
}
}
// Cache store after first successful non-final call.
if !cacheValid && state.Iteration != maxIter {
cachedToolDefs = toolDefs
cacheValid = true
}
slog.Debug("mcp.filtered_tools",
"tool_defs_count", len(toolDefs),
"mcp_defs_count", countMCPToolDefs(toolDefs),
"iteration", state.Iteration)
return toolDefs, nil
}
}
// countMCPToolDefs counts MCP-bridged tool definitions (name prefix "mcp_").
// It skips entries with a nil Function — e.g. the native image_generation
// sentinel providers.ToolDefinition{Type: "image_generation"} — which would
// otherwise nil-deref (the v3.14.0 panic on every message for codex agents).
func countMCPToolDefs(toolDefs []providers.ToolDefinition) int {
n := 0
for _, td := range toolDefs {
if td.Function == nil {
continue
}
if strings.HasPrefix(strings.TrimSpace(td.Function.Name), "mcp_") {
n++
}
}
return n
}
// makeAuthorizeToolCall enforces a runtime fail-closed allowlist check before
// every tool execution. AllowedTools is keyed by canonical registry names (built
// by ThinkStage from FilterTools output). The model may emit prefixed names when
// the agent has toolCallPrefix configured (e.g. "proxy_exec" → canonical "exec"),
// so we resolve the name to its canonical form before the lookup to avoid a
// guaranteed miss on every prefixed call.
func (l *Loop) makeAuthorizeToolCall() func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall) (bool, string) {
return func(_ context.Context, state *pipeline.RunState, tc providers.ToolCall) (bool, string) {
allowed := state.Tool.AllowedTools
if allowed == nil {
// nil allowlist means no per-iteration restriction (e.g. BuildFilteredTools not wired).
return true, ""
}
// Resolve to canonical name before allowlist lookup. AllowedTools is keyed
// by canonical names; the model may emit prefixed names when toolCallPrefix
// is set (e.g. "proxy_exec" vs "exec"). Without this the lookup always misses.
name := l.resolveToolCallName(tc.Name)
if allowed[name] {
return true, ""
}
// Preserve lazy activation for deferred tools (typically per-user MCP).
if l.tools != nil && l.tools.TryActivateDeferred(name) {
// Re-check deny policy to prevent a lazy-activated tool from bypassing
// an explicit deny rule.
if l.toolPolicy != nil && l.toolPolicy.IsDenied(tools.ResolveConcreteRegistry(l.tools), name, l.agentToolPolicy) {
return false, "tool not allowed by policy: " + name
}
allowed[name] = true
return true, ""
}
return false, "tool not allowed by policy: " + name
}
}
// allowedToolNamesSlice converts a policy-filtered allowed-tool set into a
// sorted slice for deterministic downstream consumption (e.g. Claude CLI
// --disallowedTools derivation).
func allowedToolNamesSlice(allowed map[string]bool) []string {
names := make([]string, 0, len(allowed))
for name := range allowed {
names = append(names, name)
}
slices.Sort(names)
return names
}
func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx context.Context, state *pipeline.RunState, chatReq providers.ChatRequest) (*providers.ChatResponse, error) {
return func(ctx context.Context, state *pipeline.RunState, chatReq providers.ChatRequest) (*providers.ChatResponse, error) {
provider := state.Provider
model := state.Model
// Issue 3: surface transient provider retries to the user ("Provider busy,
// retrying...") instead of a silent failure ending in a 💔 reaction. The
// providers' internal RetryDo / codex loops fire this hook before each retry
// attempt; the channel layer turns run.retrying into a placeholder update.
ctx = providers.WithRetryHook(ctx, func(attempt, maxAttempts int, _ error) {
emitRun(AgentEvent{
Type: protocol.AgentEventRunRetrying,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{
"attempt": strconv.Itoa(attempt),
"maxAttempts": strconv.Itoa(maxAttempts),
},
})
})
// Enrich ChatRequest options to match v2 (providers need these for caching, routing, audit).
if chatReq.Options == nil {
chatReq.Options = make(map[string]any)
}
chatReq.Options[providers.OptTemperature] = config.DefaultTemperature
chatReq.Options[providers.OptSessionKey] = req.SessionKey
chatReq.Options[providers.OptAgentID] = l.agentUUID.String()
chatReq.Options[providers.OptUserID] = req.UserID
chatReq.Options[providers.OptChannel] = req.Channel
chatReq.Options[providers.OptChatID] = req.ChatID
chatReq.Options[providers.OptPeerKind] = req.PeerKind
chatReq.Options[providers.OptLocalKey] = req.LocalKey
chatReq.Options[providers.OptWorkspace] = tools.ToolWorkspaceFromCtx(ctx)
if delegationID := tools.DelegationIDFromCtx(ctx); delegationID != "" {
chatReq.Options[providers.OptDelegationID] = delegationID
}
if inputs := tools.DelegationArtifactInputsFromCtx(ctx); inputs != "" {
chatReq.Options[providers.OptDelegationInputs] = inputs
}
// Pass the policy-filtered allowed tool set so the Claude CLI provider
// can restrict its native built-in tools (Bash, Edit, Write, Read,
// WebFetch, WebSearch) to what the agent's tool policy actually allows.
// A nil state.Tool.AllowedTools means BuildFilteredTools didn't run
// (not wired) — do NOT set the option in that case, so the CLI
// provider's own fail-closed default (nil -> no tools allowed) applies
// rather than silently omitting the flag.
if state.Tool.AllowedTools != nil {
chatReq.Options[providers.OptAllowedToolNames] = allowedToolNamesSlice(state.Tool.AllowedTools)
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID != uuid.Nil {
chatReq.Options[providers.OptTenantID] = tenantID.String()
}
if supportsPromptCacheParams(provider) {
setDefaultPromptCacheOptions(chatReq.Options, tenantID, l.agentUUID, provider.Name(), req.SessionKey)
}
// Reasoning decision: resolve effort level for thinking models (o3, DeepSeek-R1, Kimi).
reasoningDecision := providers.ResolveReasoningDecision(
provider, model,
l.reasoningConfig.Effort,
l.reasoningConfig.Fallback,
l.reasoningConfig.Source,
)
if effort := reasoningDecision.RequestEffort(); effort != "" {
chatReq.Options[providers.OptThinkingLevel] = effort
}
if reasoningDecision.StripThinking {
chatReq.Options[providers.OptStripThinking] = true
}
// Emit LLM span start for tracing.
start := time.Now().UTC()
var opts []spanOption
if state.Model != "" {
opts = append(opts, withModel(state.Model))
}
if provider != nil {
opts = append(opts, withProvider(provider.Name()))
}
spanID := l.emitLLMSpanStart(ctx, start, state.Iteration+1, chatReq.Messages, opts...)
if spanID != uuid.Nil {
state.CurrentLLMSpanID = &spanID
}
recordUsageCapAttempt := func(reservation *usagecaps.Reservation) {
if reservation != nil {
opts = append(opts, withUsageCapMetadata(reservation.TraceMetadata()))
}
}
streamThinkingEmitted := false
emitChunk := func(chunk providers.StreamChunk) {
if chunk.Thinking != "" {
streamThinkingEmitted = true
emitRun(AgentEvent{
Type: protocol.ChatEventThinking,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{"content": chunk.Thinking},
})
}
if chunk.Content != "" {
emitRun(AgentEvent{
Type: protocol.ChatEventChunk,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{"content": chunk.Content},
})
}
}
fallbackTraceClassifier := providers.NewDefaultClassifier()
callProvider := func(attempt string, request providers.ChatRequest) (*providers.ChatResponse, error) {
if fallbackProvider, ok := provider.(*providers.ModelFallbackProvider); ok {
before := func(callCtx context.Context, entry providers.FallbackCandidate, actualReq providers.ChatRequest) (providers.FallbackAfterCall, error) {
candidateAttempt := fmt.Sprintf("%s:%s:%s", attempt, entry.ProviderName, actualReq.Model)
reservation, reserveErr := l.reserveLLMUsageFor(callCtx, req, state.Iteration, actualReq, candidateAttempt, entry.ProviderName, actualReq.Model)
if reserveErr != nil {
recordUsageCapAttempt(reservation)
return nil, reserveErr
}
return func(callResp *providers.ChatResponse, callErr error, info providers.FallbackCallInfo) {
fallbackMeta := providers.ModelFallbackAttemptMetadata{
ProviderName: entry.ProviderName,
Model: actualReq.Model,
Status: "success",
Streamed: info.Streamed,
}
if callErr != nil {
classification := providers.ClassifyHTTPError(fallbackTraceClassifier, callErr)
reason := string(classification.Reason)
if classification.Kind == "context_overflow" {
reason = "context_overflow"
}
fallbackMeta.Status = "error"
fallbackMeta.Reason = reason
fallbackMeta.Error = callErr.Error()
} else {
opts = append(opts, withProvider(entry.ProviderName), withModel(actualReq.Model))
}
opts = append(opts, withModelFallbackAttempt(fallbackMeta))
if reservation != nil {
if info.Streamed {
reservation.ReconcileStream(callCtx, callResp, callErr, true)
} else {
reservation.Reconcile(callCtx, callResp, callErr)
}
recordUsageCapAttempt(reservation)
}
}, nil
}
if req.Stream {
return fallbackProvider.ChatStreamWithHook(ctx, request, emitChunk, before)
}
return fallbackProvider.ChatWithHook(ctx, request, before)
}
reservation, reserveErr := l.reserveLLMUsage(ctx, req, state, request, attempt)
if reserveErr != nil {
recordUsageCapAttempt(reservation)
return nil, reserveErr
}
var callResp *providers.ChatResponse
var callErr error
if req.Stream {
streamed := false
callResp, callErr = provider.ChatStream(ctx, request, func(chunk providers.StreamChunk) {
if chunk.Content != "" || chunk.Thinking != "" || len(chunk.Images) > 0 {
streamed = true
}
emitChunk(chunk)
})
if reservation != nil {
reservation.ReconcileStream(ctx, callResp, callErr, streamed)
recordUsageCapAttempt(reservation)
}
return callResp, callErr
} else {
callResp, callErr = provider.Chat(ctx, request)
}
if reservation != nil {
reservation.Reconcile(ctx, callResp, callErr)
recordUsageCapAttempt(reservation)
}
return callResp, callErr
}
resp, err := callProvider("initial", chatReq)
slog.Info("debug.llm.first_response",
"has_error", err != nil,
"tool_calls_count", func() int {
if resp == nil {
return -1
}
return len(resp.ToolCalls)
}(),
"tools_provided", len(chatReq.Tools))
if err == nil && teamWorkDirectiveNeedsRetry(req.TeamWorkDirective, state.Iteration, resp) {
retryReq := buildTeamWorkDirectiveRetryRequest(chatReq, req.TeamWorkDirective)
resp, err = callProvider("team-work-directive-retry", retryReq)
slog.Info("team_work_classify: directive retry response",
"has_error", err != nil,
"required_tool", req.TeamWorkDirective.normalizedRequiredTool(),
"tool_calls_count", func() int {
if resp == nil {
return -1
}
return len(resp.ToolCalls)
}())
if err == nil && teamWorkDirectiveNeedsRetry(req.TeamWorkDirective, state.Iteration, resp) {
resp = &providers.ChatResponse{
Content: teamWorkDirectiveBlocker(req.TeamWorkDirective),
FinishReason: "stop",
}
}
}
// One guarded retry when MCP task tools are available but the model
// returns text-only instead of tool calls.
retryEligible := req.TeamWorkDirective == nil && err == nil && resp != nil && len(resp.ToolCalls) == 0 && shouldRetryTaskMCP(chatReq)
slog.Info("debug.llm.retry_guard", "retry_eligible", retryEligible)
if retryEligible {
retryReq := chatReq
if retryReq.Options == nil {
retryReq.Options = make(map[string]any)
}
retryReq.Options[providers.OptToolChoice] = "required"
retryReq.Messages = append(append([]providers.Message{}, chatReq.Messages...), providers.Message{
Role: "system",
Content: "MCP task tools are available in this turn. Do not ask for CRM identifier/email first. Call the relevant MCP task tool immediately, then answer with the tool result.",
})
resp, err = callProvider("retry-tool-choice", retryReq)
slog.Info("debug.llm.retry_response",
"has_error", err != nil,
"tool_calls_count", func() int {
if resp == nil {
return -1
}
return len(resp.ToolCalls)
}())
}
if req.Stream && err == nil && resp != nil && resp.Thinking != "" && !streamThinkingEmitted {
emitRun(AgentEvent{
Type: protocol.ChatEventThinking,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{"content": resp.Thinking},
})
}
// Non-streaming: emit content events matching v2 behavior (channels need these).
if !req.Stream && err == nil && resp != nil {
if resp.Thinking != "" {
emitRun(AgentEvent{
Type: protocol.ChatEventThinking,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{"content": resp.Thinking},
})
}
if resp.Content != "" {
emitRun(AgentEvent{
Type: protocol.ChatEventChunk,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{"content": resp.Content},
})
}
}
l.emitLLMSpanEnd(ctx, spanID, start, resp, err, opts...)
if err == nil && resp != nil && resp.Usage != nil {
effModel, effProvider := l.resolveSpan(opts)
state.AppendCall(providers.CallUsage{
Type: "llm_call",
Name: fmt.Sprintf("%s/%s #%d", effProvider, effModel, state.Iteration+1),
Provider: effProvider,
Model: effModel,
Usage: *resp.Usage,
CostUSD: l.calculateLLMCost(ctx, effProvider, effModel, resp.Usage),
})
}
return resp, err
}
}
func shouldRetryTaskMCP(chatReq providers.ChatRequest) bool {
hasTaskMCPTool := false
for _, td := range chatReq.Tools {
if td.Function == nil {
continue
}
name := strings.TrimSpace(td.Function.Name)
if strings.HasPrefix(name, "mcp_bx24__") && (strings.Contains(name, "search") || strings.Contains(name, "execute")) {
hasTaskMCPTool = true
break
}
}
if !hasTaskMCPTool {
return false
}
lastUser := ""
for i := len(chatReq.Messages) - 1; i >= 0; i-- {
if chatReq.Messages[i].Role == "user" {
lastUser = strings.ToLower(strings.TrimSpace(chatReq.Messages[i].Content))
break
}
}
if lastUser == "" {
return false
}
return strings.Contains(lastUser, "task") || strings.Contains(lastUser, "việc") || strings.Contains(lastUser, "công việc")
}
func (l *Loop) makePruneMessages() func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats) {
return func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats) {
var stats pipeline.PruneStats
pruned := pruneContextMessages(msgs, budget, l.contextPruningCfg, l.tokenCounter, l.model, &stats)
return pruned, stats
}
}
func (l *Loop) makeCompactMessages(req *RunRequest) func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error) {
return func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error) {
start := time.Now().UTC()
compacted := l.compactMessagesInPlace(ctx, msgs)
outcome := "compacted"
if compacted == nil {
outcome = "not_compacted"
}
tracing.EmitEventSpan(ctx, "mid_loop_compaction", start, nil, map[string]any{
"outcome": outcome,
"input_messages": len(msgs),
"output_messages": len(compacted),
})
if compacted == nil {
return nil, pipeline.ErrNotCompacted
}
// Stamp session metadata with the compaction timestamp so operators
// can diagnose compaction cadence without a dedicated column. Stored
// as RFC3339 string in sessions.metadata JSONB (flushed on next save).
if l.sessions != nil && req != nil && req.SessionKey != "" {
l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{
SessionMetaKeyLastCompactionAt: time.Now().UTC().Format(time.RFC3339),
})
}
return compacted, nil
}
}
// SessionMetaKeyLastCompactionAt is the sessions.metadata JSONB key used to
// record the RFC3339 timestamp of the most recent compaction. Exported so
// the web UI code path can read it back via GetSessionMetadata without
// duplicating the string.
const SessionMetaKeyLastCompactionAt = "last_compaction_at"
// cacheTouchAt returns the last prune-mutation timestamp for a session.
// Returns zero time if no touch recorded yet.
func (l *Loop) cacheTouchAt(sessionKey string) time.Time {
if v, ok := l.cacheTouchBySession.Load(sessionKey); ok {
return v.(time.Time)
}
return time.Time{}
}
// markCacheTouched records the current time as the last prune-mutation timestamp
// for the given session. Called only after pruning actually mutates messages.
func (l *Loop) markCacheTouched(sessionKey string) {
l.cacheTouchBySession.Store(sessionKey, time.Now())
}
func (l *Loop) makeRunMemoryFlush() func(ctx context.Context, state *pipeline.RunState) error {
return func(ctx context.Context, state *pipeline.RunState) error {
settings := ResolveMemoryFlushSettings(l.compactionCfg)
if settings == nil {
return nil
}
l.runMemoryFlush(ctx, state.Input.SessionKey, settings)
return nil
}
}
func (l *Loop) makeFlushMessages(req *RunRequest) func(ctx context.Context, sessionKey string, msgs []providers.Message) error {
// Track whether user message has been persisted (first flush only).
// v2 adds user message to pendingMsgs explicitly; v3 keeps it in history
// (via BuildMessages) so it never reaches FlushPending. This closure
// persists the user message on first flush to match v2 session format.
var userMsgFlushed bool
return func(ctx context.Context, sessionKey string, msgs []providers.Message) error {
if !userMsgFlushed && !req.HideInput && req.Message != "" {
userMsgFlushed = true
inputMessage := providers.Message{
Role: "user",
Content: req.Message,
}
if req.hasEnrichedInputMessage {
inputMessage = req.enrichedInputMessage
}
l.sessions.AddMessage(ctx, sessionKey, redactDelegationMessage(req, inputMessage))
}
for _, msg := range msgs {
l.sessions.AddMessage(ctx, sessionKey, redactDelegationMessage(req, msg))
}
return nil
}
}
func (l *Loop) makeUpdateMetadata(req *RunRequest) func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error {
return func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error {
l.sessions.UpdateMetadata(ctx, sessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(ctx, sessionKey, int64(usage.PromptTokens), int64(usage.CompletionTokens))
// Persist session to DB (matching v2 finalizeRun behavior).
// FlushMessages already ran, so all pending messages are in the cache.
// Calibration uses the FINAL iteration's context size, NOT the
// run-cumulative total — the total sums every think→act→observe
// iteration and inflated the sessions "context used" display and
// compaction decisions by the iteration count.
// Current context = final prompt (incl. cached segments) + final output:
// the last reply joins history, so it occupies the next request's prompt.
// This matches msgCount, which already counts the flushed reply.
if lastCtx := lastUsage.ContextTokens(); lastCtx > 0 {
l.sessions.SetLastPromptTokens(ctx, sessionKey, lastCtx+lastUsage.CompletionTokens, msgCount)
}
l.sessions.Save(ctx, sessionKey)
return nil
}
}
func (l *Loop) makeSkillPostscript() func(ctx context.Context, content string, totalToolCalls int) string {
if !l.skillEvolve || l.skillNudgeInterval <= 0 {
return nil // disabled — FinalizeStage skips
}
var sent bool
return func(ctx context.Context, content string, totalToolCalls int) string {
if sent || totalToolCalls < l.skillNudgeInterval || IsSilentReply(content) {
return content
}
sent = true
locale := store.LocaleFromContext(ctx)
return content + "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_"
}
}
func (l *Loop) makeBootstrapCleanup() func(ctx context.Context, state *pipeline.RunState) error {
return func(ctx context.Context, state *pipeline.RunState) error {
if l.bootstrapCleanup == nil {
return nil
}
return l.bootstrapCleanup(ctx, l.agentUUID, state.Input.UserID)
}
}
func (l *Loop) reserveLLMUsage(ctx context.Context, req *RunRequest, state *pipeline.RunState, chatReq providers.ChatRequest, attempt string) (*usagecaps.Reservation, error) {
providerName := ""
if state.Provider != nil {
providerName = state.Provider.Name()
}
// reserveLLMUsageFor runs the mandatory hard-ceiling guard before any
// reservation or transport, so the non-fallback path is guarded here even
// when usage caps are disabled (Lite runtime).
return l.reserveLLMUsageFor(ctx, req, state.Iteration, chatReq, attempt, providerName, state.Model)
}
func (l *Loop) reserveLLMUsageFor(ctx context.Context, req *RunRequest, iteration int, chatReq providers.ChatRequest, attempt, providerName, model string) (*usagecaps.Reservation, error) {
// Mandatory final hard-ceiling guard for the concrete request that is about
// to be sent, after all directive/retry/reasoning mutations. This is the
// shared pre-transport chokepoint for BOTH the fallback candidate path and
// the non-fallback path (via reserveLLMUsage), and for every retry attempt.
// It runs regardless of usage-cap configuration so the ceiling holds on Lite.
if guardErr := l.guardCompleteModelRequest(chatReq, providerName, model, attempt); guardErr != nil {
return nil, guardErr
}
if l.usageCaps == nil {
return nil, nil
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = l.tenantID
}
key := fmt.Sprintf("%s:%s:%d:%s", req.RunID, l.agentUUID.String(), iteration+1, attempt)
return l.usageCaps.Preflight(ctx, usagecaps.Request{
TenantID: tenantID,
AgentID: l.agentUUID,
ProviderName: providerName,
ModelID: model,
ReservationKey: key,
Messages: chatReq.Messages,
MaxOutputTokens: l.maxOutputTokensFromRequest(chatReq),
})
}
func supportsPromptCacheParams(provider providers.Provider) bool {
switch provider.(type) {
case *providers.CodexProvider, *providers.ChatGPTOAuthRouter:
return true
default:
return false
}
}
func setDefaultPromptCacheOptions(opts map[string]any, tenantID, agentID uuid.UUID, providerName, sessionKey string) {
if opts == nil {
return
}
if _, ok := opts[providers.OptPromptCacheKey]; !ok {
opts[providers.OptPromptCacheKey] = defaultPromptCacheKey(tenantID, agentID, providerName, sessionKey)
}
if _, ok := opts[providers.OptPromptCacheRetention]; !ok {
opts[providers.OptPromptCacheRetention] = "24h"
}
}
func defaultPromptCacheKey(tenantID, agentID uuid.UUID, providerName, sessionKey string) string {
h := sha256.Sum256([]byte(strings.Join([]string{
tenantID.String(),
agentID.String(),
providerName,
sessionKey,
}, "\x00")))
return "goclaw/" + hex.EncodeToString(h[:16])
}