Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+8 -28
View File
@@ -29,7 +29,7 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default)
{
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId);
@@ -37,29 +37,18 @@ public class NovelAgentService(
return await db.Conversations
.Where(c => c.ProjectId == projectId)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct);
}
/// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ConversationDto?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{
Guard.Default(conversationId, nameof(conversationId));
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
var conversation = await FindConversationAsync(conversationId, ct);
if (conversation is null)
{
return null;
}
return new ConversationDto(
conversation.Id,
conversation.ProjectId,
conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
conversation.UpdatedAt);
return await FindConversationAsync(conversationId, ct);
}
/// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
@@ -84,7 +73,7 @@ public class NovelAgentService(
/// Sends a message to the agent and runs it to completion, executing any tools it
/// calls along the way. Returns the assistant's final turn.
/// </summary>
public async Task<AgentTurnDto> SendMessageAsync(
public async Task<AgentMessage> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -109,7 +98,7 @@ public class NovelAgentService(
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallDto>();
var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
@@ -146,7 +135,7 @@ public class NovelAgentService(
"Agent tool {Tool} on project {ProjectId} {Outcome}",
call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), outcome.Content));
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
}
@@ -170,7 +159,7 @@ public class NovelAgentService(
toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
ct);
return new AgentTurnDto(conversation.Id, ToDto(reply));
return reply;
}
/// <summary>
@@ -307,15 +296,6 @@ public class NovelAgentService(
""";
}
private static AgentMessageDto ToDto(AgentMessage message) => new(
message.Id,
message.Role,
message.Content,
message.ToolCallsJson is null
? []
: JsonSerializer.Deserialize<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [],
message.CreatedAt);
/// <summary>Derives a conversation title from its opening message.</summary>
private static string Summarise(string message)
{