Files
Justin Truong d13cc0804a fix(providers): carry tool name on tool results for Gemini
Gemini drops tool_call_id and pairs functionCall/functionResponse by
function name, so its OpenAI-compat shim requires a non-empty
FunctionResponse.name. The name was only recoverable through a reverse
id->name lookup over assistant tool_calls still present in the request
window, which fails after pruning, truncation or tool_call collapse and
produces HTTP 400 "Name cannot be empty" on any follow-up iteration.

Add Message.ToolName, set it at every tool-result creation site and
preserve it through context pruning, then prefer it when serializing.
Fall back to the existing index for history persisted before the field,
so old sessions keep working without a reset.

When neither source resolves a name, drop the unlabelled tool result
instead of emitting an empty one: an empty name is a guaranteed 400 and
a synthetic name would match no prior functionCall. Gated behind the
existing Gemini detection so other OpenAI-compat hosts, which pair by
tool_call_id, are unaffected.

Field is JSON-optional, so session history needs no migration and older
binaries ignore it on rollback.
2026-08-03 22:50:06 +07:00

287 lines
9.0 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Default memory flush prompts matching TS memory-flush.ts.
const (
DefaultMemoryFlushPrompt = "Pre-compaction memory flush. " +
"Append durable memories to memory/YYYY-MM-DD.md (create memory/ if needed). " +
"If the file already exists, APPEND only — do not overwrite existing entries. " +
"If nothing to store, reply with NO_REPLY."
DefaultMemoryFlushSystemPrompt = "Pre-compaction memory flush turn. " +
"The session is near auto-compaction; capture durable memories to disk. " +
"Append to memory/YYYY-MM-DD.md only. " +
"If the file already exists, append — do not overwrite. " +
"You may reply, but usually NO_REPLY is correct."
)
// MemoryFlushSettings holds resolved flush config with defaults applied.
type MemoryFlushSettings struct {
Enabled bool
Prompt string
SystemPrompt string
}
// ResolveMemoryFlushSettings resolves flush settings from config, applying defaults.
// Returns nil if disabled.
func ResolveMemoryFlushSettings(compaction *config.CompactionConfig) *MemoryFlushSettings {
if compaction == nil || compaction.MemoryFlush == nil {
// Default: enabled
return &MemoryFlushSettings{
Enabled: true,
Prompt: DefaultMemoryFlushPrompt,
SystemPrompt: DefaultMemoryFlushSystemPrompt,
}
}
mf := compaction.MemoryFlush
if mf.Enabled != nil && !*mf.Enabled {
return nil
}
settings := &MemoryFlushSettings{
Enabled: true,
Prompt: DefaultMemoryFlushPrompt,
SystemPrompt: DefaultMemoryFlushSystemPrompt,
}
if mf.Prompt != "" {
settings.Prompt = mf.Prompt
}
if mf.SystemPrompt != "" {
settings.SystemPrompt = mf.SystemPrompt
}
return settings
}
// buildMemoryFlushPromptConfig returns the SystemPromptConfig used by the
// memory flush turn. Extracted as a pure function so tests can assert the
// config shape (specifically, that AgentUUID is populated) without building
// a full Loop fixture.
func buildMemoryFlushPromptConfig(
agentID, agentUUID, model, workspace string,
toolNames []string,
hasMemory bool,
providerType string,
) SystemPromptConfig {
return SystemPromptConfig{
AgentID: agentID,
AgentUUID: agentUUID,
Model: model,
Workspace: workspace,
Mode: PromptMinimal,
ToolNames: toolNames,
HasMemory: hasMemory,
ProviderType: providerType,
}
}
// shouldRunMemoryFlush checks whether a memory flush should run before compaction.
// Flush always runs when compaction triggers (called inside maybeSummarize),
// gated only by enabled/memory checks and a dedup guard per compaction cycle.
func (l *Loop) shouldRunMemoryFlush(ctx context.Context, sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool {
if settings == nil || !settings.Enabled || !l.hasMemory {
return false
}
if totalTokens <= 0 {
return false
}
// Deduplication: skip if already flushed in this compaction cycle.
compactionCount := l.sessions.GetCompactionCount(ctx, sessionKey)
lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(ctx, sessionKey)
if lastFlushAt >= 0 && lastFlushAt == compactionCount {
return false
}
return true
}
// runMemoryFlush executes a memory flush turn: sends flush prompt to LLM with tools
// so it can write memory files. Matching TS agent-runner-memory.ts.
func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *MemoryFlushSettings) {
slog.Info("memory flush: starting", "session", sessionKey)
flushCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
// Build messages: system prompt + history summary + flush prompt
history := l.sessions.GetHistory(ctx, sessionKey)
summary := l.sessions.GetSummary(ctx, sessionKey)
var messages []providers.Message
// Replace YYYY-MM-DD placeholder with today's date in flush prompts.
today := time.Now().Format("2006-01-02")
flushPrompt := strings.ReplaceAll(settings.Prompt, "YYYY-MM-DD", today)
flushSystemPrompt := strings.ReplaceAll(settings.SystemPrompt, "YYYY-MM-DD", today)
// System prompt: combine agent's normal system prompt context with flush system prompt.
// AgentUUID must stay in sync with loop_history.go's SystemPromptConfig —
// missing it here historically caused identity drift in downstream DomainEvents.
systemPrompt := BuildSystemPrompt(buildMemoryFlushPromptConfig(
l.id,
l.agentUUID.String(),
l.model,
l.workspace,
l.filteredToolNames(),
l.hasMemory,
providerTypeOf(l.provider),
))
systemPrompt += "\n\n" + flushSystemPrompt
messages = append(messages, providers.Message{
Role: "system",
Content: systemPrompt,
})
// Include conversation summary for context
if summary != "" {
messages = append(messages, providers.Message{
Role: "user",
Content: fmt.Sprintf("[Previous conversation summary]\n%s", summary),
})
messages = append(messages, providers.Message{
Role: "assistant",
Content: "Understood.",
})
}
// Include recent history (last 10 messages for context)
recentHistory := history
if len(recentHistory) > 10 {
recentHistory = recentHistory[len(recentHistory)-10:]
}
sanitized, _ := sanitizeHistory(recentHistory)
messages = append(messages, sanitized...)
// Flush prompt (with YYYY-MM-DD replaced by today's date)
messages = append(messages, providers.Message{
Role: "user",
Content: flushPrompt,
})
// Build tool list — only file tools needed for memory flush
var toolDefs []providers.ToolDefinition
if l.toolPolicy != nil {
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), nil, nil, false, false)
} else {
toolDefs = l.tools.ProviderDefs()
}
// Run LLM iteration loop (max 5 iterations for flush)
maxFlushIter := 5
for range maxFlushIter {
chatReq := providers.ChatRequest{
Messages: messages,
Tools: toolDefs,
Model: l.model,
Options: map[string]any{
"max_tokens": 4096,
"temperature": 0.3,
},
}
resp, err := l.callInternalLLMWithUsage(flushCtx, chatReq, "memory-flush")
if err != nil {
slog.Warn("memory flush: LLM call failed", "error", err)
l.extractiveMemoryFallback(flushCtx, sessionKey, history, "LLM error")
break
}
// No tool calls → done
if len(resp.ToolCalls) == 0 {
content := SanitizeAssistantContent(resp.Content)
if IsSilentReply(content) {
slog.Info("memory flush: NO_REPLY, trying extractive fallback")
l.extractiveMemoryFallback(flushCtx, sessionKey, history, "NO_REPLY")
} else if content != "" {
slog.Info("memory flush: completed with response", "content_len", len(content))
}
break
}
// Process tool calls
assistantMsg := providers.Message{
Role: "assistant",
Content: resp.Content,
ToolCalls: resp.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range resp.ToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
slog.Info("memory flush: tool call", "tool", tc.Name, "args_len", len(argsJSON))
result := l.tools.ExecuteWithContext(flushCtx, tc.Name, tc.Arguments, "", "", "", sessionKey, nil)
messages = append(messages, providers.Message{
Role: "tool",
Content: result.ForLLM,
ToolCallID: tc.ID,
ToolName: tc.Name,
})
}
}
// Mark flush as done
l.sessions.SetMemoryFlushDone(ctx, sessionKey)
l.sessions.Save(ctx, sessionKey)
slog.Info("memory flush: completed", "session", sessionKey)
}
// extractiveMemoryFallback runs the regex-based extraction on conversation history
// and writes the result directly to the memory store when the LLM flush produced no output.
func (l *Loop) extractiveMemoryFallback(ctx context.Context, sessionKey string, history []providers.Message, reason string) {
if l.memStore == nil {
return
}
// Limit input to last 20 messages to avoid regex over unbounded text
if len(history) > 20 {
history = history[len(history)-20:]
}
extracted := ExtractiveMemoryFallback(history)
if extracted == "" {
slog.Info("memory flush: extractive fallback produced no content", "session", sessionKey, "reason", reason)
return
}
agentID := l.agentUUID.String()
userID := store.MemoryUserID(ctx)
docPath := fmt.Sprintf("memory/%s-auto-extract.md", time.Now().Format("2006-01-02"))
// Append to existing document if it exists
existing, err := l.memStore.GetDocument(ctx, agentID, userID, docPath)
if err == nil && existing != "" {
extracted = existing + "\n\n---\n\n" + extracted
}
if err := l.memStore.PutDocument(ctx, agentID, userID, docPath, extracted); err != nil {
slog.Warn("memory flush: extractive fallback write failed", "session", sessionKey, "error", err)
return
}
if err := l.memStore.IndexDocument(ctx, agentID, userID, docPath); err != nil {
slog.Warn("memory flush: extractive fallback index failed", "session", sessionKey, "error", err)
// Non-fatal: document was saved
}
slog.Info("memory flush: extractive fallback saved", "session", sessionKey, "reason", reason, "path", docPath, "content_len", len(extracted))
}