mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 08:11:23 +00:00
21b6c454ca
- Enable merge UI for linking channel contacts to tenant_users - Contact → tenant_user resolution with cached lookup (60s TTL) - MCP per-user credentials via user-keyed connection pool - Secure CLI per-user credentials with AES-256-GCM encryption - Unified UserPickerCombobox searching contacts + tenant_users - Group contact collection with chat title in all channels - Group permission inheritance via wildcard user_id="*" - Fix heartbeat using wrong userID in group chats - Filter internal senders from contact collection - Add contact_type column (user/group) to channel_contacts - SQLite schema v2 migration for desktop edition
210 lines
7.2 KiB
Go
210 lines
7.2 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"log/slog"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// isUserFilePopulated checks if USER.md has been filled with actual user data
|
|
// beyond the blank template. The template has "- **Name:**\n" with no value.
|
|
func isUserFilePopulated(content string) bool {
|
|
trimmed := strings.TrimSpace(content)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
// Template markers: "**Name:**" followed by newline (no value) or just whitespace
|
|
for line := range strings.SplitSeq(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "- **Name:**" || line == "**Name:**" {
|
|
return false // name field still empty
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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,
|
|
Path: mr.Path,
|
|
})
|
|
}
|
|
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.",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md.
|
|
// If USER.md is still the blank template, inject a reminder so the agent fills it.
|
|
// Must run BEFORE session flush so the nudge message is persisted to history.
|
|
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)
|
|
// Check if USER.md is still the blank template — nudge agent to fill it
|
|
if l.contextFileLoader != nil {
|
|
files := l.contextFileLoader(ctx, l.agentUUID, req.UserID, l.agentType)
|
|
for _, f := range files {
|
|
if f.Path == bootstrap.UserFile && !isUserFilePopulated(f.Content) {
|
|
rs.pendingMsgs = append(rs.pendingMsgs, providers.Message{
|
|
Role: "user",
|
|
Content: "[System] You completed onboarding but USER.md is still empty. Please update USER.md with the user's name and details from this conversation using write_file.",
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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,
|
|
LoopKilled: rs.loopKilled,
|
|
}
|
|
}
|