Opus 4.5+ and Sonnet 4.6+ support a new "effort" knob that controls how
much budget the model spends on thinking and tool calls. Plumb it through
end to end so each agent can pick its own level.
Storage & shape:
- New TEXT column agents.effort (default '') added via TryAddColumn.
- AgentRecord.Effort + INSERT/UPDATE in DatabaseService.Agents.cs.
- ChatContext.Effort + SubAgentContext.Effort so the value travels with the
request alongside model and tool settings.
Agent editor:
- Effort dropdown above git-commit behavior in the agent form (low/medium/
high/xhigh/max plus empty = "use default"). Always visible; on OpenAI
providers the value is sent but silently ignored.
- agent-page.js load/save and AgentEndpoint create/update wired up.
Anthropic API path:
- StreamChat and RunNonStreamingToolLoop add output_config: { effort } when
effort is set and not "high" – empty/null/"high" means "omit and let the
API pick its default".
- Sub-agents (preparer/review/finalizer) get the parameter through
RunAgentChat so each step uses its own agent's effort.
Anthropic CLI path:
- ClaudeCliService.ChatStreamingAsync takes an effort parameter and exports
CLAUDE_CODE_EFFORT_LEVEL on the spawned process. Env-var beats the
--effort flag because it also covers "max" and "ultracode".
- StreamChatViaCli, ChatNonStreamingViaCli, RunSubAgentViaCli, and the
ChatEndpoint anthropic branches all hand the value through.
ARCHITECTURE.md / ARCHITECTURE_QUICK.md updated with the new field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
816 lines
32 KiB
C#
816 lines
32 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Gott.Models;
|
||
using Microsoft.AspNetCore.Http;
|
||
|
||
namespace Gott.Services;
|
||
|
||
public partial class AnthropicService
|
||
{
|
||
// ── Preparer-Agent Logik ──
|
||
|
||
public class PreparerResult
|
||
{
|
||
public bool ShouldForward { get; set; }
|
||
public string ForwardedPrompt { get; set; }
|
||
public string ResponseText { get; set; }
|
||
public int IterationCount { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Führt den Preparer-Agent aus. Der Preparer bekommt die User-Nachricht und die bisherige Konversation.
|
||
/// Antwortet er mit [FORWARD] → der Text nach [FORWARD] wird an den Session-Agent weitergegeben.
|
||
/// Sonst → seine Antwort wird als Rückfrage an den User gestreamt.
|
||
/// </summary>
|
||
public async Task<PreparerResult> RunPreparer(
|
||
HttpResponse response,
|
||
SubAgentContext preparer,
|
||
List<object> historyMessages,
|
||
string chainId,
|
||
string workspace,
|
||
string sessionId,
|
||
string apiToken,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// Preparer läuft VOR dem Worker und nutzt den Request-CancellationToken direkt.
|
||
// Bei Client-Disconnect wird der Preparer abgebrochen (kein Sinn weiterzumachen,
|
||
// da der Worker noch nicht gestartet hat).
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
await SseHelper.TryWriteSse(response, "preparer_start", new
|
||
{
|
||
agentId = preparer.AgentId,
|
||
agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent"
|
||
}, cancellationToken);
|
||
|
||
// Preparer bekommt die History als Kontext
|
||
var preparerMessages = new List<object>(historyMessages);
|
||
|
||
var usage = new TokenUsage();
|
||
string text;
|
||
int iterations;
|
||
|
||
if (!string.IsNullOrEmpty(preparer.Provider) && _openAiService != null)
|
||
{
|
||
// OpenAI-kompatibler Preparer
|
||
var subCtx = BuildSubAgentChatContext(preparer, chainId, sessionId, apiToken);
|
||
text = await _openAiService.ChatNonStreaming(subCtx, preparerMessages);
|
||
iterations = 1;
|
||
}
|
||
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
|
||
{
|
||
// Kein API-Key → CLI-Fallback
|
||
(text, iterations) = await RunSubAgentViaCli(
|
||
response, preparer, preparerMessages, chainId, sessionId,
|
||
"preparer", cancellationToken, cancellationToken);
|
||
}
|
||
else
|
||
{
|
||
(text, iterations) = await RunAgentChat(
|
||
response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId,
|
||
preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion,
|
||
preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage,
|
||
0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent",
|
||
cancellationToken, cancellationToken, preparer.Effort);
|
||
}
|
||
|
||
var result = new PreparerResult { IterationCount = iterations, ResponseText = text };
|
||
|
||
// Logik: Standardmäßig wird IMMER weitergeleitet.
|
||
// Nur wenn der Preparer explizit [QUESTION] nutzt, wird gestoppt (= Rückfrage an User).
|
||
// [FORWARD] wird weiterhin erkannt und entfernt, ist aber nicht mehr zwingend nötig.
|
||
if (!string.IsNullOrEmpty(text) && text.Contains("[QUESTION]"))
|
||
{
|
||
// Explizite Rückfrage an User
|
||
var questionText = text.Replace("[QUESTION]", "").Trim();
|
||
result.ShouldForward = false;
|
||
result.ResponseText = questionText;
|
||
|
||
await SseHelper.TryWriteSse(response, "preparer_done", new
|
||
{
|
||
agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent",
|
||
forwarded = false,
|
||
responseText = questionText
|
||
}, cancellationToken);
|
||
}
|
||
else
|
||
{
|
||
// Weiterleitung (Standard): [FORWARD]-Tag entfernen falls vorhanden
|
||
var forwardedText = text ?? "";
|
||
if (forwardedText.Contains("[FORWARD]"))
|
||
{
|
||
var idx = forwardedText.IndexOf("[FORWARD]");
|
||
forwardedText = forwardedText.Substring(idx + "[FORWARD]".Length).Trim();
|
||
}
|
||
|
||
result.ShouldForward = true;
|
||
result.ForwardedPrompt = forwardedText;
|
||
|
||
await SseHelper.TryWriteSse(response, "preparer_done", new
|
||
{
|
||
agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent",
|
||
forwarded = true
|
||
}, cancellationToken);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Review-Agent Logik ──
|
||
|
||
private class ReviewResult
|
||
{
|
||
public bool HasFeedback { get; set; }
|
||
public string Feedback { get; set; }
|
||
public int ReviewIterationCount { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Führt den Review-Agent aus. Der Review-Agent bekommt die Text-Konversation
|
||
/// und kann Tools nutzen (z.B. read_file um die Arbeit zu prüfen).
|
||
/// Default = Approved. Nur bei explizitem [FEEDBACK]-Tag wird Feedback an den Worker zurückgegeben.
|
||
/// reviewRound = aktuelle Runde (0-basiert), wird vom Caller hochgezählt.
|
||
/// </summary>
|
||
private async Task<ReviewResult> RunReview(
|
||
HttpResponse response,
|
||
SubAgentContext review,
|
||
List<object> workerMessages,
|
||
string chainId,
|
||
string workspace,
|
||
string sessionId,
|
||
string apiToken,
|
||
TokenUsage usage,
|
||
int baseIterations,
|
||
int reviewRound,
|
||
CancellationToken workCt,
|
||
CancellationToken sseCt)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "review_start", new
|
||
{
|
||
agentId = review.AgentId,
|
||
agentName = review.Agent?.Name ?? "Review-Agent",
|
||
round = reviewRound
|
||
}, sseCt);
|
||
|
||
// Review-Agent bekommt die Worker-Konversation als Kontext
|
||
var reviewMessages = BuildReviewMessages(workerMessages);
|
||
|
||
string text;
|
||
int iterations;
|
||
|
||
if (!string.IsNullOrEmpty(review.Provider) && _openAiService != null)
|
||
{
|
||
// OpenAI-kompatibler Reviewer
|
||
var subCtx = BuildSubAgentChatContext(review, chainId, sessionId, apiToken);
|
||
text = await _openAiService.ChatNonStreaming(subCtx, reviewMessages);
|
||
iterations = 1;
|
||
}
|
||
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
|
||
{
|
||
// Kein API-Key → CLI-Fallback
|
||
(text, iterations) = await RunSubAgentViaCli(
|
||
response, review, reviewMessages, chainId, sessionId,
|
||
"review", workCt, sseCt);
|
||
}
|
||
else
|
||
{
|
||
(text, iterations) = await RunAgentChat(
|
||
response, review.ModelId, review.SystemPrompt, review.AgentId,
|
||
review.Workspace, review.UseMemory, review.WebSearchVersion,
|
||
review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage,
|
||
baseIterations, "review", review.Agent?.Name ?? "Review-Agent",
|
||
workCt, sseCt, review.Effort);
|
||
}
|
||
|
||
var result = new ReviewResult { ReviewIterationCount = iterations };
|
||
|
||
if (!string.IsNullOrEmpty(text) && text.Contains("[FEEDBACK]"))
|
||
{
|
||
var feedbackText = text.Substring(text.IndexOf("[FEEDBACK]") + "[FEEDBACK]".Length).Trim();
|
||
result.HasFeedback = true;
|
||
result.Feedback = $"[Review-Feedback, Runde {reviewRound + 1}]: {feedbackText}";
|
||
}
|
||
|
||
await SseHelper.TryWriteSse(response, "review_done", new
|
||
{
|
||
agentName = review.Agent?.Name ?? "Review-Agent",
|
||
round = reviewRound,
|
||
hasFeedback = result.HasFeedback
|
||
}, sseCt);
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Führt den Finalizer-Agent aus. Wird einmal nach der Review-Phase (oder direkt nach dem Worker) aufgerufen.
|
||
/// Der Finalizer kann Tools nutzen, gibt aber kein Feedback an den Worker zurück.
|
||
/// </summary>
|
||
private async Task<int> RunFinalizer(
|
||
HttpResponse response,
|
||
SubAgentContext finalizer,
|
||
List<object> workerMessages,
|
||
string chainId,
|
||
string workspace,
|
||
string sessionId,
|
||
string apiToken,
|
||
TokenUsage usage,
|
||
int baseIterations,
|
||
CancellationToken workCt,
|
||
CancellationToken sseCt)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "finalizer_start", new
|
||
{
|
||
agentId = finalizer.AgentId,
|
||
agentName = finalizer.Agent?.Name ?? "Finalizer-Agent"
|
||
}, sseCt);
|
||
|
||
// Finalizer bekommt die Konversation als Kontext (gleiche Methode wie Review)
|
||
var finalizerMessages = BuildReviewMessages(workerMessages);
|
||
|
||
string text;
|
||
int iterations;
|
||
|
||
if (!string.IsNullOrEmpty(finalizer.Provider) && _openAiService != null)
|
||
{
|
||
var subCtx = BuildSubAgentChatContext(finalizer, chainId, sessionId, apiToken);
|
||
text = await _openAiService.ChatNonStreaming(subCtx, finalizerMessages);
|
||
iterations = 1;
|
||
}
|
||
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
|
||
{
|
||
// Kein API-Key → CLI-Fallback
|
||
(text, iterations) = await RunSubAgentViaCli(
|
||
response, finalizer, finalizerMessages, chainId, sessionId,
|
||
"finalizer", workCt, sseCt);
|
||
}
|
||
else
|
||
{
|
||
(text, iterations) = await RunAgentChat(
|
||
response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId,
|
||
finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion,
|
||
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage,
|
||
baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent",
|
||
workCt, sseCt, finalizer.Effort);
|
||
}
|
||
|
||
await SseHelper.TryWriteSse(response, "finalizer_done", new
|
||
{
|
||
agentName = finalizer.Agent?.Name ?? "Finalizer-Agent"
|
||
}, sseCt);
|
||
|
||
return iterations;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut die Messages für den Review-Agent auf.
|
||
/// Der Review-Agent bekommt die komplette Worker-Konversation als eine User-Message.
|
||
/// </summary>
|
||
private List<object> BuildReviewMessages(List<object> workerMessages)
|
||
{
|
||
// Nur die Text-Konversation extrahieren – Tool-Calls und Tool-Results weglassen.
|
||
// Der Review/Finalizer-Agent hat eigene Tools und kann bei Bedarf selbst Dateien lesen.
|
||
// Aufeinanderfolgende Messages gleicher Rolle werden zusammengeführt,
|
||
// da die Anthropic API abwechselnde user/assistant-Rollen erfordert.
|
||
var messages = new List<object>();
|
||
string lastRole = null;
|
||
var currentParts = new List<string>();
|
||
|
||
foreach (var msg in workerMessages)
|
||
{
|
||
var json = JsonSerializer.Serialize(msg);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (!el.TryGetProperty("role", out var role)) continue;
|
||
var roleStr = role.GetString() ?? "";
|
||
|
||
if (!el.TryGetProperty("content", out var content)) continue;
|
||
|
||
string textContent = null;
|
||
|
||
if (content.ValueKind == JsonValueKind.String)
|
||
{
|
||
textContent = content.GetString();
|
||
}
|
||
else if (content.ValueKind == JsonValueKind.Array)
|
||
{
|
||
var parts = new List<string>();
|
||
foreach (var block in content.EnumerateArray())
|
||
{
|
||
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||
if (blockType == "text" && block.TryGetProperty("text", out var txt))
|
||
parts.Add(txt.GetString() ?? "");
|
||
}
|
||
if (parts.Count > 0)
|
||
textContent = string.Join("\n", parts);
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(textContent)) continue;
|
||
|
||
if (roleStr == lastRole)
|
||
{
|
||
// Gleiche Rolle → Text anhängen
|
||
currentParts.Add(textContent);
|
||
}
|
||
else
|
||
{
|
||
// Rolle wechselt → vorherige Message abschließen
|
||
if (lastRole != null && currentParts.Count > 0)
|
||
{
|
||
messages.Add(new
|
||
{
|
||
role = lastRole,
|
||
content = string.Join("\n\n", currentParts)
|
||
});
|
||
}
|
||
lastRole = roleStr;
|
||
currentParts = new List<string> { textContent };
|
||
}
|
||
}
|
||
|
||
// Letzte Message abschließen
|
||
if (lastRole != null && currentParts.Count > 0)
|
||
{
|
||
messages.Add(new
|
||
{
|
||
role = lastRole,
|
||
content = string.Join("\n\n", currentParts)
|
||
});
|
||
}
|
||
|
||
// Sicherstellen, dass die Messages mit "user" beginnen
|
||
if (messages.Count > 0)
|
||
{
|
||
var firstJson = JsonSerializer.Serialize(messages[0]);
|
||
var firstEl = JsonSerializer.Deserialize<JsonElement>(firstJson);
|
||
if (firstEl.GetProperty("role").GetString() != "user")
|
||
{
|
||
messages.Insert(0, new { role = "user", content = "Bitte überprüfe die folgende Konversation und die Arbeit des Agents." });
|
||
}
|
||
}
|
||
else
|
||
{
|
||
messages.Add(new { role = "user", content = "Es gibt keine Konversation zum Überprüfen." });
|
||
}
|
||
|
||
return messages;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut einen ChatContext für Sub-Agents (Preparer/Review/Finalizer) auf,
|
||
/// wenn diese einen OpenAI-kompatiblen Provider nutzen.
|
||
/// </summary>
|
||
private ChatContext BuildSubAgentChatContext(SubAgentContext sub, string chainId, string sessionId, string apiToken)
|
||
{
|
||
// SystemPrompt als Text extrahieren (SubAgent hat List<object> mit { type, text } Blöcken)
|
||
var systemText = "";
|
||
if (sub.SystemPrompt != null)
|
||
{
|
||
var parts = new List<string>();
|
||
foreach (var block in sub.SystemPrompt)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (el.TryGetProperty("text", out var t))
|
||
parts.Add(t.GetString() ?? "");
|
||
}
|
||
systemText = string.Join("\n\n", parts);
|
||
}
|
||
|
||
return new ChatContext
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
ModelId = sub.ModelId,
|
||
Provider = sub.Provider,
|
||
Workspace = sub.Workspace,
|
||
AgentId = sub.AgentId,
|
||
SystemPromptText = systemText,
|
||
UseMemory = sub.UseMemory,
|
||
WebSearchVersion = sub.WebSearchVersion,
|
||
MaxIterations = 20,
|
||
ApiToken = apiToken
|
||
};
|
||
}
|
||
|
||
// ── Shared Non-Streaming Tool Loop ──
|
||
|
||
/// <summary>
|
||
/// Optionale Callbacks für RunNonStreamingToolLoop.
|
||
/// </summary>
|
||
private class NonStreamingLoopCallbacks
|
||
{
|
||
/// <summary>Wird zu Beginn jeder Iteration aufgerufen (z.B. Typing-Indicator).</summary>
|
||
public Func<int, Task> OnIteration { get; set; }
|
||
|
||
/// <summary>SSE-Response für Client-Events. Null = keine SSE-Events.</summary>
|
||
public HttpResponse SseResponse { get; set; }
|
||
public CancellationToken SseCt { get; set; }
|
||
|
||
/// <summary>SSE-Event-Prefix (z.B. "review" → "review_text", "review_tool").</summary>
|
||
public string SsePrefix { get; set; }
|
||
public string AgentName { get; set; }
|
||
|
||
/// <summary>Tag für DB-Records (z.B. "review", "finalizer").</summary>
|
||
public string Tag { get; set; }
|
||
|
||
/// <summary>Ob Text-Embeddings in die Queue geschrieben werden.</summary>
|
||
public bool EnqueueEmbeddings { get; set; }
|
||
|
||
/// <summary>
|
||
/// Optionaler Tool-Executor. Parameter: (toolName, input, ct) → result.
|
||
/// Bei Fehler Exception werfen. Wenn null, wird Standard-Executor verwendet.
|
||
/// </summary>
|
||
public Func<string, JsonElement, CancellationToken, Task<string>> ExecuteTool { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gemeinsamer Non-Streaming Tool-Loop für RunAgentChat und ChatNonStreaming.
|
||
/// Baut Payload, sendet API-Request, verarbeitet Content-Blöcke, führt Tools aus.
|
||
/// systemPrompt kann List<object> (Agent) oder string (ChatContext) sein.
|
||
/// </summary>
|
||
private async Task<(string text, int iterations)> RunNonStreamingToolLoop(
|
||
string modelId,
|
||
object systemPrompt,
|
||
ToolPayloadOptions toolOpts,
|
||
List<object> messages,
|
||
string chainId,
|
||
string sessionId,
|
||
string apiToken,
|
||
TokenUsage usage,
|
||
int baseIteration,
|
||
int maxIterations,
|
||
CancellationToken ct,
|
||
NonStreamingLoopCallbacks cb = null,
|
||
string effort = null)
|
||
{
|
||
cb ??= new NonStreamingLoopCallbacks();
|
||
int iterations = 0;
|
||
var finalText = new StringBuilder();
|
||
|
||
while (iterations < maxIterations)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
iterations++;
|
||
if (cb.OnIteration != null) await cb.OnIteration(iterations);
|
||
|
||
var payload = new Dictionary<string, object>
|
||
{
|
||
["model"] = modelId,
|
||
["max_tokens"] = 16384,
|
||
["messages"] = messages
|
||
};
|
||
|
||
// Reasoning-Effort (Opus 4.5+, Sonnet 4.6+). "" / "high" / null = Default weglassen.
|
||
if (!string.IsNullOrEmpty(effort) && effort != "high")
|
||
payload["output_config"] = new { effort };
|
||
|
||
if (systemPrompt is List<object> spBlocks && spBlocks.Count > 0)
|
||
{
|
||
// Cache-Breakpoint auf den letzten System-Prompt-Block (1h TTL).
|
||
ApplyCacheControlToLast(spBlocks);
|
||
payload["system"] = spBlocks;
|
||
}
|
||
else if (systemPrompt is string spText && !string.IsNullOrEmpty(spText))
|
||
{
|
||
// String-System-Prompt → in Block-Form mit cache_control konvertieren.
|
||
payload["system"] = new List<object>
|
||
{
|
||
new Dictionary<string, object>
|
||
{
|
||
["type"] = "text",
|
||
["text"] = spText,
|
||
["cache_control"] = new { type = "ephemeral", ttl = "1h" }
|
||
}
|
||
};
|
||
}
|
||
|
||
AddToolsToPayload(payload, toolOpts with { Iteration = iterations });
|
||
|
||
var client = CreateClient(apiToken);
|
||
var json = JsonSerializer.Serialize(payload);
|
||
LogPayload(json, sessionId, chainId, baseIteration + iterations);
|
||
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||
|
||
using var httpResp = await client.SendAsync(
|
||
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
|
||
HttpCompletionOption.ResponseHeadersRead,
|
||
ct
|
||
);
|
||
|
||
TryUpdateKeyMeta(httpResp, apiToken);
|
||
|
||
if (!httpResp.IsSuccessStatusCode)
|
||
{
|
||
var errBody = await httpResp.Content.ReadAsStringAsync(ct);
|
||
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = baseIteration + iterations,
|
||
Role = "system", Type = "error",
|
||
Content = errMsg, IsError = true, Tag = cb.Tag
|
||
});
|
||
if (cb.SseResponse != null)
|
||
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt);
|
||
break;
|
||
}
|
||
|
||
var respJson = await httpResp.Content.ReadAsStringAsync(ct);
|
||
var respDoc = JsonSerializer.Deserialize<JsonElement>(respJson);
|
||
|
||
if (respDoc.TryGetProperty("usage", out var u))
|
||
TrackUsage(usage, u);
|
||
|
||
var stopReason = respDoc.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : "";
|
||
var contentArr = respDoc.TryGetProperty("content", out var c) ? c : default;
|
||
if (contentArr.ValueKind != JsonValueKind.Array) break;
|
||
|
||
var assistantContent = new List<object>();
|
||
var toolResults = new List<object>();
|
||
|
||
foreach (var block in contentArr.EnumerateArray())
|
||
{
|
||
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||
if (blockType == "text")
|
||
{
|
||
var text = block.GetProperty("text").GetString() ?? "";
|
||
finalText.Append(text);
|
||
assistantContent.Add(new { type = "text", text });
|
||
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = baseIteration + iterations,
|
||
Role = "assistant", Type = "text",
|
||
Content = text, Tag = cb.Tag
|
||
});
|
||
if (cb.EnqueueEmbeddings)
|
||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
|
||
if (cb.SseResponse != null)
|
||
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { text, agentName = cb.AgentName }, cb.SseCt);
|
||
}
|
||
else if (blockType == "tool_use")
|
||
{
|
||
var toolId = block.GetProperty("id").GetString() ?? "";
|
||
var toolName = block.GetProperty("name").GetString() ?? "";
|
||
JsonElement inputEl;
|
||
try { inputEl = block.GetProperty("input"); }
|
||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||
|
||
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = inputEl });
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = baseIteration + iterations,
|
||
Role = "assistant", Type = "tool_use",
|
||
ToolName = toolName, ToolCallId = toolId,
|
||
Content = inputEl.ToString(), Tag = cb.Tag
|
||
});
|
||
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
string toolResult;
|
||
bool isError = false;
|
||
try
|
||
{
|
||
toolResult = cb.ExecuteTool != null
|
||
? await cb.ExecuteTool(toolName, inputEl, ct)
|
||
: await _toolService.ExecuteTool(toolName, inputEl, new ToolExecutionContext
|
||
{
|
||
Workspace = toolOpts.Workspace, MemoryService = _memoryService,
|
||
AgentId = toolOpts.AgentId, CancellationToken = ct,
|
||
VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg
|
||
});
|
||
}
|
||
catch (OperationCanceledException) { throw; }
|
||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
toolResults.Add(new
|
||
{
|
||
type = "tool_result",
|
||
tool_use_id = toolId,
|
||
content = TruncateToolResult(toolResult),
|
||
is_error = isError
|
||
});
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = baseIteration + iterations,
|
||
Role = "user", Type = "tool_result",
|
||
ToolCallId = toolId,
|
||
Content = toolResult, IsError = isError, Tag = cb.Tag
|
||
});
|
||
|
||
if (cb.SseResponse != null)
|
||
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
|
||
}
|
||
}
|
||
|
||
messages.Add(new { role = "assistant", content = assistantContent });
|
||
|
||
if (stopReason != "tool_use" || toolResults.Count == 0)
|
||
break;
|
||
|
||
messages.Add(new { role = "user", content = toolResults });
|
||
}
|
||
|
||
return (finalText.ToString(), iterations);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Agent-Chat (non-streaming API, SSE-Events für Client).
|
||
/// Wrapper um RunNonStreamingToolLoop für Review-/Finalizer-Agents.
|
||
/// </summary>
|
||
private Task<(string text, int iterations)> RunAgentChat(
|
||
HttpResponse response,
|
||
string modelId,
|
||
List<object> systemPrompt,
|
||
string agentId,
|
||
string agentWorkspace,
|
||
bool useMemory,
|
||
string webSearchVersion,
|
||
string agentToolChoice,
|
||
List<object> messages,
|
||
string chainId,
|
||
string sessionId,
|
||
string apiToken,
|
||
TokenUsage usage,
|
||
int baseIterations,
|
||
string agentType,
|
||
string agentName,
|
||
CancellationToken workCt,
|
||
CancellationToken sseCt,
|
||
string effort = null)
|
||
{
|
||
return RunNonStreamingToolLoop(
|
||
modelId, systemPrompt,
|
||
new ToolPayloadOptions
|
||
{
|
||
Workspace = agentWorkspace, AgentId = agentId,
|
||
UseMemory = useMemory, WebSearchVersion = webSearchVersion,
|
||
ToolChoice = agentToolChoice
|
||
},
|
||
messages, chainId, sessionId, apiToken, usage,
|
||
baseIterations, 20, workCt,
|
||
new NonStreamingLoopCallbacks
|
||
{
|
||
SseResponse = response, SseCt = sseCt,
|
||
SsePrefix = agentType, AgentName = agentName,
|
||
Tag = agentType
|
||
},
|
||
effort);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Führt einen Sub-Agent (Preparer/Review/Finalizer) über die Claude CLI aus.
|
||
/// Sammelt den gesamten Text und gibt ihn zurück. Wird als Fallback genutzt,
|
||
/// wenn kein API-Key vorhanden ist.
|
||
/// </summary>
|
||
private async Task<(string text, int iterations)> RunSubAgentViaCli(
|
||
HttpResponse response,
|
||
SubAgentContext sub,
|
||
List<object> historyMessages,
|
||
string chainId,
|
||
string sessionId,
|
||
string agentType,
|
||
CancellationToken workCt,
|
||
CancellationToken sseCt)
|
||
{
|
||
if (_cliService == null || !_cliService.IsAvailable)
|
||
return ("", 0);
|
||
|
||
// System-Prompt als Text extrahieren
|
||
var systemText = new StringBuilder();
|
||
if (sub.SystemPrompt != null)
|
||
{
|
||
foreach (var block in sub.SystemPrompt)
|
||
{
|
||
var json = JsonSerializer.Serialize(block);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (el.TryGetProperty("text", out var t))
|
||
systemText.AppendLine(t.GetString());
|
||
}
|
||
}
|
||
|
||
// Letzte User-Message als Prompt extrahieren
|
||
var prompt = ExtractLastUserText(historyMessages) ?? "";
|
||
|
||
// Konversations-History als Kontext
|
||
var historyContext = BuildHistoryContext(historyMessages);
|
||
if (!string.IsNullOrEmpty(historyContext))
|
||
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
|
||
|
||
var fullText = new StringBuilder();
|
||
|
||
var result = await _cliService.ChatStreamingAsync(
|
||
prompt,
|
||
async evt =>
|
||
{
|
||
switch (evt.Type)
|
||
{
|
||
case CliEventType.TextDelta:
|
||
fullText.Append(evt.Text);
|
||
await SseHelper.TryWriteSse(response, $"{agentType}_text",
|
||
new { text = evt.Text, agentName = sub.Agent?.Name ?? agentType }, sseCt);
|
||
break;
|
||
case CliEventType.ToolResult:
|
||
await SseHelper.TryWriteSse(response, $"{agentType}_tool",
|
||
new { name = evt.ToolName, result = evt.ToolResultContent, isError = evt.ToolResultIsError, agentName = sub.Agent?.Name ?? agentType }, sseCt);
|
||
break;
|
||
}
|
||
},
|
||
modelId: sub.ModelId,
|
||
systemPrompt: systemText.ToString(),
|
||
workspace: sub.Workspace,
|
||
hasWorkspace: !string.IsNullOrEmpty(sub.Workspace),
|
||
effort: sub.Effort,
|
||
cancellationToken: workCt
|
||
);
|
||
|
||
// Text in DB speichern
|
||
if (fullText.Length > 0)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 0,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = fullText.ToString(),
|
||
Tag = agentType
|
||
});
|
||
}
|
||
|
||
return (fullText.ToString(), 1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück.
|
||
/// Wrapper um RunNonStreamingToolLoop mit Chain-Management und Embedding-Enqueue.
|
||
/// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator).
|
||
/// </summary>
|
||
public async Task<string> ChatNonStreaming(
|
||
ChatContext ctx,
|
||
List<object> messages,
|
||
Func<int, Task> onIteration = null)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
SessionId = ctx.SessionId,
|
||
Model = ctx.ModelId
|
||
};
|
||
|
||
try
|
||
{
|
||
var (text, iterations) = await RunNonStreamingToolLoop(
|
||
ctx.ModelId, ctx.SystemPromptText,
|
||
new ToolPayloadOptions
|
||
{
|
||
Workspace = ctx.Workspace, AgentId = ctx.AgentId,
|
||
UseMemory = ctx.UseMemory, WebSearchVersion = ctx.WebSearchVersion,
|
||
ToolChoice = "auto"
|
||
},
|
||
messages, ctx.ChainId, ctx.SessionId, ctx.ApiToken, usage,
|
||
0, ctx.MaxIterations, CancellationToken.None,
|
||
new NonStreamingLoopCallbacks
|
||
{
|
||
OnIteration = onIteration,
|
||
EnqueueEmbeddings = true,
|
||
ExecuteTool = (name, input, ct) =>
|
||
_toolService.ExecuteTool(name, input, new ToolExecutionContext
|
||
{
|
||
Workspace = ctx.Workspace, MemoryService = _memoryService, AgentId = ctx.AgentId,
|
||
CancellationToken = ct, VectorSearch = _vectorSearch, EmbeddingBg = _embeddingBg,
|
||
Db = _db, ChatService = _chatService, AnthropicService = this, OpenAiService = _openAiService
|
||
})
|
||
},
|
||
ctx.Effort);
|
||
|
||
var finalStopReason = text.Length > 0 ? "end_turn" : "error";
|
||
await FinalizeChain(chain, usage, iterations, finalStopReason, sw);
|
||
return text;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = 0,
|
||
Role = "system", Type = "error",
|
||
Content = $"Fehler: {ex.Message}", IsError = true
|
||
});
|
||
await FinalizeChain(chain, usage, 0, "error", sw);
|
||
return "";
|
||
}
|
||
}
|
||
}
|