Files
gott/Services/ChatService.cs
T
Meleki 83945689a6 Remove tool_choice agent option - auto-detect based on workspace/skills/memory
- Remove tool_choice from AgentEndpoint (create, update, list responses)
- ChatService: automatically set tool_choice='any' when workspace, memory
  or skills present, null otherwise. Same for preparer/review/finalizer
- SystemPromptBuilder: remove 3 toolAny-conditional prompt blocks
  (KRITISCH, Verhalten, ERINNERUNG)
- Frontend: remove checkbox, badge and JS references
- Agent.ToolChoice DB field preserved for backward compat, no longer read
2026-03-09 05:39:09 +01:00

482 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Models;
namespace Gott.Services;
public class ChatService
{
private readonly DatabaseService _db;
private readonly ToolService _toolService;
private readonly MemoryService _memoryService;
private readonly SkillService _skillService;
public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService)
{
_db = db;
_toolService = toolService;
_memoryService = memoryService;
_skillService = skillService;
}
public static string ResolveModelId(string model)
{
return model switch
{
"sonnet" => "claude-sonnet-4-6",
"sonnet-4-5" => "claude-sonnet-4-5",
"sonnet-4-6" => "claude-sonnet-4-6",
"opus" => "claude-opus-4-6",
"opus-4-5" => "claude-opus-4-5",
"opus-4-6" => "claude-opus-4-6",
"haiku" => "claude-haiku-4-5",
"haiku-4-5" => "claude-haiku-4-5",
_ => "claude-sonnet-4-6"
};
}
/// <summary>
/// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc.
/// </summary>
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false)
{
var session = await _db.GetSession(sessionId);
if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId))
return null;
var agent = await _db.GetAgent(session.AgentId);
if (agent == null)
return null;
var modelId = ResolveModelId(agent.Model);
var markedMessages = await _db.GetMarkedMessages(sessionId);
var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId);
var agentSkills = await _db.GetAgentSkills(agent.AgentId);
var user = userId != null ? await _db.GetUser(userId) : null;
var workspace = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workspace) && agentSkills.Any())
workspace = "/tmp";
var systemPromptText = SystemPromptBuilder.Build(
agent, session, markedMessages, _memoryService, agentMemoryDir,
agentSkills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: workspace, planMode: planMode);
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
var hasTools = hasWorkspace || includeMemory;
var toolChoice = hasTools ? "any" : null;
var toolDefs = hasTools
? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice)
: Array.Empty<object>();
var toolNames = toolDefs.Length > 0
? JsonSerializer.Serialize(toolDefs.Select(td =>
{
var json = JsonSerializer.Serialize(td);
var el = JsonSerializer.Deserialize<JsonElement>(json);
return el.GetProperty("name").GetString();
}).ToArray())
: null;
var chainId = Guid.NewGuid().ToString("N");
await _db.CreateChain(new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
AgentId = agent.AgentId,
Model = modelId,
SystemPrompt = systemPromptText,
ToolChoice = toolChoice,
ToolNames = toolNames,
PlanMode = planMode
});
await _db.UpdateSessionTimestamp(sessionId);
if (!string.IsNullOrEmpty(workspace))
Directory.CreateDirectory(workspace);
var systemPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(systemPromptText))
systemPrompt.Add(new { type = "text", text = systemPromptText });
// Preparer-Agent vorbereiten (falls konfiguriert)
PreparerContext preparerContext = null;
if (!string.IsNullOrEmpty(session.PreparerAgentId))
{
var preparerAgent = await _db.GetAgent(session.PreparerAgentId);
if (preparerAgent != null)
{
var preparerModelId = ResolveModelId(preparerAgent.Model);
var preparerMarked = await _db.GetMarkedMessages(sessionId);
var preparerMemDir = _memoryService.GetAgentMemoryDir(preparerAgent.AgentId);
var preparerSkills = await _db.GetAgentSkills(preparerAgent.AgentId);
var preparerWorkspace = workspace;
var preparerSystemText = SystemPromptBuilder.Build(
preparerAgent, session, preparerMarked, _memoryService, preparerMemDir,
preparerSkills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: preparerWorkspace);
var preparerPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(preparerSystemText))
preparerPrompt.Add(new { type = "text", text = preparerSystemText });
// Preparer-Instruktion: erklärt Vorbereiter-Mechanismus
preparerPrompt.Add(new { type = "text", text = @"## KRITISCH: Deine Rolle als Vorbereiter-Agent
Du bist ein Vorbereiter-Agent. Deine Aufgabe: Die Anfrage des Users aufbereiten, deine Fähigkeiten nutzen, und das Ergebnis an den Session-Agent weiterleiten.
### Standardverhalten: Automatische Weiterleitung
Deine Antwort wird AUTOMATISCH an den Session-Agent weitergeleitet. Du musst nichts Besonderes tun – antworte einfach mit dem aufbereiteten Ergebnis. Optional kannst du `[FORWARD]` an den Anfang schreiben, aber es ist nicht nötig.
### Ausnahme: Rückfrage an den User
NUR wenn du eine echte Rückfrage hast (Anfrage ist unklar/mehrdeutig), schreibe `[QUESTION]` an den Anfang deiner Antwort. Dann wird deine Antwort dem User als Rückfrage angezeigt und NICHT an den Session-Agent weitergeleitet.
### Nutze deine Fähigkeiten aktiv!
Du hast eigene Tools. Nutze sie! Erledige was du kannst (Dateien lesen, Daten aufbereiten, Strukturen erstellen, recherchieren). Der Session-Agent soll ein möglichst gut vorbereitetes Paket bekommen.
### Beispiele:
Weiterleitung (wird automatisch an den Session-Agent übergeben):
```
Der User möchte Produktdaten als JSON. Ich habe es bereits erstellt:
{""products"": [{""id"": 1, ""name"": ""Widget""}]}
Bitte speichere es in products.json.
```
Rückfrage (NUR mit [QUESTION] – stoppt die Weiterleitung):
```
[QUESTION]
Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
```" });
var preparerHasTools = !string.IsNullOrEmpty(preparerWorkspace) || preparerAgent.UseMemory;
preparerContext = new PreparerContext
{
ModelId = preparerModelId,
SystemPrompt = preparerPrompt,
AgentId = preparerAgent.AgentId,
Workspace = preparerWorkspace,
UseMemory = preparerAgent.UseMemory,
WebSearchVersion = preparerAgent.WebSearchVersion,
ToolChoice = preparerHasTools ? "any" : "auto",
AllowUserChoice = preparerAgent.AllowUserChoice,
Agent = preparerAgent
};
}
}
// Review-Agent vorbereiten (falls konfiguriert)
ReviewContext reviewContext = null;
if (!string.IsNullOrEmpty(session.ReviewAgentId))
{
var reviewAgent = await _db.GetAgent(session.ReviewAgentId);
if (reviewAgent != null)
{
var reviewModelId = ResolveModelId(reviewAgent.Model);
var reviewMarked = await _db.GetMarkedMessages(sessionId);
var reviewMemDir = _memoryService.GetAgentMemoryDir(reviewAgent.AgentId);
var reviewSkills = await _db.GetAgentSkills(reviewAgent.AgentId);
var reviewWorkspace = workspace; // gleicher Workspace wie Worker
var reviewSystemText = SystemPromptBuilder.Build(
reviewAgent, session, reviewMarked, _memoryService, reviewMemDir,
reviewSkills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: reviewWorkspace);
var reviewPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(reviewSystemText))
reviewPrompt.Add(new { type = "text", text = reviewSystemText });
var reviewHasTools = !string.IsNullOrEmpty(reviewWorkspace) || reviewAgent.UseMemory;
reviewContext = new ReviewContext
{
ModelId = reviewModelId,
SystemPrompt = reviewPrompt,
AgentId = reviewAgent.AgentId,
Workspace = reviewWorkspace,
UseMemory = reviewAgent.UseMemory,
WebSearchVersion = reviewAgent.WebSearchVersion,
ToolChoice = reviewHasTools ? "any" : "auto",
MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3,
Agent = reviewAgent
};
}
}
// Finalizer-Agent vorbereiten (falls konfiguriert)
FinalizerContext finalizerContext = null;
if (!string.IsNullOrEmpty(session.FinalizerAgentId))
{
var finalizerAgent = await _db.GetAgent(session.FinalizerAgentId);
if (finalizerAgent != null)
{
var finalizerModelId = ResolveModelId(finalizerAgent.Model);
var finalizerMarked = await _db.GetMarkedMessages(sessionId);
var finalizerMemDir = _memoryService.GetAgentMemoryDir(finalizerAgent.AgentId);
var finalizerSkills = await _db.GetAgentSkills(finalizerAgent.AgentId);
var finalizerWorkspace = workspace;
var finalizerSystemText = SystemPromptBuilder.Build(
finalizerAgent, session, finalizerMarked, _memoryService, finalizerMemDir,
finalizerSkills, _skillService, userPersona: user?.GetEffectivePersona(),
resolvedWorkspace: finalizerWorkspace);
var finalizerPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(finalizerSystemText))
finalizerPrompt.Add(new { type = "text", text = finalizerSystemText });
var finalizerHasTools = !string.IsNullOrEmpty(finalizerWorkspace) || finalizerAgent.UseMemory;
finalizerContext = new FinalizerContext
{
ModelId = finalizerModelId,
SystemPrompt = finalizerPrompt,
AgentId = finalizerAgent.AgentId,
Workspace = finalizerWorkspace,
UseMemory = finalizerAgent.UseMemory,
WebSearchVersion = finalizerAgent.WebSearchVersion,
ToolChoice = finalizerHasTools ? "any" : "auto",
Agent = finalizerAgent
};
}
}
return new ChatContext
{
ChainId = chainId,
SessionId = sessionId,
ModelId = modelId,
Workspace = workspace,
AgentId = agent.AgentId,
SystemPromptText = systemPromptText,
SystemPrompt = systemPrompt,
ToolChoice = toolChoice,
UseMemory = agent.UseMemory,
WebSearchVersion = agent.WebSearchVersion,
MaxIterations = maxIterations,
Agent = agent,
Session = session,
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
PlanMode = planMode
};
}
/// <summary>
/// Findet die Chain-ID die einen bestimmten tool_use-Call enthält.
/// Wird genutzt um bei ask_user_choice das tool_result in die richtige Chain zu speichern.
/// </summary>
public async Task<string> FindChainWithToolCall(string sessionId, string toolCallId)
{
var chains = (await _db.GetActiveChains(sessionId)).ToList();
foreach (var chain in chains)
{
var messages = await _db.GetChainMessages(chain.ChainId);
foreach (var msg in messages)
{
if (msg.Role == "assistant" && msg.Type == "tool_use" && msg.ToolCallId == toolCallId)
return chain.ChainId;
}
}
return null;
}
/// <summary>
/// Baut die Message-History aus der DB (aktive Chains).
/// Optimiert: Nur Text-Messages (user/assistant) für ältere Chains,
/// Tool-Calls nur für die aktuelle Chain, Attachments nur für die letzte User-Message.
/// </summary>
/// <summary>
/// Baut die Message-History aus der DB (aktive Chains).
/// includeTag: Wenn gesetzt, werden Messages mit diesem Tag NICHT ausgefiltert
/// (z.B. "preparer" damit der Preparer seine eigenen vorherigen Messages sieht).
/// </summary>
public async Task<List<object>> BuildHistoryFromDb(string sessionId, string includeTag = null)
{
var chainsList = (await _db.GetActiveChains(sessionId)).ToList();
// chronologisch sortieren (älteste zuerst)
chainsList.Reverse();
var history = new List<object>();
var isLastChain = false;
long? lastUserMsgId = null;
// Bestimme welche Chain als "vollständige letzte Chain" gilt.
// Wenn die letzte Chain leer oder nur tool_result enthält (z.B. nach ask_user_choice),
// dann muss die vorletzte Chain vollständig rekonstruiert werden.
int fullChainStartIndex = chainsList.Count - 1;
if (chainsList.Count >= 2)
{
var lastChainMsgs = (await _db.GetChainMessages(chainsList[chainsList.Count - 1].ChainId)).ToList();
if (lastChainMsgs.Count == 0 ||
(lastChainMsgs.All(m => m.Type == "tool_result") && lastChainMsgs.Count <= 2))
{
// Vorletzte Chain hatte einen ask_user_choice → beide Chains vollständig laden
fullChainStartIndex = chainsList.Count - 2;
}
}
for (int i = 0; i < chainsList.Count; i++)
{
var chain = chainsList[i];
isLastChain = (i == chainsList.Count - 1);
var msgs = (await _db.GetChainMessages(chain.ChainId))
.Where(m => string.IsNullOrEmpty(m.Tag) || m.Tag == "openclaw-import" || m.Tag == includeTag) // Sub-Agent-Messages ausfiltern; openclaw-import ist Herkunfts-Tag und wird immer durchgelassen
.ToList();
// Letzte User-Message-ID der letzten Chain merken (für Attachments)
if (isLastChain || i == fullChainStartIndex)
{
var lastUserMsg = msgs.LastOrDefault(m => m.Role == "user" && m.Type == "text");
if (lastUserMsg != null)
lastUserMsgId = lastUserMsg.Id;
}
// Für ältere Chains: nur Text-Messages (wie Frontend es vorher gemacht hat)
if (i < fullChainStartIndex)
{
foreach (var msg in msgs)
{
if (msg.Role == "user" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
else if (msg.Role == "assistant" && msg.Type == "text" && !string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
}
continue;
}
// Letzte Chain: vollständig mit Tool-Calls
var grouped = new Dictionary<int, List<MessageRecord>>();
foreach (var msg in msgs)
{
if (!grouped.ContainsKey(msg.Iteration))
grouped[msg.Iteration] = new List<MessageRecord>();
grouped[msg.Iteration].Add(msg);
}
foreach (var kvp in grouped.OrderBy(x => x.Key))
{
foreach (var msg in kvp.Value)
{
if (msg.Role == "user" && msg.Type == "text")
{
var content = new List<object>();
// Attachments nur für die letzte User-Message laden
if (msg.Id == lastUserMsgId)
{
var attachments = await _db.GetAttachments(msg.Id);
foreach (var att in attachments)
{
if (att.Type == "image")
{
content.Add(new
{
type = "image",
source = new { type = "base64", media_type = att.MediaType, data = att.Data }
});
}
else if (att.Type == "document")
{
content.Add(new
{
type = "document",
source = new { type = "base64", media_type = att.MediaType, data = att.Data }
});
}
}
}
if (!string.IsNullOrEmpty(msg.Content))
content.Add(new { type = "text", text = msg.Content });
if (content.Count > 0)
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = content });
}
else if (msg.Role == "assistant" && msg.Type == "text")
{
if (!string.IsNullOrEmpty(msg.Content))
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object> { new { type = "text", text = msg.Content } }
});
}
}
else if (msg.Role == "assistant" && msg.Type == "tool_use")
{
JsonElement inputEl;
try { inputEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null;
if (lastAssistant is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "assistant" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl });
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "assistant",
["content"] = new List<object>
{
new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }
}
});
}
}
else if (msg.Role == "user" && msg.Type == "tool_result")
{
var lastUser = history.Count > 0 ? history[history.Count - 1] : null;
if (lastUser is Dictionary<string, object> dict &&
dict.TryGetValue("role", out var role) && role.ToString() == "user" &&
dict.TryGetValue("content", out var existingContent) && existingContent is List<object> contentList)
{
contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = msg.Content ?? "", is_error = msg.IsError });
}
else
{
history.Add(new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object>
{
new { type = "tool_result", tool_use_id = msg.ToolCallId, content = msg.Content ?? "", is_error = msg.IsError }
}
});
}
}
}
}
}
return history;
}
}