mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 19:04:49 +00:00
9dee3b3d26
* fix(agent): use agentUUID instead of agent key in DomainEvent for episodic worker DomainEvent.AgentID was set to l.id (agent_key string like "goctech-leader") instead of l.agentUUID.String() (UUID). This caused episodic worker to fail with "invalid input syntax for type uuid" on every session.completed event, breaking the entire 3-tier memory consolidation pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(consolidation): parse tenant/agent IDs safely in episodic worker Replace uuid.MustParse with uuid.Parse so a malformed tenant_id or agent_id in a DomainEvent no longer panics the worker goroutine. Parse up front and return a descriptive error before touching the store, so bad IDs surface with context instead of leaking into a raw PG error. Add regression tests asserting Handle rejects non-UUID agent_id and tenant_id with clear errors and skips creation — guards against the l.id vs l.agentUUID mix-up fixed in the prior commit. --------- Co-authored-by: tuannt23065 <tuannt23065@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: viettranx <viettranx@gmail.com>
229 lines
7.9 KiB
Go
229 lines
7.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"log/slog"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"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)
|
|
|
|
// V3: emit session.completed for consolidation pipeline (episodic → semantic → dreaming)
|
|
if l.domainBus != nil {
|
|
l.domainBus.Publish(eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
TenantID: l.tenantID.String(),
|
|
AgentID: l.agentUUID.String(),
|
|
UserID: req.UserID,
|
|
SourceID: req.SessionKey,
|
|
Payload: &eventbus.SessionCompletedPayload{
|
|
SessionKey: req.SessionKey,
|
|
MessageCount: len(history) + len(rs.pendingMsgs),
|
|
TokensUsed: rs.totalUsage.PromptTokens + rs.totalUsage.CompletionTokens,
|
|
CompactionCount: l.sessions.GetCompactionCount(ctx, req.SessionKey),
|
|
},
|
|
})
|
|
}
|
|
|
|
return &RunResult{
|
|
Content: rs.finalContent,
|
|
Thinking: rs.finalThinking,
|
|
RunID: req.RunID,
|
|
Iterations: rs.iteration,
|
|
Usage: &rs.totalUsage,
|
|
Media: rs.mediaResults,
|
|
Deliverables: rs.deliverables,
|
|
BlockReplies: rs.blockReplies,
|
|
LastBlockReply: rs.lastBlockReply,
|
|
LoopKilled: rs.loopKilled,
|
|
}
|
|
}
|