refactor(agent): extract post-loop finalization to loop_finalize.go

Move sanitization, media dedup, session flush, bootstrap cleanup, and
RunResult construction from runLoop() into dedicated finalizeRun() method.
Reduces loop.go from 1123 to 956 LOC (-167). No behavior change.
This commit is contained in:
viettranx committed 2026-03-25 08:20:10 +07:00
1 parent ebef1951bf
commit 379da91a68
2 files changed
+175 -169

No files matched your search

+2 -169
View File
@@ -5,8 +5,6 @@ import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"sort"
"strings"
@@ -931,173 +929,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
}
// 5. Full sanitization pipeline (matching TS extractAssistantText + sanitizeUserFacingText)
rs.finalContent = SanitizeAssistantContent(rs.finalContent)
// 4b. Config leak detection — disabled: too many false positives
// (e.g. agent explaining public architecture mentioning SOUL.md etc.)
// rs.finalContent = StripConfigLeak(rs.finalContent, l.agentType)
// 6. Handle NO_REPLY: save to session for context but mark as silent.
// Matching TS: NO_REPLY is saved (via resolveSilentReplyFallbackText) but
// filtered at the payload level before delivery.
isSilent := IsSilentReply(rs.finalContent)
// 5b. Skill evolution: postscript suggestion after complex tasks.
// Fires when skill_evolve=true AND the run involved enough tool calls to warrant a skill.
// Appended to the agent's own final response so the user sees it inline and can explicitly
// consent ("save as skill") before anything is created. No mid-loop injection, no async
// goroutine, no session contamination — the next user turn naturally triggers skill creation.
if l.skillEvolve && l.skillNudgeInterval > 0 &&
rs.totalToolCalls >= l.skillNudgeInterval &&
rs.finalContent != "" && !isSilent && !rs.skillPostscriptSent {
rs.skillPostscriptSent = true
locale := store.LocaleFromContext(ctx)
rs.finalContent += "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_"
}
// 7. Fallback for empty content
if rs.finalContent == "" {
if len(rs.asyncToolCalls) > 0 {
rs.finalContent = "..."
} else {
rs.finalContent = "..."
}
}
// Append content suffix (e.g. image markdown for WS) before saving to session.
if req.ContentSuffix != "" && !strings.Contains(rs.finalContent, req.ContentSuffix) {
rs.finalContent += req.ContentSuffix
}
// Collect forwarded media + dedup + populate sizes BEFORE saving to session,
// so we can attach output MediaRefs to the assistant message for history reload.
for _, mf := range req.ForwardMedia {
ct := mf.MimeType
if ct == "" {
ct = mimeFromExt(filepath.Ext(mf.Path))
}
rs.mediaResults = append(rs.mediaResults, MediaResult{Path: mf.Path, ContentType: ct})
}
rs.mediaResults = deduplicateMedia(rs.mediaResults)
for i := range rs.mediaResults {
if rs.mediaResults[i].Size == 0 {
if info, err := os.Stat(rs.mediaResults[i].Path); err == nil {
rs.mediaResults[i].Size = info.Size()
}
}
}
// Build final assistant message with output media refs for history persistence.
assistantMsg := providers.Message{
Role: "assistant",
Content: rs.finalContent,
Thinking: rs.finalThinking,
}
for _, mr := range rs.mediaResults {
kind := "document"
if strings.HasPrefix(mr.ContentType, "image/") {
kind = "image"
} else if strings.HasPrefix(mr.ContentType, "audio/") {
kind = "audio"
} else if strings.HasPrefix(mr.ContentType, "video/") {
kind = "video"
}
assistantMsg.MediaRefs = append(assistantMsg.MediaRefs, providers.MediaRef{
ID: filepath.Base(mr.Path),
MimeType: mr.ContentType,
Kind: kind,
})
}
rs.pendingMsgs = append(rs.pendingMsgs, assistantMsg)
// Bootstrap nudge: if model didn't call write_file on turn 2+, inject reminder
// into session history so the next turn sees it. Appended to rs.pendingMsgs so it's
// flushed in the single Save below (avoids double I/O).
// Note: the nudge counts as a "user" turn in history, which accelerates auto-cleanup
// by one turn — acceptable since bootstrap should complete in 2-3 turns anyway.
if hadBootstrap && l.bootstrapCleanup != nil {
nudgeUserTurns := 1
for _, m := range history {
if m.Role == "user" {
nudgeUserTurns++
}
}
if !rs.bootstrapWriteDetected && nudgeUserTurns >= 2 && nudgeUserTurns < bootstrapAutoCleanupTurns {
rs.pendingMsgs = append(rs.pendingMsgs, providers.Message{
Role: "user",
Content: "[System] You haven't completed onboarding yet. Please update USER.md with the user's details and clear BOOTSTRAP.md as instructed.",
})
}
}
// Flush all buffered messages to session atomically.
// This ensures concurrent runs never see each other's in-progress messages.
for _, msg := range rs.pendingMsgs {
l.sessions.AddMessage(ctx, req.SessionKey, msg)
}
// Persist adaptive tool timing to session metadata.
if serialized := toolTiming.Serialize(); serialized != "" {
l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized})
}
// Write session metadata (matching TS session entry updates)
l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(rs.totalUsage.PromptTokens), int64(rs.totalUsage.CompletionTokens))
// Calibrate token estimation: store actual prompt tokens + message count.
// Next time EstimateTokensWithCalibration() is called, it uses this as a base
// instead of the chars/3 heuristic (more accurate for multilingual content).
if rs.totalUsage.PromptTokens > 0 {
msgCount := len(history) + rs.checkpointFlushedMsgs + len(rs.pendingMsgs)
l.sessions.SetLastPromptTokens(ctx, req.SessionKey, rs.totalUsage.PromptTokens, msgCount)
}
l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md
// as a safety net in case the LLM didn't clear it itself.
// Bootstrap typically completes in 2-3 turns; we auto-cleanup after 3 user messages.
// Uses pre-run history (already loaded) + 1 for current message — no extra DB call.
if hadBootstrap && l.bootstrapCleanup != nil {
userTurns := 1 // current user message
for _, m := range history {
if m.Role == "user" {
userTurns++
}
}
if userTurns >= bootstrapAutoCleanupTurns {
if cleanErr := l.bootstrapCleanup(ctx, l.agentUUID, req.UserID); cleanErr != nil {
slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID)
} else {
slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns)
}
}
}
// 8. Metadata Stripping: Clean internal [[...]] tags for user-facing content
// (Session version is already saved in assistantMsg above)
rs.finalContent = StripMessageDirectives(rs.finalContent)
if isSilent {
slog.Info("agent loop: NO_REPLY detected, suppressing delivery",
"agent", l.id, "session", req.SessionKey)
rs.finalContent = ""
}
// 9. Maybe summarize
l.maybeSummarize(ctx, req.SessionKey)
return &RunResult{
Content: rs.finalContent,
RunID: req.RunID,
Iterations: rs.iteration,
Usage: &rs.totalUsage,
Media: rs.mediaResults,
Deliverables: rs.deliverables,
BlockReplies: rs.blockReplies,
LastBlockReply: rs.lastBlockReply,
}, nil
// 5-9. Finalize: sanitize, media dedup, session flush, bootstrap cleanup, build result.
return l.finalizeRun(ctx, rs, &req, history, hadBootstrap, toolTiming), nil
}
// resolveToolCallName strips the configured tool call prefix from a name
+173
View File
@@ -0,0 +1,173 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// finalizeRun performs post-loop processing: sanitization, media dedup, session flush,
// bootstrap cleanup, and builds the final RunResult.
func (l *Loop) finalizeRun(
ctx context.Context,
rs *runState,
req *RunRequest,
history []providers.Message,
hadBootstrap bool,
toolTiming ToolTimingMap,
) *RunResult {
// 5. Full sanitization pipeline (matching TS extractAssistantText + sanitizeUserFacingText)
rs.finalContent = SanitizeAssistantContent(rs.finalContent)
// 6. Handle NO_REPLY: save to session for context but mark as silent.
isSilent := IsSilentReply(rs.finalContent)
// 5b. Skill evolution: postscript suggestion after complex tasks.
if l.skillEvolve && l.skillNudgeInterval > 0 &&
rs.totalToolCalls >= l.skillNudgeInterval &&
rs.finalContent != "" && !isSilent && !rs.skillPostscriptSent {
rs.skillPostscriptSent = true
locale := store.LocaleFromContext(ctx)
rs.finalContent += "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_"
}
// 7. Fallback for empty content
if rs.finalContent == "" {
if len(rs.asyncToolCalls) > 0 {
rs.finalContent = "..."
} else {
rs.finalContent = "..."
}
}
// Append content suffix (e.g. image markdown for WS) before saving to session.
if req.ContentSuffix != "" && !strings.Contains(rs.finalContent, req.ContentSuffix) {
rs.finalContent += req.ContentSuffix
}
// Collect forwarded media + dedup + populate sizes BEFORE saving to session,
// so we can attach output MediaRefs to the assistant message for history reload.
for _, mf := range req.ForwardMedia {
ct := mf.MimeType
if ct == "" {
ct = mimeFromExt(filepath.Ext(mf.Path))
}
rs.mediaResults = append(rs.mediaResults, MediaResult{Path: mf.Path, ContentType: ct})
}
rs.mediaResults = deduplicateMedia(rs.mediaResults)
for i := range rs.mediaResults {
if rs.mediaResults[i].Size == 0 {
if info, err := os.Stat(rs.mediaResults[i].Path); err == nil {
rs.mediaResults[i].Size = info.Size()
}
}
}
// Build final assistant message with output media refs for history persistence.
assistantMsg := providers.Message{
Role: "assistant",
Content: rs.finalContent,
Thinking: rs.finalThinking,
}
for _, mr := range rs.mediaResults {
kind := "document"
if strings.HasPrefix(mr.ContentType, "image/") {
kind = "image"
} else if strings.HasPrefix(mr.ContentType, "audio/") {
kind = "audio"
} else if strings.HasPrefix(mr.ContentType, "video/") {
kind = "video"
}
assistantMsg.MediaRefs = append(assistantMsg.MediaRefs, providers.MediaRef{
ID: filepath.Base(mr.Path),
MimeType: mr.ContentType,
Kind: kind,
})
}
rs.pendingMsgs = append(rs.pendingMsgs, assistantMsg)
// Bootstrap nudge: if model didn't call write_file on turn 2+, inject reminder
// into session history so the next turn sees it.
if hadBootstrap && l.bootstrapCleanup != nil {
nudgeUserTurns := 1
for _, m := range history {
if m.Role == "user" {
nudgeUserTurns++
}
}
if !rs.bootstrapWriteDetected && nudgeUserTurns >= 2 && nudgeUserTurns < bootstrapAutoCleanupTurns {
rs.pendingMsgs = append(rs.pendingMsgs, providers.Message{
Role: "user",
Content: "[System] You haven't completed onboarding yet. Please update USER.md with the user's details and clear BOOTSTRAP.md as instructed.",
})
}
}
// Flush all buffered messages to session atomically.
for _, msg := range rs.pendingMsgs {
l.sessions.AddMessage(ctx, req.SessionKey, msg)
}
// Persist adaptive tool timing to session metadata.
if serialized := toolTiming.Serialize(); serialized != "" {
l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized})
}
// Write session metadata (matching TS session entry updates)
l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(rs.totalUsage.PromptTokens), int64(rs.totalUsage.CompletionTokens))
// Calibrate token estimation: store actual prompt tokens + message count.
if rs.totalUsage.PromptTokens > 0 {
msgCount := len(history) + rs.checkpointFlushedMsgs + len(rs.pendingMsgs)
l.sessions.SetLastPromptTokens(ctx, req.SessionKey, rs.totalUsage.PromptTokens, msgCount)
}
l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md.
if hadBootstrap && l.bootstrapCleanup != nil {
userTurns := 1 // current user message
for _, m := range history {
if m.Role == "user" {
userTurns++
}
}
if userTurns >= bootstrapAutoCleanupTurns {
if cleanErr := l.bootstrapCleanup(ctx, l.agentUUID, req.UserID); cleanErr != nil {
slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID)
} else {
slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns)
}
}
}
// 8. Metadata Stripping: Clean internal [[...]] tags for user-facing content
rs.finalContent = StripMessageDirectives(rs.finalContent)
if isSilent {
slog.Info("agent loop: NO_REPLY detected, suppressing delivery",
"agent", l.id, "session", req.SessionKey)
rs.finalContent = ""
}
// 9. Maybe summarize
l.maybeSummarize(ctx, req.SessionKey)
return &RunResult{
Content: rs.finalContent,
RunID: req.RunID,
Iterations: rs.iteration,
Usage: &rs.totalUsage,
Media: rs.mediaResults,
Deliverables: rs.deliverables,
BlockReplies: rs.blockReplies,
LastBlockReply: rs.lastBlockReply,
}
}