Unify MCP and agent tool surfaces onto one registry in NovelAgentToolset

Fixes NovelAgentService continuing a conversation under the wrong
novel's route, since FindConversationAsync matched by id alone. Then
extends NovelAgentToolset to all 45 tools the stdio MCP server offered
(tag/location CRUD, character relationships, arc-stage beat pinning,
question editing, cross-novel novel listing/creation), tagging each
with whether it needs an explicit novel scope so a later MCP adapter
can inject it. Renames the toolset's 33 existing schemas from
snake_case to camelCase to match .NET/REST convention, since nothing
external consumes them.

Lays the groundwork to serve this same registry over MCP at /mcp and
retire the separate stdio Novelly.Mcp project (docs/plans/api/mcp_http_merge_plan.md).
This commit is contained in:
James Wampler
2026-08-21 10:52:50 -07:00
parent bb2a499569
commit 897fb442a1
6 changed files with 784 additions and 117 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json;
namespace Novelly.Api.Agent;
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema, bool RequiresNovelId = false);
public abstract record AgentContentBlock;
+12 -4
View File
@@ -43,7 +43,7 @@ public class NovelAgentService(
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
return await FindConversationAsync(conversationId, ct);
return await FindConversationAsync(conversationId, null, ct);
}
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
@@ -52,7 +52,7 @@ public class NovelAgentService(
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
var conversation = await FindConversationAsync(conversationId, ct);
var conversation = await FindConversationAsync(conversationId, null, ct);
if (conversation is null)
{
return false;
@@ -81,7 +81,7 @@ public class NovelAgentService(
}
var conversation = request.ConversationId is { } id
? await FindConversationAsync(id, ct)
? await FindConversationAsync(id, novelId, ct)
: StartConversation(novelId, request.Message);
if (conversation is null) return null;
@@ -181,7 +181,7 @@ public class NovelAgentService(
return conversation;
}
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, CancellationToken ct)
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, Guid? novelId, CancellationToken ct)
{
logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
@@ -195,6 +195,14 @@ public class NovelAgentService(
return conversation;
}
if (novelId is { } expectedNovelId && conversation.NovelId != expectedNovelId)
{
logger.LogWarning(
"AgentConversation {ConversationId} belongs to novel {ActualNovelId}, not requested novel {NovelId}",
conversationId, conversation.NovelId, expectedNovelId);
return null;
}
logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
return conversation;
}
+328 -111
View File
@@ -21,7 +21,8 @@ public record AgentTool(
string Name,
string Description,
JsonElement InputSchema,
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler,
bool RequiresNovelId = false);
public class NovelAgentToolset(
NovelService novels,
@@ -46,7 +47,7 @@ public class NovelAgentToolset(
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema, t.RequiresNovelId))];
public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
{
@@ -97,12 +98,45 @@ public class NovelAgentToolset(
private IEnumerable<AgentTool> Build()
{
yield return new AgentTool(
"list_novels",
"List every novel, with counts of characters, chapters and drafted words. "
+ "Start here to find the novel id everything else needs.",
new JsonSchemaBuilder().Build(),
async (_, _, ct) => await novels.ListAsync(ct));
yield return new AgentTool(
"create_novel",
"Create a new novel.",
new JsonSchemaBuilder()
.Str("title", "Working title.", required: true)
.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("targetWordCount", "Target manuscript length in words.")
.Build(),
async (_, input, ct) =>
{
var novel = await novels.CreateAsync(new CreateNovelRequest(
JsonInput.RequiredString(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, "targetWordCount")), ct);
return novel.ToResponse(null);
});
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));
async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId),
RequiresNovelId: true);
yield return new AgentTool(
"update_novel_brief",
@@ -115,7 +149,7 @@ public class NovelAgentToolset(
.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.")
.Int("targetWordCount", "Target manuscript length in words.")
.Build(),
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"),
@@ -124,13 +158,27 @@ public class NovelAgentToolset(
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));
JsonInput.Int(input, "targetWordCount")), ct), p => p.ToResponse(null), "Novel", novelId),
RequiresNovelId: true);
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()));
async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()),
RequiresNovelId: true);
yield return new AgentTool(
"get_character",
"Read one character's dossier.",
new JsonSchemaBuilder()
.Str("characterId", "Id of the character to read.", required: true)
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "characterId");
return await OrNotFound(characters.GetAsync(characterId, ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
"create_character",
@@ -152,17 +200,18 @@ public class NovelAgentToolset(
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId),
RequiresNovelId: true);
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)
.Str("characterId", "Id of the character to update.", required: true)
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
var characterId = JsonInput.RequiredGuid(input, "characterId");
return await OrNotFound(characters.UpdateAsync(
characterId,
new UpdateCharacterRequest(
@@ -183,25 +232,49 @@ public class NovelAgentToolset(
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
"relate_characters",
"Record a relationship between two characters in the same novel. Creates both directions "
+ "at once — characterId's side and relatedCharacterId's side — so the pair always shows up "
+ "on both dossiers.",
new JsonSchemaBuilder()
.Str("characterId", "Id of the character the relationship belongs to.", required: true)
.Str("relatedCharacterId", "Id of the character they are related to.", required: true)
.Str("relationshipType", "How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.", required: true)
.Str("reciprocalRelationshipType", "How relatedCharacterId is related back to characterId, if different. Defaults to relationshipType when the relation is symmetric, like 'rival'.")
.Str("description", "What the relationship is like, and where it is headed.")
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "characterId");
return await OrNotFound(characters.AddRelationshipAsync(
characterId,
new CreateRelationshipRequest(
JsonInput.RequiredGuid(input, "relatedCharacterId"),
JsonInput.RequiredString(input, "relationshipType"),
JsonInput.String(input, "description"),
JsonInput.String(input, "reciprocalRelationshipType")), 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("characterId", "Id of the character being revealed as someone else.", required: true)
.Str("sameCharacterAsId", "Id of the character this one really is.", required: true)
.Str("revealedInChapterId", "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");
var characterId = JsonInput.RequiredGuid(input, "characterId");
return await OrNotFound(characters.LinkIdentityAsync(
characterId,
new LinkCharacterIdentityRequest(
JsonInput.RequiredGuid(input, "same_character_as_id"),
JsonInput.Guid(input, "revealed_in_chapter_id"),
JsonInput.RequiredGuid(input, "sameCharacterAsId"),
JsonInput.Guid(input, "revealedInChapterId"),
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
});
@@ -209,11 +282,11 @@ public class NovelAgentToolset(
"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)
.Str("characterId", "Id of the character to unlink.", required: true)
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
var characterId = JsonInput.RequiredGuid(input, "characterId");
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
});
@@ -222,29 +295,29 @@ public class NovelAgentToolset(
"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)
.Str("chapterId", "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()));
async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapterId"), 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.",
+ "handle, not a sentence; the detail belongs in whatHappened and whatsNext.",
BeatSchema()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("chapterId", "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");
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
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.Int(input, "sortOrder"),
JsonInput.Guids(input, "characterIds"),
JsonInput.String(input, "whatHappened"),
JsonInput.String(input, "whatsNext"),
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
});
@@ -253,20 +326,20 @@ public class NovelAgentToolset(
"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("beatId", "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");
var beatId = JsonInput.RequiredGuid(input, "beatId");
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.Int(input, "sortOrder"),
JsonInput.Guids(input, "characterIds"),
JsonInput.String(input, "whatHappened"),
JsonInput.String(input, "whatsNext"),
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
});
@@ -274,11 +347,11 @@ public class NovelAgentToolset(
"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)
.Str("beatId", "Id of the beat to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
var beatId = JsonInput.RequiredGuid(input, "beat_id");
var beatId = JsonInput.RequiredGuid(input, "beatId");
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
});
@@ -287,16 +360,16 @@ public class NovelAgentToolset(
"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)
.Str("chapterId", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beatIds", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
return await OrNotFound(beats.ReorderAsync(
chapterId,
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
[.. (JsonInput.Strings(input, "beatIds") ?? [])
.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);
});
@@ -306,18 +379,18 @@ public class NovelAgentToolset(
"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)
.Str("chapterId", "Id of the chapter the beats belong to.", required: true)
.Str("characterId", "Id of the character to add.", required: true)
.StringArray("beatIds", "Ids of the beats to add the character to.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
return await OrNotFound(beats.AssignCharacterAsync(
chapterId,
new AssignCharacterToBeatsRequest(
JsonInput.RequiredGuid(input, "character_id"),
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
JsonInput.RequiredGuid(input, "characterId"),
[.. (JsonInput.Strings(input, "beatIds") ?? [])
.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);
});
@@ -327,18 +400,18 @@ public class NovelAgentToolset(
"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)
.Str("chapterId", "Id of the beats' current chapter.", required: true)
.Str("targetChapterId", "Id of the chapter to move the beats into.", required: true)
.StringArray("beatIds", "Ids of the beats to move.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
return await OrNotFound(beats.MoveAsync(
chapterId,
new MoveBeatsRequest(
JsonInput.RequiredGuid(input, "target_chapter_id"),
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
JsonInput.RequiredGuid(input, "targetChapterId"),
[.. (JsonInput.Strings(input, "beatIds") ?? [])
.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);
});
@@ -348,18 +421,19 @@ public class NovelAgentToolset(
"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));
async (novelId, _, ct) => await tags.ListAsync(novelId, ct),
RequiresNovelId: true);
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)
.Str("tagId", "Id of the tag to trace.", required: true)
.Build(),
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tag_id");
var tagId = JsonInput.RequiredGuid(input, "tagId");
var tag = await tags.GetReferencesAsync(tagId, ct);
if (tag is null)
{
@@ -370,22 +444,68 @@ public class NovelAgentToolset(
return tag.ToReferencesResponse(displayNumbers);
});
yield return new AgentTool(
"create_tag",
"Create a tag explicitly. Applying an unknown tag by name to a character, chapter or "
+ "beat also creates it, so this is only needed to set a colour up front.",
new JsonSchemaBuilder()
.Str("name", "The tag's name. Unique within the novel, matched case-insensitively.", required: true)
.Str("color", "Optional hex colour for the UI, e.g. \"#9a4a2f\".")
.Build(),
async (novelId, input, ct) => await OrNotFound(tags.CreateAsync(
novelId,
new CreateTagRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.String(input, "color")), ct), t => t.ToResponse(), "Novel", novelId),
RequiresNovelId: true);
yield return new AgentTool(
"update_tag",
"Rename or recolour a tag. Renaming updates it everywhere it is applied.",
new JsonSchemaBuilder()
.Str("tagId", "Id of the tag to update.", required: true)
.Str("name", "New name.")
.Str("color", "Hex colour, e.g. \"#9a4a2f\".")
.Build(),
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tagId");
return await OrNotFound(tags.UpdateAsync(
tagId,
new UpdateTagRequest(
JsonInput.String(input, "name"),
JsonInput.String(input, "color")), ct), t => t.ToResponse(), "Tag", tagId);
});
yield return new AgentTool(
"delete_tag",
"Delete a tag. Whatever carried it is left alone — only the label goes.",
new JsonSchemaBuilder()
.Str("tagId", "Id of the tag to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tagId");
return await DeletedOrNotFound(tags.DeleteAsync(tagId, ct), "Tag", tagId);
});
yield return new AgentTool(
"list_locations",
"List the novel's locations with how many chapters are set there. "
+ "Read this before inventing a new location so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(),
async (novelId, _, ct) => await locations.ListAsync(novelId, ct));
async (novelId, _, ct) => await locations.ListAsync(novelId, ct),
RequiresNovelId: true);
yield return new AgentTool(
"get_location_references",
"Cross-reference a location: every chapter set there.",
new JsonSchemaBuilder()
.Str("location_id", "Id of the location to trace.", required: true)
.Str("locationId", "Id of the location to trace.", required: true)
.Build(),
async (_, input, ct) =>
{
var locationId = JsonInput.RequiredGuid(input, "location_id");
var locationId = JsonInput.RequiredGuid(input, "locationId");
var location = await locations.GetReferencesAsync(locationId, ct);
if (location is null)
{
@@ -396,6 +516,45 @@ public class NovelAgentToolset(
return location.ToReferencesResponse(displayNumbers);
});
yield return new AgentTool(
"create_location",
"Create a location explicitly. Applying an unknown location by name to a chapter also "
+ "creates it, so this is only needed to set one up ahead of time.",
new JsonSchemaBuilder()
.Str("name", "The location's name. Unique within the novel, matched case-insensitively.", required: true)
.Build(),
async (novelId, input, ct) => await OrNotFound(locations.CreateAsync(
novelId,
new CreateLocationRequest(JsonInput.RequiredString(input, "name")), ct), l => l.ToResponse(), "Novel", novelId),
RequiresNovelId: true);
yield return new AgentTool(
"update_location",
"Rename a location. Renaming updates it everywhere it is applied.",
new JsonSchemaBuilder()
.Str("locationId", "Id of the location to update.", required: true)
.Str("name", "New name.", required: true)
.Build(),
async (_, input, ct) =>
{
var locationId = JsonInput.RequiredGuid(input, "locationId");
return await OrNotFound(locations.UpdateAsync(
locationId,
new UpdateLocationRequest(JsonInput.RequiredString(input, "name")), ct), l => l.ToResponse(), "Location", locationId);
});
yield return new AgentTool(
"delete_location",
"Delete a location. Whatever carried it is left alone — only the label goes.",
new JsonSchemaBuilder()
.Str("locationId", "Id of the location to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
var locationId = JsonInput.RequiredGuid(input, "locationId");
return await DeletedOrNotFound(locations.DeleteAsync(locationId, ct), "Location", locationId);
});
yield return new AgentTool(
"list_chapters",
"List the novel's chapters in manuscript order with beat and word counts.",
@@ -405,17 +564,18 @@ public class NovelAgentToolset(
var list = await chapters.ListAsync(novelId, ct);
var displayNumbers = ChapterNumbering.DisplayNumbers(list);
return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null));
});
},
RequiresNovelId: true);
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)
.Str("chapterId", "Id of the chapter to read.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
var chapter = await chapters.GetAsync(chapterId, ct);
if (chapter is null)
{
@@ -439,7 +599,7 @@ public class NovelAgentToolset(
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.Int("targetWordCount", "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(),
@@ -453,7 +613,7 @@ public class NovelAgentToolset(
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.Int(input, "targetWordCount"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct);
@@ -464,7 +624,8 @@ public class NovelAgentToolset(
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
});
},
RequiresNovelId: true);
yield return new AgentTool(
"update_chapter",
@@ -472,7 +633,7 @@ public class NovelAgentToolset(
+ "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("chapterId", "Id of the chapter to update.", required: true)
.Str("title", "New title.")
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
@@ -480,13 +641,13 @@ public class NovelAgentToolset(
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.Int("targetWordCount", "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");
var chapterId = JsonInput.RequiredGuid(input, "chapterId");
var chapter = await chapters.UpdateAsync(
chapterId,
new UpdateChapterRequest(
@@ -497,7 +658,7 @@ public class NovelAgentToolset(
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Int(input, "targetWordCount"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct);
@@ -516,11 +677,11 @@ public class NovelAgentToolset(
+ "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)
.Str("characterId", "Id of the character.", required: true)
.Build(),
async (novelId, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
var characterId = JsonInput.RequiredGuid(input, "characterId");
var characterBeats = await beats.ListForCharacterAsync(characterId, ct);
if (characterBeats is null)
{
@@ -530,66 +691,67 @@ public class NovelAgentToolset(
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return characterBeats.Select(b =>
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers)));
});
},
RequiresNovelId: true);
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)
.Str("characterId", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => (await arcs.ListAsync(
JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse()));
JsonInput.RequiredGuid(input, "characterId"), 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("characterId", "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");
var characterId = JsonInput.RequiredGuid(input, "characterId");
return await OrNotFound(arcs.CreateAsync(
characterId,
new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Int(input, "sortOrder"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "Character", characterId);
JsonInput.Guid(input, "chapterId")), 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("arcStageId", "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");
var arcStageId = JsonInput.RequiredGuid(input, "arcStageId");
return await OrNotFound(arcs.UpdateAsync(
arcStageId,
new UpdateArcStageRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Int(input, "sortOrder"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
JsonInput.Guid(input, "chapterId")), 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)
.Str("arcStageId", "Id of the arc stage to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
var arcStageId = JsonInput.RequiredGuid(input, "arcStageId");
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
});
@@ -598,16 +760,35 @@ public class NovelAgentToolset(
"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)
.Str("characterId", "Id of the character whose arc to reorder.", required: true)
.StringArray("stageIds", "Arc stage ids in the order wanted.", required: true)
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
var characterId = JsonInput.RequiredGuid(input, "characterId");
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);
[.. JsonInput.Strings(input, "stageIds")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId);
});
yield return new AgentTool(
"set_arc_stage_beats",
"Set which beats belong to an arc stage, replacing its current set. This groups the "
+ "chapter-level beats that establish or pay off this stage of the character's arc. A "
+ "beat moved into this stage leaves any other stage of the same character it was in. "
+ "Each beat must already include this character.",
new JsonSchemaBuilder()
.Str("arcStageId", "Id of the arc stage.", required: true)
.StringArray("beatIds", "Beat ids that belong to this stage, replacing whatever was there before.", required: true)
.Build(),
async (_, input, ct) =>
{
var arcStageId = JsonInput.RequiredGuid(input, "arcStageId");
return await OrNotFound(arcs.SetBeatsAsync(
arcStageId,
new SetArcStageBeatsRequest(
[.. JsonInput.Strings(input, "beatIds")?.Select(Guid.Parse) ?? []]), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
});
yield return new AgentTool(
@@ -615,22 +796,23 @@ public class NovelAgentToolset(
"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.")
.Str("chapterId", "Narrow to questions about one chapter outline.")
.Str("characterId", "Narrow to questions about one character.")
.Bool("includeResolved", "Include questions already settled. Defaults to false.")
.Build(),
async (novelId, input, ct) =>
{
var list = await questions.ListAsync(
novelId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
JsonInput.Guid(input, "chapterId"),
JsonInput.Guid(input, "characterId"),
JsonInput.Bool(input, "includeResolved") ?? false,
ct);
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return list.Select(q => q.ToResponse(displayNumbers));
});
},
RequiresNovelId: true);
yield return new AgentTool(
"raise_open_question",
@@ -640,8 +822,8 @@ public class NovelAgentToolset(
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.")
.Str("chapterId", "The chapter outline this is about, if any.")
.Str("characterId", "The character this is about, if any.")
.Build(),
async (novelId, input, ct) =>
{
@@ -650,8 +832,8 @@ public class NovelAgentToolset(
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct);
JsonInput.Guid(input, "chapterId"),
JsonInput.Guid(input, "characterId")), ct);
if (question is null)
{
@@ -660,25 +842,60 @@ public class NovelAgentToolset(
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return question.ToResponse(displayNumbers);
},
RequiresNovelId: true);
yield return new AgentTool(
"update_open_question",
"Revise a question or change what it is attached to. Only the fields you supply change.",
new JsonSchemaBuilder()
.Str("questionId", "Id of the question to update.", required: true)
.Str("question", "New wording for the question.")
.Str("detail", "New detail. Pass an empty string to clear it.")
.Str("chapterId", "Attach to this chapter outline.")
.Str("characterId", "Attach to this character.")
.Bool("clearChapter", "Detach from its chapter.")
.Bool("clearCharacter", "Detach from its character.")
.Build(),
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "questionId");
var question = await questions.UpdateAsync(
questionId,
new UpdateOpenQuestionRequest(
JsonInput.String(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapterId"),
JsonInput.Guid(input, "characterId"),
JsonInput.Bool(input, "clearChapter") ?? false,
JsonInput.Bool(input, "clearCharacter") ?? false), ct);
if (question is null)
{
return new ToolNotFound("OpenQuestion", questionId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
return question.ToResponse(displayNumbers);
});
yield return new AgentTool(
"resolve_open_question",
"Settle a question with what the writer decided. Set append_to_notes to also write "
"Settle a question with what the writer decided. Set appendToNotes 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("questionId", "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.")
.Bool("appendToNotes", "Also append the resolution to the associated notes.")
.Build(),
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
var questionId = JsonInput.RequiredGuid(input, "questionId");
var question = await questions.ResolveAsync(
questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct);
JsonInput.Bool(input, "appendToNotes") ?? false), ct);
if (question is null)
{
@@ -693,11 +910,11 @@ public class NovelAgentToolset(
"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)
.Str("questionId", "Id of the question to reopen.", required: true)
.Build(),
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
var questionId = JsonInput.RequiredGuid(input, "questionId");
var question = await questions.ReopenAsync(questionId, ct);
if (question is null)
{
@@ -712,20 +929,20 @@ public class NovelAgentToolset(
"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)
.Str("questionId", "Id of the question to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
var questionId = JsonInput.RequiredGuid(input, "questionId");
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.")
.Int("sortOrder", "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.");
.Str("chapterId", "The chapter where this stage lands, if it is pinned to one.");
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
{
@@ -759,9 +976,9 @@ public class NovelAgentToolset(
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.")
.Int("sortOrder", "Position in the chapter. Appended to the end when omitted.")
.StringArray("characterIds", "Ids of the characters whose beat this is. Replaces the existing list.")
.Str("whatHappened", "The event itself.")
.Str("whatsNext", "What it sets in motion — the hook into the next beat.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
}