mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 06:15:25 +00:00
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:
1 parent
ebef1951bf
commit
379da91a68
2 files changed
+175
-169
No files matched your search
+2
-169
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user