feat: send_to_session tool – agent-to-agent messaging

- Neues Tool send_to_session: Agent kann Nachrichten in andere Sessions schicken
- Ziel-Session per ID oder Name (case-insensitive) auffindbar
- User-Message wird nicht in DB gespeichert (wie Cronjob) – keine Bubble im Frontend
- Nachricht wird als '[Nachricht von Agent "X"]' kontextualisiert injiziert
- Sicherheitscheck: Ziel-Session muss demselben User gehören, keine eigene Session
- Zirkuläre Abhängigkeit ChatService ↔ AnthropicService via SetChatService() gelöst
- tool-ui.js: Display-Name für send_to_session
This commit is contained in:
Meleki committed 2026-03-11 12:56:36 +01:00
1 parent 20d891c9ef
commit c18b94cd4e
5 files changed
+99 -11

No files matched your search

+1 -1
View File
@@ -208,7 +208,7 @@ public static class ChatEndpoint
try try
{ {
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr);
} }
finally finally
{ {
+6 -2
View File
@@ -57,12 +57,16 @@ var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false); var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false);
var payloadLoggingGzip = builder.Configuration.GetValue<bool>("PayloadLoggingGzip", false); var payloadLoggingGzip = builder.Configuration.GetValue<bool>("PayloadLoggingGzip", false);
builder.Services.AddSingleton<AnthropicService>(sp => builder.Services.AddSingleton<AnthropicService>(sp =>
new AnthropicService( {
var svc = new AnthropicService(
sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IHttpClientFactory>(),
anthropicToken, toolService, dbService, memoryService, anthropicToken, toolService, dbService, memoryService,
payloadLogging, payloadLoggingGzip, payloadLogging, payloadLoggingGzip,
vectorSearch: vectorSearchService, vectorSearch: vectorSearchService,
embeddingBg: embeddingBgService)); embeddingBg: embeddingBgService);
svc.SetChatService(chatService);
return svc;
});
// Telegram Bot (pro Session – Token aus Session-Config) // Telegram Bot (pro Session – Token aus Session-Config)
var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100); var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100);
+18 -5
View File
@@ -24,6 +24,7 @@ public class AnthropicService
private readonly bool _payloadLoggingGzip; private readonly bool _payloadLoggingGzip;
private readonly VectorSearchService _vectorSearch; private readonly VectorSearchService _vectorSearch;
private readonly EmbeddingBackgroundService _embeddingBg; private readonly EmbeddingBackgroundService _embeddingBg;
private ChatService _chatService;
public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
{ {
@@ -38,6 +39,15 @@ public class AnthropicService
_embeddingBg = embeddingBg; _embeddingBg = embeddingBg;
} }
/// <summary>
/// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden
/// (ChatService → AnthropicService → ChatService).
/// </summary>
public void SetChatService(ChatService chatService)
{
_chatService = chatService;
}
public async Task StreamChat( public async Task StreamChat(
HttpResponse response, HttpResponse response,
string modelId, string modelId,
@@ -47,6 +57,7 @@ public class AnthropicService
string workspace, string workspace,
string sessionId = null, string sessionId = null,
string agentId = null, string agentId = null,
string userId = null,
bool useMemory = true, bool useMemory = true,
string webSearchVersion = null, string webSearchVersion = null,
int maxIterations = 100, int maxIterations = 100,
@@ -183,7 +194,7 @@ public class AnthropicService
return; return;
} }
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId); var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId);
lastStopReason = stopReason; lastStopReason = stopReason;
promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten
@@ -1204,7 +1215,8 @@ public class AnthropicService
CancellationToken workCt = default, CancellationToken workCt = default,
CancellationToken sseCt = default, CancellationToken sseCt = default,
RemoteConnectionManager remoteMgr = null, RemoteConnectionManager remoteMgr = null,
string sessionId = null) string sessionId = null,
string userId = null)
{ {
var stream = await httpResp.Content.ReadAsStreamAsync(workCt); var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
// Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht. // Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht.
@@ -1386,7 +1398,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; bool isError = false;
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg); } try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: userId); }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1593,7 +1605,8 @@ public class AnthropicService
var hasWorkspace = !string.IsNullOrEmpty(workspace); var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId);
var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0; var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0;
var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch; var includeSendToSession = _db != null && _chatService != null;
var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch || includeSendToSession;
var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion);
if (!hasTools && !hasWebSearch) return; if (!hasTools && !hasWebSearch) return;
@@ -1601,7 +1614,7 @@ public class AnthropicService
var tools = new List<object>(); var tools = new List<object>();
if (hasTools) if (hasTools)
{ {
var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch); var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch, includeSendToSession: includeSendToSession);
if (planMode) if (planMode)
allTools = _toolService.FilterReadOnlyTools(allTools); allTools = _toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools); tools.AddRange(allTools);
+72 -2
View File
@@ -54,7 +54,7 @@ public class ToolService
return filtered.ToArray(); return filtered.ToArray();
} }
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false) public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false, bool includeSendToSession = false)
{ {
var allTools = new List<object>(); var allTools = new List<object>();
if (includeUserChoice) if (includeUserChoice)
@@ -67,9 +67,30 @@ public class ToolService
allTools.Add(GetMemorySearchTool()); allTools.Add(GetMemorySearchTool());
if (includeRemote) if (includeRemote)
allTools.Add(GetRemoteExecuteTool()); allTools.Add(GetRemoteExecuteTool());
if (includeSendToSession)
allTools.Add(GetSendToSessionTool());
return allTools.ToArray(); return allTools.ToArray();
} }
private object GetSendToSessionTool()
{
return new
{
name = "send_to_session",
description = "Schreibt eine Nachricht in eine andere Session und löst dort eine Antwort des Agents aus. Nützlich um Nachrichten, Grüße oder Aufgaben an einen anderen Agenten in einer anderen Session zu übermitteln.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["session_id_or_name"] = new { type = "string", description = "Die ID oder der Name der Ziel-Session" },
["message"] = new { type = "string", description = "Die Nachricht die in die Ziel-Session geschrieben wird" }
},
required = new[] { "session_id_or_name", "message" }
}
};
}
private object GetAskUserChoiceTool() private object GetAskUserChoiceTool()
{ {
return new return new
@@ -356,7 +377,7 @@ public class ToolService
}; };
} }
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, string userId = null)
{ {
switch (name) switch (name)
{ {
@@ -706,6 +727,55 @@ public class ToolService
} }
return sb.Length > 0 ? sb.ToString() : "(keine Ausgabe)"; return sb.Length > 0 ? sb.ToString() : "(keine Ausgabe)";
} }
case "send_to_session":
{
if (db == null || chatService == null || anthropicService == null)
return "Fehler: send_to_session nicht verfügbar";
var sessionIdOrName = input.GetProperty("session_id_or_name").GetString() ?? "";
var message = input.GetProperty("message").GetString() ?? "";
if (string.IsNullOrEmpty(sessionIdOrName))
return "Fehler: session_id_or_name fehlt";
if (string.IsNullOrEmpty(message))
return "Fehler: message fehlt";
// Ziel-Session per ID oder Name suchen
var targetSession = await db.GetSession(sessionIdOrName);
if (targetSession == null && !string.IsNullOrEmpty(userId))
{
// Fallback: per Name suchen (case-insensitive)
var allSessions = await db.GetSessionsWithStats(userId);
targetSession = allSessions
.FirstOrDefault(s => string.Equals(s.Session.Name, sessionIdOrName, StringComparison.OrdinalIgnoreCase))
?.Session;
}
if (targetSession == null)
return $"Fehler: Session '{sessionIdOrName}' nicht gefunden";
if (!string.IsNullOrEmpty(userId) && targetSession.UserId != userId)
return "Fehler: Zugriff auf diese Session nicht erlaubt";
var targetSessionId = targetSession.SessionId;
if (targetSessionId == sessionId)
return "Fehler: Kann keine Nachricht an die eigene Session senden";
// Context für Ziel-Session vorbereiten (analog zu CronjobService)
var ctx = await chatService.PrepareContext(targetSessionId, targetSession.UserId);
if (ctx == null)
return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)";
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren
var history = await chatService.BuildHistoryFromDb(targetSessionId, promptBlocks: ctx.PromptBlocks);
var senderAgent = agentId != null ? await db.GetAgent(agentId) : null;
var senderName = senderAgent?.Name ?? agentId ?? "Unbekannter Agent";
var senderInfo = $"[Nachricht von Agent \"{senderName}\"]: {message}";
var userContent = new List<object> { new { type = "text", text = senderInfo } };
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = userContent });
await anthropicService.ChatNonStreaming(ctx, history);
await db.UpdateSessionTimestamp(targetSessionId);
return $"Nachricht wurde erfolgreich an Session \"{targetSession.Name}\" gesendet. Der Agent dort hat geantwortet.";
}
default: default:
return $"Fehler: Unbekanntes Tool: {name}"; return $"Fehler: Unbekanntes Tool: {name}";
} }
+2 -1
View File
@@ -8,7 +8,8 @@ const TOOL_DISPLAY_NAMES = {
memory_write: 'Erinnerungen festhalten', memory_write: 'Erinnerungen festhalten',
web_search: 'Web-Suche', web_search: 'Web-Suche',
remote_execute: 'Remote-Befehl', remote_execute: 'Remote-Befehl',
memory_search: 'Erinnerungen durchsuchen' memory_search: 'Erinnerungen durchsuchen',
send_to_session: 'Nachricht an Session senden'
}; };
function isToolVisible(toolName) { function isToolVisible(toolName) {