Not-found lookups return null/false instead of throwing NotFoundException across all services — a missing row is expected control flow, not an exceptional condition. NotFoundException stays for embedded precondition checks inside mutations (missing parent, invalid foreign reference). Guard (copied from mic-check) enforces required arguments at the top of every service method. A ported IModelValidator<T> framework validates every request DTO at the API layer via a new ValidationEndpointFilter, returning a 400 with field-level messages; services re-run the same validator and throw for direct callers that bypass the API. Endpoints translate null/false into 404 via a new ToApiResult() helper. The agent toolset boundary translates the same nullable/bool results into the tool-error text the model already expected.
648 lines
33 KiB
C#
648 lines
33 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.Projects;
|
|
using Novelly.Api.Questions;
|
|
using Novelly.Api.Scenes;
|
|
using Novelly.Api.Tags;
|
|
|
|
namespace Novelly.Api.Agent;
|
|
|
|
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
|
|
public record AgentToolResult(string Content, bool IsError);
|
|
|
|
/// <summary>
|
|
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
|
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
|
|
/// recognises and turns into the same error-result shape a caught exception would produce.
|
|
/// </summary>
|
|
internal record ToolNotFound(string Message);
|
|
|
|
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
|
public record AgentTool(
|
|
string Name,
|
|
string Description,
|
|
JsonElement InputSchema,
|
|
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
|
|
|
/// <summary>
|
|
/// The tools the writing agent can reach for. Everything here goes through the same
|
|
/// application services the REST API uses, so an edit made by the agent is
|
|
/// indistinguishable from one made in the UI.
|
|
/// </summary>
|
|
public class NovelAgentToolset(
|
|
ProjectService projects,
|
|
CharacterService characters,
|
|
CharacterArcService arcs,
|
|
ChapterService chapters,
|
|
BeatService beats,
|
|
SceneService scenes,
|
|
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))];
|
|
|
|
/// <summary>
|
|
/// Runs a tool and serialises its result. Failures come back as text rather than
|
|
/// exceptions so the model can read the message and correct itself.
|
|
/// </summary>
|
|
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, 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 project {ProjectId}", name, projectId);
|
|
|
|
try
|
|
{
|
|
var result = await tool.Handler(projectId, input, ct);
|
|
|
|
if (result is ToolNotFound notFound)
|
|
{
|
|
logger.LogInformation("Tool {Tool} for project {ProjectId} found nothing: {Message}", name, projectId, notFound.Message);
|
|
return new AgentToolResult(notFound.Message, true);
|
|
}
|
|
|
|
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
|
|
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
|
|
}
|
|
catch (NotFoundException ex)
|
|
{
|
|
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: not found", name, projectId);
|
|
return new AgentToolResult(ex.Message, true);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId);
|
|
return new AgentToolResult(ex.Message, true);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId);
|
|
return new AgentToolResult(ex.Message, true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
|
|
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}' was not found.");
|
|
|
|
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
|
|
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
|
|
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
|
|
|
|
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
|
|
|
private IEnumerable<AgentTool> Build()
|
|
{
|
|
yield return new AgentTool(
|
|
"get_project_brief",
|
|
"Read the project'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 (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId));
|
|
|
|
yield return new AgentTool(
|
|
"update_project_brief",
|
|
"Revise the project'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 (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
|
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), "Project", projectId));
|
|
|
|
yield return new AgentTool(
|
|
"list_characters",
|
|
"List every character in the project with their full dossiers.",
|
|
new JsonSchemaBuilder().Build(),
|
|
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
|
|
|
|
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 (projectId, input, ct) => await characters.CreateAsync(projectId, 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")), ct));
|
|
|
|
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")), 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));
|
|
|
|
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) => await beats.CreateAsync(
|
|
JsonInput.RequiredGuid(input, "chapter_id"),
|
|
new CreateBeatRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.Guid(input, "character_id"),
|
|
JsonInput.String(input, "what_happened"),
|
|
JsonInput.String(input, "whats_next"),
|
|
JsonInput.Guid(input, "scene_id"),
|
|
JsonInput.Strings(input, "tags")), ct));
|
|
|
|
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.Guid(input, "character_id"),
|
|
JsonInput.String(input, "what_happened"),
|
|
JsonInput.String(input, "whats_next"),
|
|
JsonInput.Guid(input, "scene_id"),
|
|
JsonInput.Strings(input, "tags")), ct), "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) => await beats.ReorderAsync(
|
|
JsonInput.RequiredGuid(input, "chapter_id"),
|
|
new ReorderBeatsRequest(
|
|
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
|
|
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
|
|
.Where(g => g != Guid.Empty)]), ct));
|
|
|
|
yield return new AgentTool(
|
|
"list_tags",
|
|
"List the project'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 (projectId, _, ct) => await tags.ListAsync(projectId, 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), "Tag", tagId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"list_chapters",
|
|
"List the project's chapters in manuscript order with scene and word counts.",
|
|
new JsonSchemaBuilder().Build(),
|
|
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
|
|
|
|
yield return new AgentTool(
|
|
"get_chapter",
|
|
"Read one chapter in full, including all of its scenes and any 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), "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("pov_character_id", "Id of the point-of-view character.")
|
|
.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.")
|
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
|
.Build(),
|
|
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "number"),
|
|
JsonInput.String(input, "summary"),
|
|
JsonInput.Guid(input, "pov_character_id"),
|
|
JsonInput.String(input, "setting"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
|
JsonInput.Int(input, "target_word_count"),
|
|
JsonInput.Strings(input, "tags")), ct));
|
|
|
|
yield return new AgentTool(
|
|
"update_chapter",
|
|
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
|
|
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("pov_character_id", "Id of the point-of-view character.")
|
|
.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.")
|
|
.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.Guid(input, "pov_character_id"),
|
|
JsonInput.String(input, "setting"),
|
|
JsonInput.String(input, "notes"),
|
|
JsonInput.Enum<DraftStatus>(input, "status"),
|
|
JsonInput.Int(input, "target_word_count"),
|
|
JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId);
|
|
});
|
|
|
|
yield return new AgentTool(
|
|
"create_scene",
|
|
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
|
|
+ "draftable later, so fill those in when the writer has given you enough to work with.",
|
|
SceneSchema()
|
|
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
|
|
.Str("title", "Scene title.", required: true)
|
|
.Build(),
|
|
async (_, input, ct) => await scenes.CreateAsync(
|
|
JsonInput.RequiredGuid(input, "chapter_id"),
|
|
new CreateSceneRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.String(input, "summary"),
|
|
JsonInput.String(input, "goal"),
|
|
JsonInput.String(input, "conflict"),
|
|
JsonInput.String(input, "outcome"),
|
|
JsonInput.Guid(input, "pov_character_id"),
|
|
JsonInput.String(input, "location"),
|
|
JsonInput.String(input, "prose"),
|
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct));
|
|
|
|
yield return new AgentTool(
|
|
"update_scene",
|
|
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
|
|
+ "the word count is recomputed automatically.",
|
|
SceneSchema()
|
|
.Str("scene_id", "Id of the scene to update.", required: true)
|
|
.Str("title", "New title.")
|
|
.Build(),
|
|
async (_, input, ct) =>
|
|
{
|
|
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
|
|
return await OrNotFound(scenes.UpdateAsync(
|
|
sceneId,
|
|
new UpdateSceneRequest(
|
|
JsonInput.String(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.String(input, "summary"),
|
|
JsonInput.String(input, "goal"),
|
|
JsonInput.String(input, "conflict"),
|
|
JsonInput.String(input, "outcome"),
|
|
JsonInput.Guid(input, "pov_character_id"),
|
|
JsonInput.String(input, "location"),
|
|
JsonInput.String(input, "prose"),
|
|
JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId);
|
|
});
|
|
|
|
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), "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));
|
|
|
|
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) => await arcs.CreateAsync(
|
|
JsonInput.RequiredGuid(input, "character_id"),
|
|
new CreateArcStageRequest(
|
|
JsonInput.RequiredString(input, "title"),
|
|
JsonInput.Int(input, "sort_order"),
|
|
JsonInput.String(input, "description"),
|
|
JsonInput.Guid(input, "chapter_id")), ct));
|
|
|
|
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), "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) => await arcs.ReorderAsync(
|
|
JsonInput.RequiredGuid(input, "character_id"),
|
|
new ReorderArcStagesRequest(
|
|
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct));
|
|
|
|
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 (projectId, input, ct) => await questions.ListAsync(
|
|
projectId,
|
|
JsonInput.Guid(input, "chapter_id"),
|
|
JsonInput.Guid(input, "character_id"),
|
|
JsonInput.Bool(input, "include_resolved") ?? false,
|
|
ct));
|
|
|
|
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 (projectId, input, ct) => await questions.CreateAsync(
|
|
projectId,
|
|
new CreateOpenQuestionRequest(
|
|
JsonInput.RequiredString(input, "question"),
|
|
JsonInput.String(input, "detail"),
|
|
JsonInput.Guid(input, "chapter_id"),
|
|
JsonInput.Guid(input, "character_id")), ct));
|
|
|
|
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), "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), "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.");
|
|
}
|
|
|
|
private static JsonSchemaBuilder BeatSchema() =>
|
|
new JsonSchemaBuilder()
|
|
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
|
.Str("character_id", "Id of the character whose beat this is.")
|
|
.Str("what_happened", "The event itself.")
|
|
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
|
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
|
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
|
|
|
private static JsonSchemaBuilder SceneSchema() =>
|
|
new JsonSchemaBuilder()
|
|
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
|
|
.Str("summary", "What happens in the scene.")
|
|
.Str("goal", "What the POV character is trying to achieve.")
|
|
.Str("conflict", "What stands in the way.")
|
|
.Str("outcome", "How it lands, and what it costs.")
|
|
.Str("pov_character_id", "Id of the point-of-view character.")
|
|
.Str("location", "Where the scene takes place.")
|
|
.Str("prose", "The drafted prose for this scene.")
|
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
|
|
}
|