mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 12:13:51 +00:00
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.
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
|
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// runViaPipeline delegates a run to the v3 pipeline.
|
|
func (l *Loop) runViaPipeline(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|
input := convertRunInput(&req)
|
|
// Bridge runState shares loop detection state between pipeline and agent.
|
|
bridgeRS := &runState{}
|
|
|
|
// Resolve the effective model + provider BEFORE building deps so the pre-call
|
|
// budget estimate reserves reasoning output for the model that will actually
|
|
// run (a ModelOverride can change the reasoning capability, hence the bump).
|
|
model := l.model
|
|
if req.ModelOverride != "" {
|
|
model = req.ModelOverride
|
|
}
|
|
provider := l.provider
|
|
if req.ProviderOverride != nil {
|
|
provider = req.ProviderOverride
|
|
} else if req.ModelOverride != "" {
|
|
if fallback, ok := provider.(interface{ PrimaryProvider() providers.Provider }); ok {
|
|
provider = fallback.PrimaryProvider()
|
|
}
|
|
}
|
|
|
|
deps := l.buildPipelineDeps(&req, bridgeRS)
|
|
|
|
p := pipeline.NewDefaultPipeline(deps)
|
|
state := pipeline.NewRunState(input, nil, model, provider)
|
|
|
|
pResult, err := p.Run(ctx, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return redactDelegationRunResult(&req, convertRunResult(pResult)), nil
|
|
}
|
|
|
|
// buildPipelineDeps maps Loop fields + methods to PipelineDeps callbacks.
|
|
// effProvider/effModel are the resolved provider+model for THIS run (after any
|
|
// ModelOverride/ProviderOverride) so reasoning-effort resolution matches the
|
|
// request the pipeline will actually send.
|
|
func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.PipelineDeps {
|
|
maxIter := l.maxIterations
|
|
if req.MaxIterations > 0 && req.MaxIterations < maxIter {
|
|
maxIter = req.MaxIterations
|
|
}
|
|
|
|
cb := l.pipelineCallbacks(req, bridgeRS)
|
|
emitBlockReply := func(content, source string) {
|
|
sanitized := SanitizeAssistantContent(content)
|
|
if sanitized == "" || IsSilentReply(sanitized) {
|
|
return
|
|
}
|
|
payload := map[string]string{"content": sanitized}
|
|
if source != "" {
|
|
payload["source"] = source
|
|
}
|
|
cb.emitRun(AgentEvent{
|
|
Type: protocol.AgentEventBlockReply,
|
|
AgentID: l.id,
|
|
RunID: req.RunID,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|
|
return pipeline.PipelineDeps{
|
|
TokenCounter: tokencount.NewTiktokenCounter(),
|
|
BudgetCounter: l.budgetCounter,
|
|
EventBus: l.domainBus,
|
|
Hooks: l.hookDispatcher,
|
|
Config: pipeline.PipelineConfig{
|
|
MaxIterations: maxIter,
|
|
MaxToolCalls: l.maxToolCalls,
|
|
CheckpointInterval: 5,
|
|
ContextWindow: l.contextWindow,
|
|
MaxTokens: l.effectiveMaxTokens(),
|
|
ReserveTokens: l.resolveReserveTokens(),
|
|
Compaction: l.compactionCfg,
|
|
// V3 memory/retrieval flags removed — always true at runtime.
|
|
},
|
|
ResolveContextWindow: l.resolveEffectiveContextWindow,
|
|
EmitEvent: func(event any) {
|
|
if ae, ok := event.(AgentEvent); ok {
|
|
l.emit(redactDelegationAgentEvent(req, ae))
|
|
}
|
|
},
|
|
|
|
// V3 auto-inject: episodic memory L0 injection into system prompt.
|
|
// Captures agent/tenant context via closure for store scoping.
|
|
AutoInject: l.makeAutoInjectCallback(req),
|
|
|
|
// Context injection + session history
|
|
InjectContext: cb.injectContext,
|
|
LoadSessionHistory: cb.loadSessionHistory,
|
|
|
|
// Context callbacks
|
|
ResolveWorkspace: cb.resolveWorkspace,
|
|
LoadContextFiles: cb.loadContextFiles,
|
|
BuildMessages: cb.buildMessages,
|
|
EnrichMedia: cb.enrichMedia,
|
|
InjectReminders: cb.injectReminders,
|
|
|
|
// Think callbacks
|
|
BuildFilteredTools: cb.buildFilteredTools,
|
|
CallLLM: cb.callLLM,
|
|
UniqueToolCallIDs: uniquifyToolCallIDs,
|
|
EmitBlockReply: func(content string) {
|
|
emitBlockReply(content, "")
|
|
},
|
|
EmitBlockReplyWithSource: emitBlockReply,
|
|
|
|
// Prune callbacks
|
|
PruneMessages: cb.pruneMessages,
|
|
SanitizeHistory: cb.sanitizeHistory,
|
|
CompactMessages: cb.compactMessages,
|
|
|
|
// Cache-TTL gate callbacks (Phase 06)
|
|
GetProviderCaps: func() providers.ProviderCapabilities {
|
|
if ca, ok := l.provider.(providers.CapabilitiesAware); ok {
|
|
return ca.Capabilities()
|
|
}
|
|
return providers.ProviderCapabilities{}
|
|
},
|
|
GetPruningConfig: func() *config.ContextPruningConfig {
|
|
return l.contextPruningCfg
|
|
},
|
|
GetCacheTouch: l.cacheTouchAt,
|
|
MarkCacheTouched: l.markCacheTouched,
|
|
|
|
// Memory flush
|
|
RunMemoryFlush: cb.runMemoryFlush,
|
|
|
|
// Tool callbacks
|
|
ExecuteToolCall: cb.executeToolCall,
|
|
ExecuteToolRaw: cb.executeToolRaw,
|
|
ProcessToolResult: cb.processToolResult,
|
|
AuthorizeToolCall: cb.authorizeToolCall,
|
|
SequentialToolCall: func(tc providers.ToolCall) bool {
|
|
return l.resolveToolCallName(tc.Name) == "wait"
|
|
},
|
|
ParallelEligibleToolCall: l.parallelEligibleToolCall,
|
|
CheckReadOnly: cb.checkReadOnly,
|
|
|
|
// Observe: drain InjectCh
|
|
DrainInjectCh: func() []providers.Message {
|
|
if req.InjectCh == nil {
|
|
return nil
|
|
}
|
|
var msgs []providers.Message
|
|
for {
|
|
select {
|
|
case injected := <-req.InjectCh:
|
|
msgs = append(msgs, providers.Message{
|
|
Role: "user",
|
|
Content: injected.Content,
|
|
})
|
|
default:
|
|
return msgs
|
|
}
|
|
}
|
|
},
|
|
|
|
// Checkpoint + Finalize
|
|
FlushMessages: cb.flushMessages,
|
|
PersistAssistantImages: persistAssistantImages,
|
|
SkillPostscript: l.makeSkillPostscript(),
|
|
SanitizeContent: cb.sanitizeContent,
|
|
StripMessageDirectives: StripMessageDirectives,
|
|
DeduplicateMediaSuffix: deduplicateMediaSuffix,
|
|
IsSilentReply: IsSilentReply,
|
|
EmitSessionCompleted: func(ctx context.Context, sessionKey string, msgCount, tokensUsed, _ int) {
|
|
// The per-run count (5th arg) is intentionally ignored — emitSessionCompleted
|
|
// reads the CUMULATIVE session count itself (Bug A). See method doc.
|
|
l.emitSessionCompleted(ctx, sessionKey, req.UserID, msgCount, tokensUsed)
|
|
},
|
|
UpdateMetadata: cb.updateMetadata,
|
|
BootstrapCleanup: cb.bootstrapCleanup,
|
|
MaybeSummarize: cb.maybeSummarize,
|
|
}
|
|
}
|
|
|
|
// emitSessionCompleted publishes the session.completed domain event that drives
|
|
// the consolidation pipeline (episodic → semantic → dreaming). No-op when no bus.
|
|
//
|
|
// Bug A: it reads the CUMULATIVE session compaction count via GetCompactionCount
|
|
// (matching the legacy v2 emit path) rather than the per-run counter the pipeline
|
|
// tracks — the per-run counter resets to 0 each run, which pinned source_id at
|
|
// ":0" and made the episodic worker skip every cycle after the first.
|
|
//
|
|
// Bug C: SourceID embeds that count ("<sessionKey>:<count>") so the eventbus dedup
|
|
// key (Type+":"+SourceID, 5m TTL) advances per compaction cycle instead of
|
|
// swallowing every rapid same-session turn. Safe for the worker, which builds its
|
|
// own idempotency key from payload.SessionKey+payload.CompactionCount and never
|
|
// parses SourceID.
|
|
func (l *Loop) emitSessionCompleted(ctx context.Context, sessionKey, userID string, msgCount, tokensUsed int) {
|
|
if l.domainBus == nil {
|
|
return
|
|
}
|
|
count := l.sessions.GetCompactionCount(ctx, sessionKey)
|
|
// Attach the existing session summary (from a PREVIOUS compaction cycle) when
|
|
// one exists. The current cycle's summary is produced asynchronously and isn't
|
|
// ready yet, but a prior summary spares the worker an LLM re-summarize call.
|
|
var summary string
|
|
if count > 0 {
|
|
summary = l.sessions.GetSummary(ctx, sessionKey)
|
|
}
|
|
l.domainBus.Publish(eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
TenantID: l.tenantID.String(),
|
|
AgentID: l.agentUUID.String(),
|
|
UserID: userID,
|
|
SourceID: fmt.Sprintf("%s:%d", sessionKey, count),
|
|
Payload: &eventbus.SessionCompletedPayload{
|
|
SessionKey: sessionKey,
|
|
MessageCount: msgCount,
|
|
TokensUsed: tokensUsed,
|
|
CompactionCount: count,
|
|
Summary: summary,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (l *Loop) resolveEffectiveContextWindow() int {
|
|
return l.contextWindow
|
|
}
|
|
|
|
// convertRunInput converts agent.RunRequest to pipeline.RunInput.
|
|
func convertRunInput(req *RunRequest) *pipeline.RunInput {
|
|
return &pipeline.RunInput{
|
|
SessionKey: req.SessionKey,
|
|
Message: req.Message,
|
|
Media: req.Media,
|
|
ForwardMedia: req.ForwardMedia,
|
|
Channel: req.Channel,
|
|
ChannelType: req.ChannelType,
|
|
BitrixPortalDomain: req.BitrixPortalDomain,
|
|
ChatTitle: req.ChatTitle,
|
|
ChatID: req.ChatID,
|
|
PeerKind: req.PeerKind,
|
|
RunID: req.RunID,
|
|
UserID: req.UserID,
|
|
SenderID: req.SenderID,
|
|
SenderName: req.SenderName,
|
|
Stream: req.Stream,
|
|
ExtraSystemPrompt: req.ExtraSystemPrompt,
|
|
SkillFilter: req.SkillFilter,
|
|
HistoryLimit: req.HistoryLimit,
|
|
ToolAllow: req.ToolAllow,
|
|
TelegramManagerPermissions: req.TelegramManagerPermissions,
|
|
LightContext: req.LightContext,
|
|
RunKind: req.RunKind,
|
|
DelegationID: req.DelegationID,
|
|
TeamID: req.TeamID,
|
|
TeamTaskID: req.TeamTaskID,
|
|
ParentAgentID: req.ParentAgentID,
|
|
MaxIterations: req.MaxIterations,
|
|
ModelOverride: req.ModelOverride,
|
|
HideInput: req.HideInput,
|
|
ContentSuffix: req.ContentSuffix,
|
|
LeaderAgentID: req.LeaderAgentID,
|
|
WorkspaceChannel: req.WorkspaceChannel,
|
|
WorkspaceChatID: req.WorkspaceChatID,
|
|
TeamWorkspace: req.TeamWorkspace,
|
|
}
|
|
}
|
|
|
|
// convertRunResult converts pipeline.RunResult to agent.RunResult.
|
|
func convertRunResult(pr *pipeline.RunResult) *RunResult {
|
|
if pr == nil {
|
|
return nil
|
|
}
|
|
var lastUsage *providers.Usage
|
|
if pr.LastUsage.PromptTokens > 0 || pr.LastUsage.CompletionTokens > 0 || pr.LastUsage.TotalTokens > 0 {
|
|
lu := pr.LastUsage
|
|
lastUsage = &lu
|
|
}
|
|
media := make([]MediaResult, len(pr.MediaResults))
|
|
for i, m := range pr.MediaResults {
|
|
media[i] = MediaResult{
|
|
Path: m.Path,
|
|
ContentType: m.ContentType,
|
|
Size: m.Size,
|
|
AsVoice: m.AsVoice,
|
|
Prompt: m.Prompt,
|
|
}
|
|
}
|
|
return &RunResult{
|
|
Content: pr.Content,
|
|
Thinking: pr.Thinking,
|
|
RunID: pr.RunID,
|
|
Iterations: pr.Iterations,
|
|
Usage: &pr.TotalUsage,
|
|
LastUsage: lastUsage,
|
|
Media: media,
|
|
Deliverables: pr.Deliverables,
|
|
BlockReplies: pr.BlockReplies,
|
|
LastBlockReply: pr.LastBlockReply,
|
|
LoopKilled: pr.LoopKilled,
|
|
Calls: pr.Calls,
|
|
StopReason: pr.StopReason,
|
|
}
|
|
}
|
|
|
|
// makeAutoInjectCallback creates the AutoInject callback that captures agent/tenant context.
|
|
// Returns nil if autoInjector is not configured (v3 retrieval disabled or no episodic store).
|
|
// Phase 9: plumbs recentContext through to enrich vector search queries for
|
|
// context-aware recall.
|
|
func (l *Loop) makeAutoInjectCallback(req *RunRequest) func(ctx context.Context, userMessage, userID, recentContext string) (string, error) {
|
|
if l.autoInjector == nil {
|
|
return nil
|
|
}
|
|
return func(ctx context.Context, userMessage, userID, recentContext string) (string, error) {
|
|
result, err := l.autoInjector.Inject(ctx, memory.InjectParams{
|
|
AgentID: l.agentUUID.String(),
|
|
UserID: store.MemoryUserID(ctx),
|
|
TenantID: store.TenantIDFromContext(ctx).String(),
|
|
UserMessage: userMessage,
|
|
RecentContext: recentContext,
|
|
})
|
|
if err != nil || result == nil {
|
|
return "", err
|
|
}
|
|
return result.Section, nil
|
|
}
|
|
}
|