using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; using Novelly.Api.Projects; namespace Novelly.Api.Agent; public class NovelAgentService( INovelDbContext db, IAgentModelClient model, NovelAgentToolset toolset, IOptions options, ILogger logger, IModelValidator sendMessageValidator) { private static readonly JsonSerializerOptions JsonOptions = new() { Converters = { new JsonStringEnumConverter() } }; private readonly AgentOptions _options = options.Value; public async Task> ListConversationsAsync( Guid projectId, CancellationToken ct = default) { logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId); return await db.Conversations .Where(c => c.ProjectId == projectId) .OrderByDescending(c => c.UpdatedAt) .Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) .ToListAsync(ct); } public async Task GetConversationAsync(Guid conversationId, CancellationToken ct = default) { Guard.Default(conversationId, nameof(conversationId)); logger.LogInformation("Getting agent conversation {ConversationId}", conversationId); return await FindConversationAsync(conversationId, ct); } public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) { Guard.Default(conversationId, nameof(conversationId)); logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId); var conversation = await FindConversationAsync(conversationId, ct); if (conversation is null) { return false; } db.Conversations.Remove(conversation); await db.SaveChangesAsync(ct); return true; } public async Task SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); Guard.Null(request, nameof(request)); sendMessageValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation( "Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}", projectId, request.ConversationId, request.Message.Length); var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct); if (project is null) { logger.LogWarning("Project {ProjectId} not found", projectId); return null; } var conversation = request.ConversationId is { } id ? await FindConversationAsync(id, ct) : StartConversation(projectId, request.Message); if (conversation is null) return null; await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); var systemPrompt = BuildSystemPrompt(project); var transcript = BuildTranscript(conversation); var toolCalls = new List(); var text = new StringBuilder(); for (var iteration = 0; iteration < _options.MaxIterations; iteration++) { logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId); var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); foreach (var block in response.Content.OfType()) { if (!string.IsNullOrWhiteSpace(block.Text)) text.AppendLine(block.Text.Trim()); } var requestedTools = response.Content.OfType().ToList(); if (requestedTools.Count == 0) break; transcript.Add(AgentChatMessage.Assistant(response.Content)); var results = new List(); foreach (var call in requestedTools) { var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct); logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded"); toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content)); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); } transcript.Add(AgentChatMessage.User([.. results])); if (iteration != _options.MaxIterations - 1) continue; logger.LogWarning("Agent hit the {Max}-iteration ceiling on project {ProjectId}", _options.MaxIterations, projectId); text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._"); } var reply = await AppendMessageAsync( conversation, AgentRole.Assistant, text.ToString().TrimEnd(), toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null, ct); return reply; } private async Task AppendMessageAsync(AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct) { logger.LogDebug("Appending {Role} message to conversation {ConversationId}, content length {ContentLength}", role, conversation.Id, content.Length); var message = new AgentMessage { ConversationId = conversation.Id, Role = role, Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1, Content = content, ToolCallsJson = toolCallsJson }; db.AgentMessages.Add(message); conversation.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); if (!conversation.Messages.Contains(message)) conversation.Messages.Add(message); logger.LogDebug("Appended {Role} message {MessageId} to conversation {ConversationId}", role, message.Id, conversation.Id); return message; } private AgentConversation StartConversation(Guid projectId, string firstMessage) { logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId); var conversation = new AgentConversation { ProjectId = projectId, Title = Summarise(firstMessage) }; db.Conversations.Add(conversation); logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId); return conversation; } private async Task FindConversationAsync(Guid conversationId, CancellationToken ct) { logger.LogDebug("Finding agent conversation {ConversationId}", conversationId); var conversation = await db.Conversations .Include(c => c.Messages) .FirstOrDefaultAsync(c => c.Id == conversationId, ct); if (conversation is null) { logger.LogWarning("AgentConversation {ConversationId} not found", conversationId); return conversation; } logger.LogDebug("Found agent conversation {ConversationId}", conversationId); return conversation; } private static List BuildTranscript(AgentConversation conversation) => [ .. conversation.Messages .Where(m => !string.IsNullOrWhiteSpace(m.Content)) .OrderBy(m => m.Sequence) .Select(m => new AgentChatMessage( m.Role == AgentRole.User ? "user" : "assistant", [new AgentTextBlock(m.Content)])) ]; private static string BuildSystemPrompt(Project project) { var brief = new StringBuilder(); brief.AppendLine($"Title: {project.Title}"); if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}"); if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}"); if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words"); return $""" You are a developmental editor and writing partner embedded in the software the writer is using to plan their novel. You have tools that read and write the project's real data: the brief, character dossiers, the outline (beats) and each chapter's drafted prose. The project you are working on: {brief} Working principles: - Read before you write. Call get_project_brief, get_outline, or list_characters to ground yourself rather than assuming what is already there. - The book is the writer's. Ask about the choices that define the story — what a character wants, what the ending costs them — instead of deciding for them. - Do not invent biographical detail to fill an empty field. An unanswered question in a dossier is more useful than a plausible-sounding fabrication. - When you do have enough to act, act. Make the edit and say what you changed in a sentence; do not narrate every tool call or ask permission for routine work. - Prefer structural help — where a beat lands, whether a want and a need are genuinely in tension, what the outline is missing — over line-level polish, unless the writer asks for prose. - When drafting a chapter's prose, match the voice already established in the project. Write the chapter, then stop; do not append notes about your choices. - Destructive operations (deleting outline nodes) need the writer's explicit go-ahead first. Keep replies short. Lead with the outcome, then the reasoning if it earns its place. """; } private static string Summarise(string message) { var trimmed = message.Trim().ReplaceLineEndings(" "); return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "..."); } }