OpenAI-kompatible Provider (MiniMax, DeepSeek-R1, QwQ, Kimi K2, etc.) verloren bei
jeder neuen Iteration ihre eigene Chain-of-Thought, weil thinking-Messages aus der
DB-History rausgefiltert wurden. Folge: Modell kündigt Action an, ruft Tool aber
nicht auf → Stream endet mit finish_reason:stop ohne Tool-Call. Zusätzlich fehlte
der ask_user_choice-Intercept (Tool wurde als unbekannt abgelehnt) und es gab keine
Diagnose-Möglichkeit für unerwartete Stop-Verhalten.
- ChatService.BuildHistoryFromDb(includeThinking): neuer Parameter, fügt
thinking-Messages als {type:"thinking"}-Block in die Assistant-Message ein.
Text/Tool-Use derselben Iteration werden via AppendToLastMessage angehängt
→ ein Assistant-Block enthält [thinking, text, tool_use…].
- OpenAiService.ConvertMessage + StreamChat + ChatNonStreaming: Thinking als
<think>...</think>-Prefix dem Assistant-Content voranstellen (Per-Iteration und
beim DB-History-Aufbau). Konvention von MiniMax-Docs, wird auch von DeepSeek-R1,
QwQ, Kimi K2 verstanden.
- ChatEndpoint, SessionEndpoint, CronjobService, TelegramService, ToolService:
includeThinking je nach Provider durchgereicht. Anthropic-Pfad bleibt aus
(lehnt thinking-Blöcke ohne Signature ab).
- OpenAiService: ask_user_choice-Intercept im Streaming-Pfad (analog
AnthropicService.Streaming) – Tool nicht ausführen, SSE user_choice rausschicken,
mit stopReason awaiting_user_choice finalisieren und return.
- OpenAiService: MiniMax-SSE-Debug-Log in data/openai-debug/ (JSONL: Header +
Roh-data-Zeilen + Summary mit promptTokens/completionTokens/finishReason/
toolCalls/duration) – nur für provider=="minimax", nicht-blockierend.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
227 lines
8.3 KiB
C#
227 lines
8.3 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Cronos;
|
||
using Gott.Models;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace Gott.Services;
|
||
|
||
public class CronjobService : BackgroundService
|
||
{
|
||
private readonly DatabaseService _db;
|
||
private readonly ChatService _chatService;
|
||
private readonly AnthropicService _anthropicService;
|
||
private readonly OpenAiService _openAiService;
|
||
private readonly TelegramService _telegramService;
|
||
private readonly ILogger<CronjobService> _logger;
|
||
|
||
public CronjobService(
|
||
DatabaseService db,
|
||
ChatService chatService,
|
||
AnthropicService anthropicService,
|
||
OpenAiService openAiService,
|
||
TelegramService telegramService,
|
||
ILogger<CronjobService> logger)
|
||
{
|
||
_db = db;
|
||
_chatService = chatService;
|
||
_anthropicService = anthropicService;
|
||
_openAiService = openAiService;
|
||
_telegramService = telegramService;
|
||
_logger = logger;
|
||
}
|
||
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||
{
|
||
_logger.LogInformation("CronjobService gestartet");
|
||
|
||
while (!stoppingToken.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
await ProcessDueCronjobs();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Fehler beim Verarbeiten der Cronjobs");
|
||
}
|
||
|
||
// Jede Minute prüfen
|
||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||
}
|
||
}
|
||
|
||
private async Task ProcessDueCronjobs()
|
||
{
|
||
var dueJobs = await _db.GetDueCronjobs();
|
||
|
||
foreach (var job in dueJobs)
|
||
{
|
||
_logger.LogInformation("Cronjob '{Name}' ({Id}) wird ausgeführt", job.Name, job.CronjobId);
|
||
|
||
try
|
||
{
|
||
await ExecuteCronjob(job);
|
||
|
||
var nextRun = CalculateNextRun(job.CronExpression);
|
||
await _db.UpdateCronjobRun(job.CronjobId, "success", null, nextRun);
|
||
|
||
_logger.LogInformation("Cronjob '{Name}' erfolgreich. Nächste Ausführung: {Next}", job.Name, nextRun);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Cronjob '{Name}' fehlgeschlagen", job.Name);
|
||
|
||
var nextRun = CalculateNextRun(job.CronExpression);
|
||
await _db.UpdateCronjobRun(job.CronjobId, "error", ex.Message, nextRun);
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task ExecuteCronjob(CronjobRecord job)
|
||
{
|
||
// Chat-Context vorbereiten (wie bei normalem Chat)
|
||
var ctx = await _chatService.PrepareContext(job.SessionId, job.UserId);
|
||
if (ctx == null)
|
||
throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert");
|
||
|
||
// API-Key des Cronjob-Owners für diesen Agent auflösen
|
||
var userApiKey = await _db.GetAgentApiKeyRecord(job.UserId, ctx.AgentId);
|
||
if (userApiKey == null)
|
||
throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen (User {job.UserId})");
|
||
ctx.ApiToken = userApiKey.ApiKey;
|
||
|
||
// History aus DB laden. includeThinking=true für OpenAI-kompatible Provider →
|
||
// <think>...</think>-Prefix wird dem Assistant-Text vorangestellt.
|
||
var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider));
|
||
|
||
// User-Nachricht hinzufügen
|
||
var userContent = new List<object> { new { type = "text", text = job.Prompt } };
|
||
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = userContent });
|
||
|
||
// User-Nachricht in DB speichern
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 0,
|
||
Role = "user",
|
||
Type = "text",
|
||
Content = job.Prompt
|
||
});
|
||
|
||
// Non-Streaming Chat ausführen – Provider-Weiche
|
||
string responseText;
|
||
if (!string.IsNullOrEmpty(ctx.Provider))
|
||
responseText = await _openAiService.ChatNonStreaming(ctx, history);
|
||
else
|
||
responseText = await _anthropicService.ChatNonStreaming(ctx, history);
|
||
|
||
// Session-Timestamp aktualisieren
|
||
await _db.UpdateSessionTimestamp(job.SessionId);
|
||
|
||
// Telegram-Benachrichtigung auf der Arbeits-Session senden, falls sie Telegram hat
|
||
if (!string.IsNullOrEmpty(responseText))
|
||
{
|
||
await _telegramService.SendNotification(job.SessionId,
|
||
$"⏰ Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}");
|
||
}
|
||
|
||
// Benachrichtigungs-Session: Zusammenfassung in eine andere Session schreiben
|
||
if (!string.IsNullOrEmpty(job.NotifySessionId) && job.NotifySessionId != job.SessionId)
|
||
{
|
||
await SendCronjobNotification(job, responseText);
|
||
}
|
||
}
|
||
|
||
private async Task SendCronjobNotification(CronjobRecord job, string responseText)
|
||
{
|
||
try
|
||
{
|
||
var notifyCtx = await _chatService.PrepareContext(job.NotifySessionId, job.UserId);
|
||
if (notifyCtx == null)
|
||
{
|
||
_logger.LogWarning("Notification-Session {SessionId} für Cronjob '{Name}' nicht gefunden", job.NotifySessionId, job.Name);
|
||
return;
|
||
}
|
||
|
||
// API-Key des Cronjob-Owners für Notify-Agent auflösen
|
||
var notifyApiKey = await _db.GetAgentApiKeyRecord(job.UserId, notifyCtx.AgentId);
|
||
if (notifyApiKey == null)
|
||
{
|
||
_logger.LogWarning("Kein API-Key für Agent {AgentId} (Cronjob '{Name}' Notification)", notifyCtx.AgentId, job.Name);
|
||
return;
|
||
}
|
||
notifyCtx.ApiToken = notifyApiKey.ApiKey;
|
||
|
||
// Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben
|
||
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
|
||
|
||
// In Notify-Session als Nachricht speichern
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = notifyCtx.ChainId,
|
||
Iteration = 0,
|
||
Role = "user",
|
||
Type = "text",
|
||
Content = summary
|
||
});
|
||
|
||
// Nur die Benachrichtigung als History senden (nicht volle Session-History,
|
||
// da diese mit einer assistant-Nachricht enden könnte → prefill-Fehler)
|
||
var notifyHistory = new List<object>
|
||
{
|
||
new Dictionary<string, object>
|
||
{
|
||
["role"] = "user",
|
||
["content"] = new List<object> { new { type = "text", text = summary } }
|
||
}
|
||
};
|
||
string notifyResponse;
|
||
if (!string.IsNullOrEmpty(notifyCtx.Provider))
|
||
notifyResponse = await _openAiService.ChatNonStreaming(notifyCtx, notifyHistory);
|
||
else
|
||
notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory);
|
||
|
||
await _db.UpdateSessionTimestamp(job.NotifySessionId);
|
||
|
||
// Telegram auf Notify-Session senden
|
||
if (!string.IsNullOrEmpty(notifyResponse))
|
||
{
|
||
await _telegramService.SendNotification(job.NotifySessionId,
|
||
$"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{notifyResponse}");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Fehler beim Senden der Cronjob-Benachrichtigung an Session {SessionId}", job.NotifySessionId);
|
||
}
|
||
}
|
||
|
||
private static string Truncate(string text, int maxLength)
|
||
{
|
||
if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? "";
|
||
return text.Substring(0, maxLength) + "…";
|
||
}
|
||
|
||
public static string CalculateNextRun(string cronExpression)
|
||
{
|
||
try
|
||
{
|
||
var cron = CronExpression.Parse(cronExpression, CronFormat.Standard);
|
||
// Cron-Expression in Server-Lokalzeit interpretieren
|
||
var next = cron.GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
|
||
return next?.UtcDateTime.ToString("o") ?? "";
|
||
}
|
||
catch
|
||
{
|
||
return "";
|
||
}
|
||
}
|
||
|
||
|
||
}
|