Renames the domain concept from Project to Novel throughout the backend (entities, DTOs, services, endpoints, ProjectAccessService/Permission, ProjectId foreign keys), MCP server (tool names and routes), and the React/Vite frontend (types, hooks, routes, components). Adds a new EF Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to preserve existing data instead of dropping/recreating tables. Updates CLAUDE.md's structure section to reference Novels/ instead of Projects/.
668 lines
34 KiB
C#
668 lines
34 KiB
C#
using System.Text.Json;
|
|
using Novelly.Api.Beats;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Novels;
|
|
using Novelly.Api.Questions;
|
|
using Novelly.Api.Tags;
|
|
|
|
namespace Novelly.Api.Agent;
|
|
|
|
public record AgentToolResult(string Content, bool IsError);
|
|
|
|
internal record ToolNotFound(string Entity, Guid Id)
|
|
{
|
|
public string Message => $"{Entity} '{Id}' was not found.";
|
|
}
|
|
|
|
public record AgentTool(
|
|
string Name,
|
|
string Description,
|
|
JsonElement InputSchema,
|
|
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
|
|
|
public class NovelAgentToolset(
|
|
NovelService novels,
|
|
CharacterService characters,
|
|
CharacterArcService arcs,
|
|
ChapterService chapters,
|
|
BeatService beats,
|
|
TagService tags,
|
|
OpenQuestionService questions,
|
|
ILogger<NovelAgentToolset> logger)
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
{
|
|
WriteIndented = false,
|
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
|
};
|
|
|
|
private Dictionary<string, AgentTool>? _byName;
|
|
|
|
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
|
|
|
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
|
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
|
|
|
public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
|
|
{
|
|
if (!ByName.TryGetValue(name, out var tool))
|
|
{
|
|
logger.LogWarning("Agent requested unknown tool {Tool}", name);
|
|
return new AgentToolResult($"No such tool: '{name}'.", true);
|
|
}
|
|
|
|
logger.LogDebug("Running tool {Tool} for novel {NovelId}", name, novelId);
|
|
|
|
try
|
|
{
|
|
var result = await tool.Handler(novelId, input, ct);
|
|
|
|
if (result is ToolNotFound notFound)
|
|
{
|
|
logger.LogWarning("Tool {Tool} for novel {NovelId} found no {Entity} {EntityId}", name, novelId, notFound.Entity, notFound.Id);
|
|
return new AgentToolResult(notFound.Message, true);
|
|
}
|
|
|
|
logger.LogDebug("Tool {Tool} for novel {NovelId} succeeded", name, novelId);
|
|
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid argument", name, novelId);
|
|
return new AgentToolResult(ex.Message, true);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid operation", name, novelId);
|
|
return new AgentToolResult(ex.Message, true);
|
|
}
|
|
}
|
|
|
|
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
|
|
await lookup as object ?? new ToolNotFound(entity, id);
|
|
|
|
private static async Task<object> OrNotFound<TEntity, TResponse>(
|
|
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
|
|
await lookup is { } value ? map(value)! : new ToolNotFound(entity, id);
|
|
|
|
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
|
|
await delete ? new { deleted = true } : new ToolNotFound(entity, id);
|
|
|
|
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
|
|
|
private IEnumerable<AgentTool> Build()
|
|
{
|
|
yield return new AgentTool(
|
|
"get_novel_brief",
|
|
"Read the novel's title, logline, synopsis, genre, notes and word-count target. "
|
|
+ "Call this first in a conversation to ground yourself in what the book is.",
|
|
new JsonSchemaBuilder().Build(),
|
|
async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId));
|
|
|
|
yield return new AgentTool(
|
|
"update_novel_brief",
|
|
"Revise the novel's top-level fields. Only the fields you supply change; "
|
|
+ "pass an empty string to clear a field.",
|
|
new JsonSchemaBuilder()
|
|
.Str("title", "New title.")
|
|
.Str("author", "Author name.")
|
|
.Str("genre", "Genre or category.")
|
|
.Str("logline", "One-sentence pitch.")
|
|
.Str("synopsis", "Paragraph-length summary of the whole book.")
|
|
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
|
.Int("target_word_count", "Target manuscript length in words.")
|
|
.Build(),
|
|
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
|
JsonInput.String(input, "title"),
|
|
JsonInput.String(input, "author"),
|
|
JsonInput.String(input, "genre"),
|
|
JsonInput.String(input, "logline"),
|
|
JsonInput.String(input, "synopsis"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
|
|
|
|
yield return new AgentTool(
|
|
"list_characters",
|
|
"List every character in the novel with their full dossiers.",
|
|
new JsonSchemaBuilder().Build(),
|
|
async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()));
|
|
|
|
yield return new AgentTool(
|
|
"create_character",
|
|
"Add a character dossier. Name is the only requirement — leave fields blank when "
|
|
+ "the writer has not decided them yet rather than inventing detail.",
|
|
CharacterSchema(includeName: true, nameRequired: true).Build(),
|
|
async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest(
|
|
JsonInput.RequiredString(input, "name"),
|
|
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
|
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
|
JsonInput.String(input, "age"),
|
|
JsonInput.String(input, "pronouns"),
|
|
JsonInput.String(input, "occupation"),
|
|
JsonInput.String(input, "appearance"),
|
|
JsonInput.String(input, "personality"),
|
|
JsonInput.String(input, "backstory"),
|
|
JsonInput.String(input, "want"),
|
|
JsonInput.String(input, "need"),
|
|
JsonInput.String(input, "internal_conflict"),
|
|
JsonInput.String(input, "external_conflict"),
|
|
JsonInput.String(input, "arc_summary"),
|
|
JsonInput.String(input, "voice"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Strings(input, "tags"),
|
|
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
|
|
|
|
yield return new AgentTool(
|
|
"update_character",
|
|
"Revise an existing character dossier. Only the fields you supply change.",
|
|
CharacterSchema(includeName: true, nameRequired: false)
|
|
.Str("character_id", "Id of the character to update.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
|
return await OrNotFound(characters.UpdateAsync(
|
|
characterId,
|
|
new UpdateCharacterRequest(
|
|
JsonInput.String(input, "name"),
|
|
JsonInput.Enum<CharacterRole>(input, "role"),
|
|
JsonInput.Enum<CharacterImportance>(input, "importance"),
|
|
JsonInput.String(input, "age"),
|
|
JsonInput.String(input, "pronouns"),
|
|
JsonInput.String(input, "occupation"),
|
|
JsonInput.String(input, "appearance"),
|
|
JsonInput.String(input, "personality"),
|
|
JsonInput.String(input, "backstory"),
|
|
JsonInput.String(input, "want"),
|
|
JsonInput.String(input, "need"),
|
|
JsonInput.String(input, "internal_conflict"),
|
|
JsonInput.String(input, "external_conflict"),
|
|
JsonInput.String(input, "arc_summary"),
|
|
JsonInput.String(input, "voice"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Strings(input, "tags"),
|
|
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"link_character_identity",
|
|
"Record that a character is really another character — e.g. one introduced under one name "
|
|
+ "who is later revealed to be a character already in the novel under another name. Both "
|
|
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
|
|
new JsonSchemaBuilder()
|
|
.Str("character_id", "Id of the character being revealed as someone else.", required: true)
|
|
.Str("same_character_as_id", "Id of the character this one really is.", required: true)
|
|
.Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.")
|
|
.Str("note", "Context on the reveal, e.g. how and why the disguise held.")
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
|
return await OrNotFound(characters.LinkIdentityAsync(
|
|
characterId,
|
|
new LinkCharacterIdentityRequest(
|
|
JsonInput.RequiredGuid(input, "same_character_as_id"),
|
|
JsonInput.Guid(input, "revealed_in_chapter_id"),
|
|
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"unlink_character_identity",
|
|
"Remove a character's identity link, restoring it to its own separate identity.",
|
|
new JsonSchemaBuilder()
|
|
.Str("character_id", "Id of the character to unlink.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
|
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"get_chapter_outline",
|
|
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
|
|
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)).Select(b => b.ToResponse()));
|
|
|
|
yield return new AgentTool(
|
|
"create_beat",
|
|
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
|
|
+ "handle, not a sentence; the detail belongs in what_happened and whats_next.",
|
|
BeatSchema()
|
|
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
|
|
.Str("title", "Three to five words naming the beat.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
|
return await OrNotFound(beats.CreateAsync(
|
|
chapterId,
|
|
new CreateBeatRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.Guids(input, "character_ids"),
|
|
JsonInput.String(input, "what_happened"),
|
|
JsonInput.String(input, "whats_next"),
|
|
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"update_beat",
|
|
"Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
|
|
+ "the beat's tags outright, so include the ones you want to keep.",
|
|
BeatSchema()
|
|
.Str("beat_id", "Id of the beat to update.", required: true)
|
|
.Str("title", "Three to five words naming the beat.")
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var beatId = JsonInput.RequiredGuid(input, "beat_id");
|
|
return await OrNotFound(beats.UpdateAsync(
|
|
beatId,
|
|
new UpdateBeatRequest(
|
|
JsonInput.String(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.Guids(input, "character_ids"),
|
|
JsonInput.String(input, "what_happened"),
|
|
JsonInput.String(input, "whats_next"),
|
|
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"delete_beat",
|
|
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
|
|
new JsonSchemaBuilder()
|
|
.Str("beat_id", "Id of the beat to delete.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var beatId = JsonInput.RequiredGuid(input, "beat_id");
|
|
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"reorder_beats",
|
|
"Renumber a chapter's beats to match the order given. List every beat id in the "
|
|
+ "order you want; any you leave out keep their relative position at the end.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
|
|
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
|
return await OrNotFound(beats.ReorderAsync(
|
|
chapterId,
|
|
new ReorderBeatsRequest(
|
|
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
|
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"assign_character_to_beats",
|
|
"Add a character to several beats at once. Leaves each beat's existing characters and "
|
|
+ "other fields alone — this only adds, it never removes.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Id of the chapter the beats belong to.", required: true)
|
|
.Str("character_id", "Id of the character to add.", required: true)
|
|
.StringArray("beat_ids", "Ids of the beats to add the character to.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
|
return await OrNotFound(beats.AssignCharacterAsync(
|
|
chapterId,
|
|
new AssignCharacterToBeatsRequest(
|
|
JsonInput.RequiredGuid(input, "character_id"),
|
|
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
|
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"move_beats",
|
|
"Move one or more beats from one chapter to another, appending them to the target "
|
|
+ "chapter's end in the order given.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Id of the beats' current chapter.", required: true)
|
|
.Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true)
|
|
.StringArray("beat_ids", "Ids of the beats to move.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
|
return await OrNotFound(beats.MoveAsync(
|
|
chapterId,
|
|
new MoveBeatsRequest(
|
|
JsonInput.RequiredGuid(input, "target_chapter_id"),
|
|
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
|
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"list_tags",
|
|
"List the novel's tags with how many characters, chapters and beats carry each. "
|
|
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
|
|
new JsonSchemaBuilder().Build(),
|
|
async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
|
|
|
|
yield return new AgentTool(
|
|
"get_tag_references",
|
|
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
|
|
+ "trace a motif or a thread through the book.",
|
|
new JsonSchemaBuilder()
|
|
.Str("tag_id", "Id of the tag to trace.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var tagId = JsonInput.RequiredGuid(input, "tag_id");
|
|
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"list_chapters",
|
|
"List the novel's chapters in manuscript order with beat and word counts.",
|
|
new JsonSchemaBuilder().Build(),
|
|
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
|
|
|
|
yield return new AgentTool(
|
|
"get_chapter",
|
|
"Read one chapter in full: its outline (beats) and its drafted prose.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
|
return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"create_chapter",
|
|
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
|
|
new JsonSchemaBuilder()
|
|
.Str("title", "Chapter title.", required: true)
|
|
.Int("number", "Position in the manuscript, 1-based.")
|
|
.Str("summary", "What the chapter covers.")
|
|
.Str("setting", "Where and when the chapter takes place.")
|
|
.Str("notes", "Anything else worth recording.")
|
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
|
.Int("target_word_count", "Target length in words.")
|
|
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
|
.Build(),
|
|
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "number"),
|
|
JsonInput.String(input, "summary"),
|
|
JsonInput.String(input, "setting"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
|
JsonInput.Int(input, "target_word_count"),
|
|
JsonInput.String(input, "prose"),
|
|
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
|
|
|
|
yield return new AgentTool(
|
|
"update_chapter",
|
|
"Revise a chapter's title, number, summary, setting, notes, status or drafted "
|
|
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
|
+ "word count is recomputed automatically.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
|
.Str("title", "New title.")
|
|
.Int("number", "Position in the manuscript.")
|
|
.Str("summary", "What the chapter covers.")
|
|
.Str("setting", "Where and when the chapter takes place.")
|
|
.Str("notes", "Anything else worth recording.")
|
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
|
.Int("target_word_count", "Target length in words.")
|
|
.Str("prose", "The chapter's drafted text, in markdown.")
|
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
|
return await OrNotFound(chapters.UpdateAsync(
|
|
chapterId,
|
|
new UpdateChapterRequest(
|
|
JsonInput.String(input, "title"),
|
|
JsonInput.Int(input, "number"),
|
|
JsonInput.String(input, "summary"),
|
|
JsonInput.String(input, "setting"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Enum<DraftStatus>(input, "status"),
|
|
JsonInput.Int(input, "target_word_count"),
|
|
JsonInput.String(input, "prose"),
|
|
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"get_character_beats",
|
|
"Every beat this character appears in, across the whole book, in manuscript order. "
|
|
+ "Read this before revising a character — it is what they actually do on the page, "
|
|
+ "as opposed to what the dossier claims about them.",
|
|
new JsonSchemaBuilder()
|
|
.Str("character_id", "Id of the character.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
|
return await OrNotFound(
|
|
beats.ListForCharacterAsync(characterId, ct),
|
|
list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
|
|
"Character",
|
|
characterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"get_character_arc",
|
|
"Read a main character's arc: the ordered stages of how they change. Each stage may "
|
|
+ "be pinned to the chapter where it lands.",
|
|
new JsonSchemaBuilder()
|
|
.Str("character_id", "Id of the character.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) => (await arcs.ListAsync(
|
|
JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse()));
|
|
|
|
yield return new AgentTool(
|
|
"add_arc_stage",
|
|
"Add a stage to a character's arc. Arcs are for main characters — promote the "
|
|
+ "character first with update_character if they are still Supporting.",
|
|
ArcStageSchema()
|
|
.Str("character_id", "Id of the character whose arc to add to.", required: true)
|
|
.Str("title", "A short handle for the change, three to five words.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
|
return await OrNotFound(arcs.CreateAsync(
|
|
characterId,
|
|
new CreateArcStageRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.String(input, "description"),
|
|
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "Character", characterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"update_arc_stage",
|
|
"Revise a stage of a character's arc. Only the fields you supply change.",
|
|
ArcStageSchema()
|
|
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
|
|
.Str("title", "New title for the stage.")
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
|
|
return await OrNotFound(arcs.UpdateAsync(
|
|
arcStageId,
|
|
new UpdateArcStageRequest(
|
|
JsonInput.String(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.String(input, "description"),
|
|
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"delete_arc_stage",
|
|
"Remove a stage from a character's arc.",
|
|
new JsonSchemaBuilder()
|
|
.Str("arc_stage_id", "Id of the arc stage to delete.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
|
|
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"reorder_arc_stages",
|
|
"Renumber a character's arc to match the order given. Stages left out keep their "
|
|
+ "relative position after the ones listed.",
|
|
new JsonSchemaBuilder()
|
|
.Str("character_id", "Id of the character whose arc to reorder.", required: true)
|
|
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
|
return await OrNotFound(arcs.ReorderAsync(
|
|
characterId,
|
|
new ReorderArcStagesRequest(
|
|
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"list_open_questions",
|
|
"The decisions the writer has not made yet. Read this before proposing changes — an "
|
|
+ "open question is a place the writer is still thinking, not a gap to fill in for them.",
|
|
new JsonSchemaBuilder()
|
|
.Str("chapter_id", "Narrow to questions about one chapter outline.")
|
|
.Str("character_id", "Narrow to questions about one character.")
|
|
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
|
.Build(),
|
|
async (novelId, input, ct) => (await questions.ListAsync(
|
|
novelId,
|
|
JsonInput.Guid(input, "chapter_id"),
|
|
JsonInput.Guid(input, "character_id"),
|
|
JsonInput.Bool(input, "include_resolved") ?? false,
|
|
ct)).Select(q => q.ToResponse()));
|
|
|
|
yield return new AgentTool(
|
|
"raise_open_question",
|
|
"Record a question the writer has not settled. Attach it to the chapter outline "
|
|
+ "and/or the character it is about. Prefer raising a question over guessing when "
|
|
+ "the writer has not decided something.",
|
|
new JsonSchemaBuilder()
|
|
.Str("question", "The question, in one line.", required: true)
|
|
.Str("detail", "The thinking around it — options, and what each costs.")
|
|
.Str("chapter_id", "The chapter outline this is about, if any.")
|
|
.Str("character_id", "The character this is about, if any.")
|
|
.Build(),
|
|
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
|
|
novelId,
|
|
new CreateOpenQuestionRequest(
|
|
JsonInput.RequiredString(input, "question"),
|
|
JsonInput.String(input, "detail"),
|
|
JsonInput.Guid(input, "chapter_id"),
|
|
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId));
|
|
|
|
yield return new AgentTool(
|
|
"resolve_open_question",
|
|
"Settle a question with what the writer decided. Set append_to_notes to also write "
|
|
+ "the resolution into the notes of the chapter and character it hangs off.",
|
|
new JsonSchemaBuilder()
|
|
.Str("question_id", "Id of the question to resolve.", required: true)
|
|
.Str("resolution", "What was decided.", required: true)
|
|
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
|
return await OrNotFound(questions.ResolveAsync(
|
|
questionId,
|
|
new ResolveOpenQuestionRequest(
|
|
JsonInput.RequiredString(input, "resolution"),
|
|
JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"reopen_question",
|
|
"Put a resolved question back on the list. Anything already appended to notes stays.",
|
|
new JsonSchemaBuilder()
|
|
.Str("question_id", "Id of the question to reopen.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
|
return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"delete_open_question",
|
|
"Delete a question outright. Resolving is usually better — it keeps the decision.",
|
|
new JsonSchemaBuilder()
|
|
.Str("question_id", "Id of the question to delete.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
|
return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId);
|
|
});
|
|
}
|
|
|
|
private static JsonSchemaBuilder ArcStageSchema() =>
|
|
new JsonSchemaBuilder()
|
|
.Int("sort_order", "Position in the arc. Appended to the end when omitted.")
|
|
.Str("description", "What shifts in the character here, and what it costs them.")
|
|
.Str("chapter_id", "The chapter where this stage lands, if it is pinned to one.");
|
|
|
|
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
|
|
{
|
|
var schema = new JsonSchemaBuilder();
|
|
|
|
if (includeName)
|
|
{
|
|
schema.Str("name", "The character's name.", nameRequired);
|
|
}
|
|
|
|
return schema
|
|
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
|
|
.Enum(
|
|
"importance",
|
|
"How much of the book they carry. Main characters are the few the story is "
|
|
+ "about and are worth an arc; everyone else is Supporting.",
|
|
System.Enum.GetNames<CharacterImportance>())
|
|
.Str("age", "Age, exact or approximate.")
|
|
.Str("pronouns", "The pronouns this character uses.")
|
|
.Str("occupation", "What they do.")
|
|
.Str("appearance", "How they look.")
|
|
.Str("personality", "Temperament, habits, how they treat people.")
|
|
.Str("backstory", "History that shapes who they are now.")
|
|
.Str("want", "What they consciously pursue.")
|
|
.Str("need", "What they actually need, usually at odds with what they want.")
|
|
.Str("internal_conflict", "The war inside them.")
|
|
.Str("external_conflict", "What in the world opposes them.")
|
|
.Str("arc_summary", "How they change over the course of the book.")
|
|
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
|
|
.Str("notes", "Anything else worth recording.")
|
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
|
.StringArray("aliases", "Other names this character is known by. Replaces the existing aliases.");
|
|
}
|
|
|
|
private static JsonSchemaBuilder BeatSchema() =>
|
|
new JsonSchemaBuilder()
|
|
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
|
.StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.")
|
|
.Str("what_happened", "The event itself.")
|
|
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
|
}
|