Files
goclaw/internal/agent/loop_finalize.go
T
viettranx 4a692d51d8 fix(files): heal legacy media URLs and deduplicate file references
SignFileURLs now heals legacy session data at delivery time:
- Dedup double /v1/files/v1/files/ prefix from old mutation bug
- Strip stale ?ft= from markdown link display text
- Re-sign all URLs with fresh tokens (never skip)

File serving: use workspace root for ft= auth fallback search
(no tenant context available with signed tokens).

Deduplicate ContentSuffix media by basename to prevent duplicate
file links when agent text already references the same file.
Frontend dedup as defense-in-depth for legacy data.
2026-03-25 12:37:49 +07:00

175 lines
5.7 KiB
Go

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.
// Dedup by basename: skip suffix lines whose file already appears in the agent's text.
if req.ContentSuffix != "" {
rs.finalContent += deduplicateMediaSuffix(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,
}
}