Neue Aliase im Model-Resolver (fable-5-1 -> claude-fable-5-1, opus-5-5 -> claude-opus-5-5), passende Optionen im Agent-Dropdown und ARCHITECTURE.md nachgezogen. Rein additiv: bare "opus" zeigt weiterhin auf claude-opus-4-8, bare "fable" auf claude-fable-5; opus-4-8 bleibt Default-Option. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
963 lines
46 KiB
C#
963 lines
46 KiB
C#
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;
|
||
private readonly ServerSettingsService _settings;
|
||
|
||
public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService,
|
||
ServerSettingsService settings)
|
||
{
|
||
_db = db;
|
||
_toolService = toolService;
|
||
_memoryService = memoryService;
|
||
_skillService = skillService;
|
||
_settings = settings;
|
||
}
|
||
|
||
private int MaxHistory => _settings.GetInt("MaxHistory", 220);
|
||
private int ReminderInterval => _settings.GetInt("ReminderInterval", 30);
|
||
|
||
// Rollen-Klarstellung für Panel-Member (Fusion/MoA). Member laufen READ-ONLY (PlanMode bzw.
|
||
// tool-los). Ohne diesen Hinweis behält jeder Member seinen normalen Agent-Prompt und versucht
|
||
// oft Schreib-/Execute-/git-Aktionen, die gefiltert werden → er "beißt sich fest" in wiederholten,
|
||
// fehlschlagenden Tool-Versuchen. Der Hinweis lenkt ihn auf reine Analyse/Vorschlag; das tatsächliche
|
||
// Umsetzen (Schreiben, Ausführen, Committen) übernimmt der Synthese-Agent (Session-Worker).
|
||
private const string PanelMemberRolePrompt = @"## KRITISCH: Deine Rolle als Panel-Agent (Proposer)
|
||
|
||
Du bist einer von mehreren KI-Agenten, die PARALLEL und UNABHÄNGIG dieselbe Anfrage beantworten. Aus euren Antworten synthetisiert anschließend ein separater Agent EINE Gesamtantwort für den User.
|
||
|
||
### Du arbeitest READ-ONLY
|
||
Dir stehen NUR lesende Tools zur Verfügung (Dateien lesen, suchen, auflisten). Schreibende oder ausführende Aktionen – Dateien anlegen/ändern/löschen, Shell-/Build-Befehle, git/commit, Deployment – sind für dich DEAKTIVIERT und werden blockiert. Versuche sie GAR NICHT ERST: Du kommst damit nicht weiter, verschwendest nur Zeit und blockierst dich selbst in fehlschlagenden Tool-Versuchen.
|
||
|
||
### Deine Aufgabe
|
||
Liefere deinen besten VORSCHLAG als Text-Antwort: Analyse, Lösung, konkrete Code-Vorschläge, Begründung. Nutze die lesenden Tools, um dich am echten Stand zu orientieren – aber führe nichts aus und ändere nichts. Beschreibe stattdessen klar, WAS zu tun ist. Das tatsächliche Umsetzen übernimmt der Synthese-Agent.
|
||
|
||
### Wichtig
|
||
Nur deine finale Text-Antwort wird weiterverwendet. Formuliere sie vollständig und eigenständig, sodass sie auch ohne deine Tool-Schritte verständlich ist. Antworte zügig und direkt – kein Schreib-/Commit-Versuch.";
|
||
|
||
public static string ResolveModelId(string model)
|
||
{
|
||
// OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model")
|
||
if (OpenAiService.IsOpenAiProvider(model)) return model;
|
||
|
||
// z.ai / GLM laufen über den Anthropic-kompatiblen Claude-CLI-Pfad (NICHT über den
|
||
// OpenAI-Provider). Prefix strippen → die rohe Modell-ID (z.B. "glm-4.6") geht an --model,
|
||
// und das Routing erkennt Provider="" → CLI. Die z.ai-Env-Vars setzt ClaudeCliService.
|
||
if (!string.IsNullOrEmpty(model) && model.StartsWith("glm:", System.StringComparison.OrdinalIgnoreCase))
|
||
return model.Substring(4);
|
||
|
||
// Defensiv-Strip: alte "[1m]"-Suffixe aus DB transparent normalisieren.
|
||
// 1M-Context ist seit März 2026 Default bei Opus 4.6/4.7/4.8 + Sonnet 4.6 – kein Suffix mehr nötig.
|
||
if (!string.IsNullOrEmpty(model) && model.Contains("[1m]"))
|
||
model = model.Replace("[1m]", "");
|
||
|
||
return model switch
|
||
{
|
||
"sonnet" => "claude-sonnet-4-6",
|
||
"sonnet-4-5" => "claude-sonnet-4-5",
|
||
"sonnet-4-6" => "claude-sonnet-4-6",
|
||
"sonnet-5" => "claude-sonnet-5",
|
||
"opus" => "claude-opus-4-8",
|
||
"opus-3" => "claude-opus-3",
|
||
"opus-4-5" => "claude-opus-4-5",
|
||
"opus-4-6" => "claude-opus-4-6",
|
||
"opus-4-7" => "claude-opus-4-7",
|
||
"opus-4-8" => "claude-opus-4-8",
|
||
"opus-5" => "claude-opus-5",
|
||
"opus-5-5" => "claude-opus-5-5",
|
||
"fable" => "claude-fable-5",
|
||
"fable-5" => "claude-fable-5",
|
||
"fable-5-1" => "claude-fable-5-1",
|
||
"haiku" => "claude-haiku-4-5",
|
||
"haiku-4-5" => "claude-haiku-4-5",
|
||
_ => "claude-sonnet-5"
|
||
};
|
||
}
|
||
|
||
/// <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, string multiModelMode = null, bool hasRemoteConnection = false, string remoteOs = null)
|
||
{
|
||
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 = WorkspaceHelper.TempDir;
|
||
|
||
var promptBlocks = SystemPromptBuilder.BuildBlocks(
|
||
agent, session, markedMessages, _memoryService, agentMemoryDir,
|
||
agentSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||
resolvedWorkspace: workspace, planMode: planMode);
|
||
var systemPromptText = string.Join("\n\n", promptBlocks.Select(b => b.Content));
|
||
|
||
var hasWorkspace = !string.IsNullOrEmpty(workspace);
|
||
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
|
||
var includeGitHubSearch = agent.UseGitHubSearch && _toolService.HasGitHubToken;
|
||
var includeSerperSearch = agent.UseSerperSearch && _toolService.HasSerperKey;
|
||
var hasTools = hasWorkspace || includeMemory || includeGitHubSearch || includeSerperSearch;
|
||
var toolChoice = hasTools ? "any" : null;
|
||
|
||
var toolDefs = hasTools
|
||
? _toolService.GetToolDefinitions(new ToolDefinitionOptions
|
||
{
|
||
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
|
||
IncludeUserChoice = agent.AllowUserChoice, IncludeRemote = hasRemoteConnection, RemoteOs = remoteOs,
|
||
IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch
|
||
})
|
||
: 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,
|
||
Effort = OpenAiService.IsOpenAiProvider(modelId) ? "" : agent.Effort
|
||
});
|
||
|
||
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 });
|
||
|
||
// Sub-Agents vorbereiten (falls konfiguriert)
|
||
var preparerContext = await BuildSubAgentContext(
|
||
session.PreparerAgentId, sessionId, workspace, session, user);
|
||
var reviewContext = await BuildSubAgentContext(
|
||
session.ReviewAgentId, sessionId, workspace, session, user);
|
||
var finalizerContext = await BuildSubAgentContext(
|
||
session.FinalizerAgentId, sessionId, workspace, session, user);
|
||
|
||
// Multi-Modell-Panel (Fusion/MoA): Panel-Agenten (Proposer) bauen, falls aktiviert.
|
||
// Der Modus ist on-demand (transient pro Request, wie Plan-/Ultracode-Modus) → er kommt
|
||
// aus dem Request-Flag, NICHT mehr aus der Session. Die Panel-Mitglieder bleiben persistent
|
||
// (session.PanelAgentIds), nur ob/wie das Panel läuft entscheidet die einzelne Anfrage.
|
||
// Nur "fusion"/"moa" sind gültig; alles andere (leer/"off"/Müll) → kein Panel.
|
||
var effectiveMultiModelMode = (multiModelMode == "fusion" || multiModelMode == "moa") ? multiModelMode : "off";
|
||
var panelContexts = new List<SubAgentContext>();
|
||
if (effectiveMultiModelMode != "off"
|
||
&& !string.IsNullOrWhiteSpace(session.PanelAgentIds))
|
||
{
|
||
var panelIds = session.PanelAgentIds
|
||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||
.Distinct();
|
||
foreach (var pid in panelIds)
|
||
{
|
||
var pc = await BuildSubAgentContext(pid, sessionId, workspace, session, user);
|
||
if (pc != null)
|
||
{
|
||
// Proposer-Rolle klarstellen (read-only) → Member versuchen nicht mehr vergeblich
|
||
// zu schreiben/committen und "beißen sich fest". Gilt für alle Provider-Pfade
|
||
// (Anthropic nutzt SystemPrompt direkt, CLI/OpenAI via ExtractSystemPromptText).
|
||
pc.SystemPrompt.Add(new { type = "text", text = PanelMemberRolePrompt });
|
||
panelContexts.Add(pc);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (preparerContext != null)
|
||
{
|
||
preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false;
|
||
preparerContext.SystemPrompt.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?
|
||
```" });
|
||
}
|
||
|
||
if (reviewContext != null)
|
||
reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3;
|
||
|
||
|
||
var (provider, _) = OpenAiService.ParseModelString(modelId);
|
||
|
||
return new ChatContext
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
ModelId = modelId,
|
||
Provider = provider,
|
||
Workspace = workspace,
|
||
AgentId = agent.AgentId,
|
||
SystemPromptText = systemPromptText,
|
||
SystemPrompt = systemPrompt,
|
||
PromptBlocks = promptBlocks,
|
||
ToolChoice = toolChoice,
|
||
UseMemory = agent.UseMemory,
|
||
WebSearchVersion = agent.WebSearchVersion,
|
||
Effort = agent.Effort,
|
||
MaxOutputTokens = agent.MaxOutputTokens,
|
||
DisableThinking = agent.DisableThinking,
|
||
MaxIterations = maxIterations,
|
||
Agent = agent,
|
||
Session = session,
|
||
Preparer = preparerContext,
|
||
Review = reviewContext,
|
||
Finalizer = finalizerContext,
|
||
MultiModelMode = effectiveMultiModelMode,
|
||
PanelAgents = panelContexts,
|
||
MoaLayers = session.MoaLayers,
|
||
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, List<PromptBlock> promptBlocks = null, bool includeThinking = false)
|
||
{
|
||
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 ab welcher Chain vollständig geladen werden muss (mit Tool-Calls).
|
||
// Bei ask_user_choice entstehen mehrere Chains die zusammengehören:
|
||
// Chain A (awaiting_user_choice) → tool_result(iter 999) wird in A gespeichert → Chain B (neue Chain)
|
||
// Chain B braucht Chain A vollständig, weil A den tool_use UND tool_result enthält.
|
||
// Drei Fälle, in denen die VORHERIGE Chain vollständig gebraucht wird:
|
||
// 1. Aktuelle Chain ist leer (gerade erst erstellt)
|
||
// 2. Aktuelle Chain hat eine tool_result mit Iteration 999 (Choice-Antwort in aktueller Chain)
|
||
// 3. Vorherige Chain endete mit awaiting_user_choice (Choice-Antwort wurde dort gespeichert)
|
||
int fullChainStartIndex = chainsList.Count - 1;
|
||
for (int ci = chainsList.Count - 1; ci >= 1; ci--)
|
||
{
|
||
var chainMsgs = (await _db.GetChainMessages(chainsList[ci].ChainId)).ToList();
|
||
var prevChainStopReason = chainsList[ci - 1].StopReason;
|
||
|
||
bool needsPrevious = chainMsgs.Count == 0 ||
|
||
chainMsgs.Any(m => m.Type == "tool_result" && m.Iteration == 999) ||
|
||
prevChainStopReason == "awaiting_user_choice";
|
||
if (needsPrevious)
|
||
{
|
||
fullChainStartIndex = ci - 1;
|
||
}
|
||
else
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
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" || msg.Type == "steer") && !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.IsNullOrWhiteSpace(msg.Content))
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { new { type = "text", text = msg.Content.Trim() } }
|
||
});
|
||
}
|
||
}
|
||
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 == "user" && msg.Type == "steer")
|
||
{
|
||
// Live-Einwurf (Steering) des Users während die Chain lief.
|
||
// AppendToLastMessage mergt ihn in den vorausgehenden user-Turn
|
||
// (tool_result-Runde bei Drain-B) bzw. erzeugt einen neuen user-Turn
|
||
// nach dem Assistant (Drain-C) → niemals zwei aufeinanderfolgende user-Turns.
|
||
if (!string.IsNullOrEmpty(msg.Content))
|
||
AppendToLastMessage(history, "user", new { type = "text", text = msg.Content });
|
||
}
|
||
else if (msg.Role == "assistant" && msg.Type == "thinking" && includeThinking)
|
||
{
|
||
// Thinking-Inhalt für OpenAI-kompatible Provider mitführen.
|
||
// Wird in OpenAiService.ConvertMessage als <think>...</think>
|
||
// dem Assistant-Text vorangestellt (laut MiniMax-Docs: "the content field
|
||
// will contain <think> tag content, which must be preserved completely"
|
||
// → essential für Interleaved Thinking, Tool-Calling-Reliability,
|
||
// siehe https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2).
|
||
// Anthropic-Pfad: includeThinking=false → Block wird übersprungen,
|
||
// weil Anthropic-API thinking-Blöcke ohne Signature ablehnt.
|
||
if (!string.IsNullOrEmpty(msg.Content))
|
||
{
|
||
// NEUE assistant message starten; text/tool_use derselben Iteration
|
||
// werden via AppendToLastMessage angehängt → ein assistant-Block enthält
|
||
// dann [thinking, text, tool_use…].
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { new { type = "thinking", thinking = msg.Content } }
|
||
});
|
||
}
|
||
}
|
||
else if (msg.Role == "assistant" && msg.Type == "text")
|
||
{
|
||
// Trim: Nach </think>-Extraktion bleibt oft nur "\n" als Text übrig.
|
||
// Whitespace-only Text nicht in die History aufnehmen (verursacht leere Bubbles).
|
||
var trimmedContent = msg.Content?.Trim();
|
||
if (!string.IsNullOrEmpty(trimmedContent))
|
||
{
|
||
// Wenn die letzte Message bereits assistant ist (z.B. mit Thinking-Block
|
||
// aus derselben Iteration), Text dort anhängen statt neue Message zu starten.
|
||
AppendToLastMessage(history, "assistant", new { type = "text", text = trimmedContent });
|
||
}
|
||
}
|
||
else if (msg.Role == "assistant" && msg.Type == "tool_use")
|
||
{
|
||
JsonElement inputEl;
|
||
try { inputEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
|
||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||
|
||
// thought_signature mitführen falls vorhanden (für Gemini-Provider)
|
||
object toolUseBlock = !string.IsNullOrEmpty(msg.ThoughtSignature)
|
||
? (object)new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl, thought_signature = msg.ThoughtSignature }
|
||
: new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl };
|
||
|
||
AppendToLastMessage(history, "assistant", toolUseBlock);
|
||
}
|
||
else if (msg.Role == "assistant" && msg.Type == "server_tool_use")
|
||
{
|
||
// Server-Tool-Use (web_search, code_execution etc.) – Raw-JSON in Assistant-Content
|
||
JsonElement useEl;
|
||
try { useEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
|
||
catch { continue; }
|
||
|
||
AppendToLastMessage(history, "assistant", useEl);
|
||
}
|
||
else if (msg.Role == "assistant" && msg.Type == "server_tool_result")
|
||
{
|
||
// Server-Tool-Results (bash_code_execution_tool_result, code_execution_tool_result etc.)
|
||
// werden als Raw-JSON in den Assistant-Content-Block eingefügt
|
||
JsonElement resultEl;
|
||
try { resultEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
|
||
catch { continue; }
|
||
|
||
AppendToLastMessage(history, "assistant", resultEl);
|
||
}
|
||
else if (msg.Role == "assistant" && msg.Type == "web_search_result")
|
||
{
|
||
// web_search_tool_result – Raw-JSON in den Assistant-Content-Block einfügen
|
||
// (API erwartet web_search_tool_result nach server_tool_use vom Typ web_search)
|
||
JsonElement resultEl;
|
||
try { resultEl = JsonSerializer.Deserialize<JsonElement>(msg.Content ?? "{}"); }
|
||
catch { continue; }
|
||
|
||
// Rückwärtskompatibilität: Alte Einträge enthalten nur Sources-Array statt Raw-JSON
|
||
// Diese überspringen, da sie kein gültiger web_search_tool_result-Block sind
|
||
if (resultEl.ValueKind == JsonValueKind.Array) continue;
|
||
|
||
AppendToLastMessage(history, "assistant", resultEl);
|
||
}
|
||
else if ((msg.Role == "user" || msg.Role == "tool") && msg.Type == "tool_result")
|
||
{
|
||
// Anthropic speichert tool_results mit Role="user", OpenAI mit Role="tool".
|
||
// Beide werden in Anthropic-Format (user-Message mit tool_result-Block) eingefügt,
|
||
// da BuildApiMessages später das Format für OpenAI-Provider konvertiert.
|
||
var toolResultBlock = new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError };
|
||
AppendToLastMessage(history, "user", toolResultBlock);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// History auf maximal N Messages begrenzen (älteste entfernen)
|
||
// WICHTIG: Nicht mitten in einem tool_use/tool_result-Paar abschneiden!
|
||
if (history.Count > MaxHistory)
|
||
{
|
||
int removeCount = history.Count - MaxHistory;
|
||
// Nach vorne korrigieren bis wir an einer sicheren Stelle sind:
|
||
// Nicht abschneiden wenn die erste verbleibende Message ein tool_result ist
|
||
// oder die letzte entfernte Message ein assistant mit tool_use ist
|
||
while (removeCount < history.Count - 1)
|
||
{
|
||
var firstRemaining = history[removeCount];
|
||
bool isToolResult = false;
|
||
if (firstRemaining is Dictionary<string, object> frDict &&
|
||
frDict.TryGetValue("role", out var frRole) && frRole.ToString() == "user" &&
|
||
frDict.TryGetValue("content", out var frContent) && frContent is List<object> frList)
|
||
{
|
||
isToolResult = frList.Any(item =>
|
||
{
|
||
var json = JsonSerializer.Serialize(item);
|
||
return json.Contains("\"tool_result\"");
|
||
});
|
||
}
|
||
|
||
if (isToolResult)
|
||
{
|
||
// Die erste verbleibende Message ist ein tool_result → muss den vorherigen
|
||
// assistant[tool_use] auch behalten → ein Element weniger entfernen
|
||
removeCount--;
|
||
// Jetzt prüfen ob der neue Start auch sicher ist (könnte auch tool_result sein)
|
||
continue;
|
||
}
|
||
|
||
// Prüfen ob die letzte entfernte Message ein assistant mit tool_use war
|
||
bool lastRemovedHasToolUse = false;
|
||
if (removeCount > 0)
|
||
{
|
||
var lastRemoved = history[removeCount - 1];
|
||
if (lastRemoved is Dictionary<string, object> lrDict &&
|
||
lrDict.TryGetValue("role", out var lrRole) && lrRole.ToString() == "assistant" &&
|
||
lrDict.TryGetValue("content", out var lrContent) && lrContent is List<object> lrList)
|
||
{
|
||
lastRemovedHasToolUse = lrList.Any(item =>
|
||
{
|
||
var json = JsonSerializer.Serialize(item);
|
||
return json.Contains("\"tool_use\"");
|
||
});
|
||
}
|
||
}
|
||
|
||
if (lastRemovedHasToolUse)
|
||
{
|
||
// Letztes entferntes Element enthält tool_use → auch das tool_result entfernen
|
||
removeCount++;
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (removeCount > 0 && removeCount < history.Count)
|
||
history.RemoveRange(0, removeCount);
|
||
}
|
||
|
||
// Reminder einfügen: von hinten gezählt – halbes Intervall vor Ende, dann volle Intervalle
|
||
var reminder = SystemPromptBuilder.BuildReminder(promptBlocks);
|
||
int halfInterval = ReminderInterval / 2;
|
||
if (!string.IsNullOrWhiteSpace(reminder) && ReminderInterval > 0 && history.Count > halfInterval)
|
||
{
|
||
var reminderUser = new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = new List<object> { new { type = "text", text = "[System Reminder] " + reminder } }
|
||
};
|
||
var reminderAssistant = new Dictionary<string, object>
|
||
{
|
||
["role"] = "assistant",
|
||
["content"] = new List<object> { new { type = "text", text = "Verstanden." } }
|
||
};
|
||
|
||
// Zielpositionen von hinten berechnen:
|
||
// Erste Position: history.Count - halfInterval (z.B. 15 vom Ende)
|
||
// Weitere Positionen: jeweils - ReminderInterval (z.B. 30er-Schritte rückwärts)
|
||
var targetPositions = new List<int>();
|
||
int pos = history.Count - halfInterval;
|
||
while (pos > 0)
|
||
{
|
||
targetPositions.Add(pos);
|
||
pos -= ReminderInterval;
|
||
}
|
||
|
||
// Für jede Zielposition die nächste valide Einfügestelle finden
|
||
var insertPositions = new List<int>();
|
||
foreach (int target in targetPositions)
|
||
{
|
||
// Suche ab target rückwärts nach einer assistant-Message,
|
||
// deren Folge-Message kein tool_result ist
|
||
for (int i = Math.Min(target, history.Count - 1); i >= 1; i--)
|
||
{
|
||
if (!(history[i] is Dictionary<string, object> d &&
|
||
d.TryGetValue("role", out var r) && r.ToString() == "assistant"))
|
||
continue;
|
||
|
||
bool nextIsToolResult = false;
|
||
if (i + 1 < history.Count && history[i + 1] is Dictionary<string, object> nextDict &&
|
||
nextDict.TryGetValue("role", out var nextRole) && nextRole.ToString() == "user" &&
|
||
nextDict.TryGetValue("content", out var nextContent) && nextContent is List<object> nextList)
|
||
{
|
||
nextIsToolResult = nextList.Any(item =>
|
||
{
|
||
var json = JsonSerializer.Serialize(item);
|
||
return json.Contains("\"tool_result\"");
|
||
});
|
||
}
|
||
|
||
if (!nextIsToolResult)
|
||
{
|
||
insertPositions.Add(i + 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Deduplizieren und von hinten nach vorne einfügen, damit Indizes nicht verschoben werden
|
||
insertPositions = insertPositions.Distinct().OrderByDescending(x => x).ToList();
|
||
foreach (int insertPos in insertPositions)
|
||
{
|
||
history.Insert(insertPos, reminderAssistant);
|
||
history.Insert(insertPos, reminderUser);
|
||
}
|
||
}
|
||
|
||
// Post-Processing: Offene tool_use-Blöcke ohne tool_result → synthetische tool_results einfügen
|
||
FixOrphanedToolUse(history);
|
||
|
||
// Post-Processing: tool_result-Blöcke ohne passendes tool_use entfernen
|
||
// Tritt auf wenn eine ältere ask_user_choice-Chain als Text-only geladen wird:
|
||
// Der tool_use(ask_user_choice) geht verloren, aber das tool_result(iter 999) in der nächsten Chain bleibt.
|
||
FixOrphanedToolResults(history);
|
||
|
||
// Post-Processing: Orphaned web_search server_tool_use entfernen
|
||
// Bei alten DB-Einträgen wurde web_search_tool_result nur als Sources-Array gespeichert,
|
||
// nicht als Raw-JSON → BuildHistoryFromDb überspringt sie (Array-Check) →
|
||
// server_tool_use mit name "web_search" hat kein zugehöriges web_search_tool_result →
|
||
// API 400 Fehler. Lösung: Solche orphaned Blöcke aus der History entfernen.
|
||
CleanOrphanedWebSearchBlocks(history);
|
||
|
||
return history;
|
||
}
|
||
|
||
private static void AppendToLastMessage(List<object> history, string role, object block)
|
||
{
|
||
var last = history.Count > 0 ? history[history.Count - 1] : null;
|
||
if (last is Dictionary<string, object> dict &&
|
||
dict.TryGetValue("role", out var r) && r.ToString() == role &&
|
||
dict.TryGetValue("content", out var existing) && existing is List<object> contentList)
|
||
{
|
||
contentList.Add(block);
|
||
}
|
||
else
|
||
{
|
||
history.Add(new Dictionary<string, object>
|
||
{
|
||
["role"] = role,
|
||
["content"] = new List<object> { block }
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Entfernt verwaiste web_search-Blöcke aus assistant-Messages:
|
||
/// - server_tool_use ohne zugehörigen web_search_tool_result (z.B. alte DB-Einträge)
|
||
/// - web_search_tool_result ohne zugehörigen server_tool_use (z.B. Parse-Fehler)
|
||
/// </summary>
|
||
private static void CleanOrphanedWebSearchBlocks(List<object> history)
|
||
{
|
||
foreach (var msg in history)
|
||
{
|
||
if (msg is not Dictionary<string, object> dict) continue;
|
||
if (!dict.TryGetValue("role", out var role) || role.ToString() != "assistant") continue;
|
||
if (!dict.TryGetValue("content", out var content) || content is not List<object> contentList) continue;
|
||
|
||
// Prüfe ob es web_search server_tool_use gibt
|
||
var webSearchToolUseIds = new HashSet<string>();
|
||
var webSearchToolResultIds = new HashSet<string>();
|
||
|
||
foreach (var block in contentList)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
JsonElement el;
|
||
try { el = JsonSerializer.Deserialize<JsonElement>(json); }
|
||
catch { continue; }
|
||
|
||
var type = el.TryGetProperty("type", out var t) ? t.GetString() : "";
|
||
if (type == "server_tool_use")
|
||
{
|
||
var name = el.TryGetProperty("name", out var n) ? n.GetString() : "";
|
||
var id = el.TryGetProperty("id", out var idProp) ? idProp.GetString() : "";
|
||
if (name == "web_search" && !string.IsNullOrEmpty(id))
|
||
webSearchToolUseIds.Add(id);
|
||
}
|
||
else if (type == "web_search_tool_result")
|
||
{
|
||
var toolUseId = el.TryGetProperty("tool_use_id", out var tid) ? tid.GetString() : "";
|
||
if (!string.IsNullOrEmpty(toolUseId))
|
||
webSearchToolResultIds.Add(toolUseId);
|
||
}
|
||
}
|
||
|
||
// Orphaned server_tool_use = IDs ohne zugehörige web_search_tool_result
|
||
var orphanedUseIds = webSearchToolUseIds.Except(webSearchToolResultIds).ToHashSet();
|
||
// Orphaned web_search_tool_result = tool_use_ids ohne zugehörige server_tool_use
|
||
var orphanedResultIds = webSearchToolResultIds.Except(webSearchToolUseIds).ToHashSet();
|
||
|
||
if (orphanedUseIds.Count == 0 && orphanedResultIds.Count == 0) continue;
|
||
|
||
// Entferne orphaned server_tool_use und web_search_tool_result Blöcke
|
||
contentList.RemoveAll(block =>
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
JsonElement el;
|
||
try { el = JsonSerializer.Deserialize<JsonElement>(json); }
|
||
catch { return false; }
|
||
|
||
var type = el.TryGetProperty("type", out var t) ? t.GetString() : "";
|
||
|
||
if (type == "server_tool_use")
|
||
{
|
||
var id = el.TryGetProperty("id", out var idProp) ? idProp.GetString() : "";
|
||
return orphanedUseIds.Contains(id);
|
||
}
|
||
|
||
if (type == "web_search_tool_result")
|
||
{
|
||
var toolUseId = el.TryGetProperty("tool_use_id", out var tid) ? tid.GetString() : "";
|
||
return orphanedResultIds.Contains(toolUseId);
|
||
}
|
||
|
||
return false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Entfernt verwaiste tool_result-Blöcke aus user-Messages:
|
||
/// tool_results deren tool_use_id in keinem vorherigen assistant-Block existiert.
|
||
/// Tritt auf wenn eine ältere ask_user_choice-Chain als Text-only geladen wird –
|
||
/// der tool_use geht verloren, aber das tool_result in der nächsten Chain bleibt.
|
||
/// </summary>
|
||
private static void FixOrphanedToolResults(List<object> history)
|
||
{
|
||
// Alle tool_use IDs in der gesamten History sammeln
|
||
var allToolUseIds = new HashSet<string>();
|
||
foreach (var msg in history)
|
||
{
|
||
if (msg is not Dictionary<string, object> dict) continue;
|
||
if (!dict.TryGetValue("role", out var role) || role?.ToString() != "assistant") continue;
|
||
if (!dict.TryGetValue("content", out var content) || content is not List<object> contentList) continue;
|
||
|
||
foreach (var block in contentList)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
JsonElement el;
|
||
try { el = JsonSerializer.Deserialize<JsonElement>(json); }
|
||
catch { continue; }
|
||
|
||
if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_use") continue;
|
||
if (el.TryGetProperty("id", out var idProp))
|
||
{
|
||
var id = idProp.GetString();
|
||
if (!string.IsNullOrEmpty(id))
|
||
allToolUseIds.Add(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// User-Messages durchgehen und orphaned tool_results entfernen
|
||
for (int i = history.Count - 1; i >= 0; i--)
|
||
{
|
||
if (history[i] is not Dictionary<string, object> udict) continue;
|
||
if (!udict.TryGetValue("role", out var urole) || urole?.ToString() != "user") continue;
|
||
if (!udict.TryGetValue("content", out var ucontent) || ucontent is not List<object> ucontentList) continue;
|
||
|
||
ucontentList.RemoveAll(block =>
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
JsonElement el;
|
||
try { el = JsonSerializer.Deserialize<JsonElement>(json); }
|
||
catch { return false; }
|
||
|
||
if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_result") return false;
|
||
if (!el.TryGetProperty("tool_use_id", out var tid)) return false;
|
||
var toolUseId = tid.GetString();
|
||
return !string.IsNullOrEmpty(toolUseId) && !allToolUseIds.Contains(toolUseId);
|
||
});
|
||
|
||
// Wenn die user-Message nach dem Entfernen leer ist, ganze Message entfernen
|
||
if (ucontentList.Count == 0)
|
||
history.RemoveAt(i);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft die gesamte History auf tool_use-Blöcke ohne passendes tool_result in der nächsten user-Message.
|
||
/// Falls gefunden, werden synthetische tool_results eingefügt – verhindert Anthropic API 400.
|
||
/// Betrifft alle Tools, nicht nur ask_user_choice (z.B. wenn eine Chain abbricht ohne tool_result zu speichern).
|
||
/// </summary>
|
||
private static void FixOrphanedToolUse(List<object> history)
|
||
{
|
||
if (history.Count == 0) return;
|
||
|
||
for (int i = 0; i < history.Count; i++)
|
||
{
|
||
if (history[i] is not Dictionary<string, object> assistantDict) continue;
|
||
if (!assistantDict.TryGetValue("role", out var role) || role?.ToString() != "assistant") continue;
|
||
if (!assistantDict.TryGetValue("content", out var content) || content is not List<object> contentList) continue;
|
||
|
||
// Alle tool_use IDs in dieser assistant-Message sammeln
|
||
var toolUseIds = new List<string>();
|
||
foreach (var block in contentList)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
JsonElement el;
|
||
try { el = JsonSerializer.Deserialize<JsonElement>(json); }
|
||
catch { continue; }
|
||
|
||
if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_use") continue;
|
||
if (!el.TryGetProperty("id", out var idProp)) continue;
|
||
var id = idProp.GetString();
|
||
if (!string.IsNullOrEmpty(id))
|
||
toolUseIds.Add(id);
|
||
}
|
||
|
||
if (toolUseIds.Count == 0) continue;
|
||
|
||
// Prüfen ob die nächste user-Message die tool_results enthält
|
||
var coveredIds = new HashSet<string>();
|
||
if (i + 1 < history.Count &&
|
||
history[i + 1] is Dictionary<string, object> nextDict &&
|
||
nextDict.TryGetValue("role", out var nextRole) && nextRole?.ToString() == "user" &&
|
||
nextDict.TryGetValue("content", out var nextContent) && nextContent is List<object> nextContentList)
|
||
{
|
||
foreach (var block in nextContentList)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
JsonElement el;
|
||
try { el = JsonSerializer.Deserialize<JsonElement>(json); }
|
||
catch { continue; }
|
||
|
||
if (!el.TryGetProperty("type", out var t) || t.GetString() != "tool_result") continue;
|
||
if (!el.TryGetProperty("tool_use_id", out var tid)) continue;
|
||
var toolUseId = tid.GetString();
|
||
if (!string.IsNullOrEmpty(toolUseId))
|
||
coveredIds.Add(toolUseId);
|
||
}
|
||
}
|
||
|
||
// Orphaned IDs = tool_use ohne passendes tool_result
|
||
var orphanedIds = toolUseIds.Where(id => !coveredIds.Contains(id)).ToList();
|
||
if (orphanedIds.Count == 0) continue;
|
||
|
||
// Synthetische tool_results bauen
|
||
var syntheticResults = orphanedIds.Select(id => (object)new
|
||
{
|
||
type = "tool_result",
|
||
tool_use_id = id,
|
||
content = "Tool-Ergebnis nicht verfügbar (Chain wurde unterbrochen)."
|
||
}).ToList();
|
||
|
||
// Falls nächste Message eine user-Message ist, synthetische results dort prependen
|
||
if (i + 1 < history.Count &&
|
||
history[i + 1] is Dictionary<string, object> existingUser &&
|
||
existingUser.TryGetValue("role", out var er) && er?.ToString() == "user" &&
|
||
existingUser.TryGetValue("content", out var ec) && ec is List<object> existingContent)
|
||
{
|
||
existingContent.InsertRange(0, syntheticResults);
|
||
}
|
||
else
|
||
{
|
||
// Neue user-Message mit synthetischen results einfügen
|
||
history.Insert(i + 1, new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = syntheticResults
|
||
});
|
||
i++; // Eingefügte Message überspringen
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut einen SubAgentContext für Preparer/Review/Finalizer.
|
||
/// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen.
|
||
/// </summary>
|
||
private async Task<SubAgentContext> BuildSubAgentContext(
|
||
string agentId, string sessionId, string workspace, SessionRecord session, UserRecord user)
|
||
{
|
||
if (string.IsNullOrEmpty(agentId))
|
||
return null;
|
||
|
||
var agent = await _db.GetAgent(agentId);
|
||
if (agent == null)
|
||
return null;
|
||
|
||
var modelId = ResolveModelId(agent.Model);
|
||
var markedMessages = await _db.GetMarkedMessages(sessionId);
|
||
var memDir = _memoryService.GetAgentMemoryDir(agent.AgentId);
|
||
var skills = await _db.GetAgentSkills(agent.AgentId);
|
||
var systemText = SystemPromptBuilder.Build(
|
||
agent, session, markedMessages, _memoryService, memDir,
|
||
skills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||
resolvedWorkspace: workspace);
|
||
|
||
var prompt = new List<object>();
|
||
if (!string.IsNullOrWhiteSpace(systemText))
|
||
prompt.Add(new { type = "text", text = systemText });
|
||
|
||
var (provider, _) = OpenAiService.ParseModelString(modelId);
|
||
var hasTools = !string.IsNullOrEmpty(workspace) || agent.UseMemory;
|
||
return new SubAgentContext
|
||
{
|
||
ModelId = modelId,
|
||
Provider = provider,
|
||
SystemPrompt = prompt,
|
||
AgentId = agent.AgentId,
|
||
Workspace = workspace,
|
||
UseMemory = agent.UseMemory,
|
||
WebSearchVersion = agent.WebSearchVersion,
|
||
ToolChoice = hasTools ? "any" : "auto",
|
||
Effort = agent.Effort,
|
||
Agent = agent
|
||
};
|
||
}
|
||
|
||
}
|