From c913a49ffd589607770cd5d782ae84311216ac44 Mon Sep 17 00:00:00 2001 From: voocel Date: Sun, 15 Mar 2026 22:52:17 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=B8=8A=E4=B8=8B=E6=96=87=E5=88=86?= =?UTF-8?q?=E7=BA=A7=E8=A3=81=E5=89=AA=E4=B8=8EAgent=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=80=A7=E4=BF=9D=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agents.go | 29 ++++-- app/config.go | 20 ++-- app/run.go | 181 ++++++++++++++++++++++++++++++++----- domain/runtime.go | 7 +- go.mod | 6 +- go.sum | 6 +- prompts/architect-long.md | 1 + prompts/architect-mid.md | 1 + prompts/architect-short.md | 3 +- prompts/coordinator.md | 8 +- prompts/writer.md | 2 + tools/check_consistency.go | 17 +--- tools/draft_chapter.go | 2 + tools/novel_context.go | 147 ++++++++++++++++++++++-------- tools/save_foundation.go | 40 +++++--- 15 files changed, 358 insertions(+), 112 deletions(-) diff --git a/app/agents.go b/app/agents.go index 7d246ca..5920465 100644 --- a/app/agents.go +++ b/app/agents.go @@ -2,6 +2,7 @@ package app import ( "github.com/voocel/agentcore" + "github.com/voocel/agentcore/memory" "github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/tools" ) @@ -80,12 +81,19 @@ func BuildCoordinator( } writer := agentcore.SubAgentConfig{ - Name: "writer", - Description: "创作者:自主完成一章的构思、写作、自审和提交", - Model: model, - SystemPrompt: writerPrompt, - Tools: writerTools, - MaxTurns: 20, + Name: "writer", + Description: "创作者:自主完成一章的构思、写作、自审和提交", + Model: model, + SystemPrompt: writerPrompt, + Tools: writerTools, + MaxTurns: 20, + TransformContext: memory.NewCompaction(memory.CompactionConfig{ + Model: model, + ContextWindow: cfg.ContextWindow, + ReserveTokens: 16384, + KeepRecentTokens: 20000, + }), + ConvertToLLM: memory.CompactionConvertToLLM, } editor := agentcore.SubAgentConfig{ @@ -104,6 +112,15 @@ func BuildCoordinator( agentcore.WithSystemPrompt(prompts.Coordinator), agentcore.WithTools(subagentTool, contextTool, askUser), agentcore.WithMaxTurns(60), + agentcore.WithContextPipeline( + memory.NewCompaction(memory.CompactionConfig{ + Model: model, + ContextWindow: cfg.ContextWindow, + ReserveTokens: 32000, + KeepRecentTokens: 30000, + }), + memory.CompactionConvertToLLM, + ), ) return agent, askUser } diff --git a/app/config.go b/app/config.go index 689d900..1c7a4f9 100644 --- a/app/config.go +++ b/app/config.go @@ -7,14 +7,15 @@ import ( // Config 小说应用配置。 type Config struct { - Prompt string // 用户的小说需求 - NovelName string // 小说名(用作输出目录名) - OutputDir string // 输出根目录,默认 output/{NovelName} - Provider string // LLM 提供商:openai / anthropic / gemini - ModelName string // LLM 模型名 - APIKey string // API Key - BaseURL string // API Base URL(可选) - Style string // 写作风格(default/suspense/fantasy/romance) + Prompt string // 用户的小说需求 + NovelName string // 小说名(用作输出目录名) + OutputDir string // 输出根目录,默认 output/{NovelName} + Provider string // LLM 提供商:openai / anthropic / gemini + ModelName string // LLM 模型名 + APIKey string // API Key + BaseURL string // API Base URL(可选) + Style string // 写作风格(default/suspense/fantasy/romance) + ContextWindow int // 模型上下文窗口大小(token),默认 128000 } // Prompts 嵌入的提示词。 @@ -72,4 +73,7 @@ func (c *Config) FillDefaults() { if c.Style == "" { c.Style = "default" } + if c.ContextWindow <= 0 { + c.ContextWindow = 128000 + } } diff --git a/app/run.go b/app/run.go index d2b447b..b63a818 100644 --- a/app/run.go +++ b/app/run.go @@ -182,21 +182,40 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, prov } return } + + // subagent 结果:提取 usage 和 error,单独记录 + if ev.Tool == "subagent" { + logSubAgentResult(ev.Result, emit) + handleFoundationCheck(coordinator, store, emit) + committed := handleSubAgentDone(coordinator, store, emit) + if !committed { + handleUncommittedDraft(coordinator, store, emit) + } + handleEditorDone(coordinator, store, emit) + break + } + + // novel_context:提取加载摘要替代原始 JSON + if ev.Tool == "novel_context" { + if summary := extractLoadingSummary(ev.Result); summary != "" { + log.Printf("[tool:done] novel_context → %s", summary) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "CONTEXT", Summary: summary, Level: "info"}) + } + } else { + log.Printf("[tool:done] novel_context → %s", truncateLog(string(ev.Result), 200)) + } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: "novel_context.done", Level: "info"}) + } + break + } + + // 其他工具:保持原样 log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200)) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".done", Level: "info"}) } - // 上下文加载可视化:提取 novel_context 的加载摘要 - if ev.Tool == "novel_context" && emit != nil { - if summary := extractLoadingSummary(ev.Result); summary != "" { - emit(UIEvent{Time: time.Now(), Category: "CONTEXT", Summary: summary, Level: "info"}) - } - } - - if ev.Tool == "subagent" { - handleSubAgentDone(coordinator, store, emit) - handleEditorDone(coordinator, store, emit) - } case agentcore.EventMessageEnd: if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant { @@ -337,11 +356,52 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov return recoveryResult{IsNew: true} } +// handleFoundationCheck 在 SubAgent 完成后检查基础设定是否完备。 +// 如果 phase 仍在 premise(有 premise 但无 outline),注入确定性提醒。 +func handleFoundationCheck(coordinator *agentcore.Agent, store *state.Store, emit emitFn) { + progress, _ := store.LoadProgress() + if progress == nil { + return + } + // 只在规划阶段检查(premise 已保存但 outline 未保存) + if progress.Phase != domain.PhasePremise { + return + } + var missing []string + if o, _ := store.LoadOutline(); len(o) == 0 { + missing = append(missing, "outline") + } + if c, _ := store.LoadCharacters(); len(c) == 0 { + missing = append(missing, "characters") + } + if r, _ := store.LoadWorldRules(); len(r) == 0 { + missing = append(missing, "world_rules") + } + if len(missing) == 0 { + return + } + log.Printf("[host] 基础设定不完整,缺失: %v", missing) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("基础设定不完整,缺失: %v", missing), Level: "warn"}) + } + runMeta, _ := store.LoadRunMeta() + guidance := planningTierGuidance(runMeta) + msg := fmt.Sprintf( + "[系统] 基础设定不完整,以下项目尚未保存:%v。请重新调用对应规划师补全这些设定。在基础设定全部完备前,不要调用 writer。", + missing) + if guidance != "" { + msg += "\n" + guidance + } + coordinator.FollowUp(agentcore.UserMsg(msg)) +} + // handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。 -func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) { +// 返回 true 表示检测到 commit 信号(Writer 正常完成)。 +func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) bool { result, err := store.LoadLastCommit() if err != nil || result == nil { - return + return false } if err := store.ClearLastCommit(); err != nil { log.Printf("[host] 清除 commit 信号失败: %v", err) @@ -378,7 +438,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] 当前处于重写流程,但提交了非队列章节(第 %d 章)。请先完成待重写章节 %v 后再继续新章节。", result.Chapter, progress.PendingRewrites))) - return + return true } if err := store.CompleteRewrite(result.Chapter); err != nil { log.Printf("[host] 完成重写标记失败: %v", err) @@ -396,7 +456,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e log.Printf("[host] 还有 %d 章待处理:%v", len(updated.PendingRewrites), updated.PendingRewrites) saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) } - return + return true } // 确定性判断 1.5:长篇弧/卷边界处理 @@ -454,7 +514,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e } clearHandledSteer(store) saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) - return + return true } // 确定性判断 1:全书完成(TotalChapters 由大纲自动设定) @@ -475,7 +535,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。", totalChapters))) - return + return true } // 确定性判断 2:需要全局审阅 @@ -493,6 +553,37 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e } clearHandledSteer(store) saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) + return true +} + +// handleUncommittedDraft 在 Writer 结束但没有 commit 时检测是否存在未提交的草稿。 +// 如果存在,提醒 Coordinator 重新调用 writer 完成提交。 +func handleUncommittedDraft(coordinator *agentcore.Agent, store *state.Store, emit emitFn) { + progress, _ := store.LoadProgress() + if progress == nil || progress.Phase == domain.PhaseComplete { + return + } + // 确定下一个应该写的章节 + next := 1 + if progress.InProgressChapter > 0 { + next = progress.InProgressChapter + } else if len(progress.CompletedChapters) > 0 { + next = progress.NextChapter() + } + // 检查该章节是否有草稿但未提交 + draft, _ := store.LoadDraft(next) + if draft == "" { + return + } + // 有草稿但没有 commit 信号 + log.Printf("[host] Writer 结束但第 %d 章草稿未提交", next) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("第 %d 章有草稿但未提交", next), Level: "warn"}) + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] Writer 结束但第 %d 章草稿未提交。请重新调用 writer 完成该章的自审和提交(commit_chapter)。", + next))) } // handleEditorDone 在 Editor SubAgent 完成后读取审阅信号。 @@ -599,10 +690,11 @@ func parseProgressSummary(ev agentcore.Event) string { return "progress" } var data struct { - Agent string `json:"agent"` - Tool string `json:"tool"` - Turn int `json:"turn"` - Error bool `json:"error"` + Agent string `json:"agent"` + Tool string `json:"tool"` + Turn int `json:"turn"` + Error bool `json:"error"` + Message string `json:"message"` Thinking string `json:"thinking"` } if err := json.Unmarshal(ev.Result, &data); err != nil { @@ -614,6 +706,9 @@ func parseProgressSummary(ev agentcore.Event) string { } if data.Tool != "" { if data.Error { + if data.Message != "" { + return fmt.Sprintf("%s → %s (error: %s)", data.Agent, data.Tool, truncateLog(data.Message, 120)) + } return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool) } return fmt.Sprintf("%s → %s", data.Agent, data.Tool) @@ -638,6 +733,50 @@ func extractLoadingSummary(result json.RawMessage) string { return data.Summary } +// logSubAgentResult 从 subagent 结果中提取 usage 和 error,分别记录结构化日志。 +func logSubAgentResult(result json.RawMessage, emit emitFn) { + if len(result) == 0 { + log.Printf("[tool:done] subagent → (empty)") + return + } + var data struct { + Output string `json:"output"` + Error string `json:"error"` + Usage struct { + Input int `json:"input"` + Output int `json:"output"` + CacheRead int `json:"cache_read"` + CacheWrite int `json:"cache_write"` + Cost float64 `json:"cost"` + Turns int `json:"turns"` + Tools int `json:"tools"` + } `json:"usage"` + } + if err := json.Unmarshal(result, &data); err != nil { + log.Printf("[tool:done] subagent → %s", truncateLog(string(result), 200)) + return + } + + // 记录 usage + u := data.Usage + log.Printf("[usage] input=%d output=%d cache_read=%d turns=%d tools=%d", + u.Input, u.Output, u.CacheRead, u.Turns, u.Tools) + + if data.Error != "" { + log.Printf("[subagent:error] %s", data.Error) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "ERROR", + Summary: "subagent: " + truncateLog(data.Error, 80), Level: "error"}) + } + return + } + + log.Printf("[tool:done] subagent → %s", truncateLog(data.Output, 200)) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: "subagent.done", Level: "info"}) + } +} + func extractToolErrorText(result json.RawMessage) string { if len(result) == 0 { return "" diff --git a/domain/runtime.go b/domain/runtime.go index ac841d9..aa3806c 100644 --- a/domain/runtime.go +++ b/domain/runtime.go @@ -76,7 +76,6 @@ func (p *Progress) NextChapter() int { type ContextProfile struct { SummaryWindow int // 加载最近 N 章摘要 TimelineWindow int // 加载最近 N 章时间线 - FullContext bool // true = 忽略窗口,全量加载 Layered bool // true = 启用分层摘要加载(卷摘要+弧摘要+章摘要) } @@ -84,11 +83,11 @@ type ContextProfile struct { func NewContextProfile(totalChapters int) ContextProfile { switch { case totalChapters <= 15: - return ContextProfile{FullContext: true} + return ContextProfile{SummaryWindow: 10, TimelineWindow: 10} case totalChapters <= 50: - return ContextProfile{SummaryWindow: 5, TimelineWindow: 10} + return ContextProfile{SummaryWindow: 5, TimelineWindow: 8} default: - return ContextProfile{SummaryWindow: 3, TimelineWindow: 8, Layered: true} + return ContextProfile{SummaryWindow: 3, TimelineWindow: 5, Layered: true} } } diff --git a/go.mod b/go.mod index afbf843..e50a0e4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,8 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/voocel/agentcore v1.5.1 + github.com/voocel/agentcore v1.5.3 + github.com/voocel/litellm v1.6.2 ) require ( @@ -28,8 +29,9 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/voocel/litellm v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) + +replace github.com/voocel/agentcore => ../agentcore diff --git a/go.sum b/go.sum index 127b41e..e47eebc 100644 --- a/go.sum +++ b/go.sum @@ -44,10 +44,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/voocel/agentcore v1.5.1 h1:gEVpBXZfXH4fkq4fLISo2dYfoQ+SaJ0NsetU/Y0hKrI= -github.com/voocel/agentcore v1.5.1/go.mod h1:fjksENApgfL1QXbcJY8RUUU5Gl03YOYExFAZ040X/zU= -github.com/voocel/litellm v1.6.0 h1:jc0Y7q+cp6QQcag3Mhmd6wMKkfzf7mXjXY0Uvj5VBQw= -github.com/voocel/litellm v1.6.0/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4= +github.com/voocel/litellm v1.6.2 h1:TJ1s7B7UqgV86O1EcuwQTZua0FK1tbOg0+oUsDmgmuA= +github.com/voocel/litellm v1.6.2/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= diff --git a/prompts/architect-long.md b/prompts/architect-long.md index 7685a91..1e2f985 100644 --- a/prompts/architect-long.md +++ b/prompts/architect-long.md @@ -114,3 +114,4 @@ - 不要过早透支所有高潮和谜底 - 不要把同一种爽点反复复制到每一卷 - 不要让中后期只是前期的放大版 +- **你必须按顺序完成全部 4 步(premise → layered_outline → characters → world_rules),全部保存后才算完成。每次 save_foundation 返回值中的 `remaining` 字段会告诉你还有哪些未完成,不要在 remaining 非空时停止。** diff --git a/prompts/architect-mid.md b/prompts/architect-mid.md index 91eabba..dcf79d3 100644 --- a/prompts/architect-mid.md +++ b/prompts/architect-mid.md @@ -109,3 +109,4 @@ - 中篇的关键是阶段推进和平衡 - 不要像短篇那样过度压缩 - 也不要像长篇那样预留过多远期空间 +- **你必须按顺序完成全部 4 步(premise → outline → characters → world_rules),全部保存后才算完成。每次 save_foundation 返回值中的 `remaining` 字段会告诉你还有哪些未完成,不要在 remaining 非空时停止。** diff --git a/prompts/architect-short.md b/prompts/architect-short.md index 9022df5..d6647c2 100644 --- a/prompts/architect-short.md +++ b/prompts/architect-short.md @@ -106,4 +106,5 @@ - 短篇最重要的是集中与收束 - 不要预埋大量未来再说的线 -- 不要把短篇写成“长篇开头” +- 不要把短篇写成”长篇开头” +- **你必须按顺序完成全部 4 步(premise → outline → characters → world_rules),全部保存后才算完成。每次 save_foundation 返回值中的 `remaining` 字段会告诉你还有哪些未完成,不要在 remaining 非空时停止。** diff --git a/prompts/coordinator.md b/prompts/coordinator.md index 753a73d..ea22861 100644 --- a/prompts/coordinator.md +++ b/prompts/coordinator.md @@ -60,18 +60,18 @@ 调用对应规划师完成基础设定: ```json -{"agent": "architect_short", "task": "根据以下需求生成短篇/单卷小说基础设定。\\n\\n<用户需求>"} +{"agent": "architect_short", "task": "根据以下需求生成短篇/单卷小说基础设定(premise + outline + characters + world_rules,全部保存后才算完成)。\\n\\n<用户需求>"} ``` ```json -{"agent": "architect_mid", "task": "根据以下需求生成中篇/多阶段小说基础设定。\\n\\n<用户需求>"} +{"agent": "architect_mid", "task": "根据以下需求生成中篇/多阶段小说基础设定(premise + outline + characters + world_rules,全部保存后才算完成)。\\n\\n<用户需求>"} ``` ```json -{"agent": "architect_long", "task": "根据以下需求生成长篇/连载型小说基础设定。\\n\\n<用户需求>"} +{"agent": "architect_long", "task": "根据以下需求生成长篇/连载型小说基础设定(premise + layered_outline + characters + world_rules,全部保存后才算完成)。\\n\\n<用户需求>"} ``` -规划完成后,用 novel_context 确认设定已保存,再开始写作。 +规划完成后,用 novel_context(不传 chapter)确认设定已保存。**必须检查返回值中的 `foundation_status.ready` 为 true 且 `foundation_status.missing` 为空**。如果有缺失项,重新调用对应规划师补全缺失部分,不要跳过直接写作。 ### 第二阶段:逐章写作 diff --git a/prompts/writer.md b/prompts/writer.md index 5e49378..1e8b67c 100644 --- a/prompts/writer.md +++ b/prompts/writer.md @@ -67,6 +67,8 @@ 如果写作过程中发现某个角色比预期更有魅力、某条支线比主线更有趣、或大纲的走向不太对,你可以在 commit_chapter 的 feedback 字段中反馈。系统会将你的建议转达给 Coordinator 评估。 ## 提交要求 +**你必须在完成写作后调用 commit_chapter,这是你的核心职责。没有 commit 就等于没有完成任何工作。** draft_chapter 只是保存草稿,commit_chapter 才是正式提交。 + commit_chapter 时提供: - summary: 本章内容摘要(200字以内) - characters: 本章出场角色名列表(使用正式名) diff --git a/tools/check_consistency.go b/tools/check_consistency.go index 472d5ff..cb4bdd8 100644 --- a/tools/check_consistency.go +++ b/tools/check_consistency.go @@ -21,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool { func (t *CheckConsistencyTool) Name() string { return "check_consistency" } func (t *CheckConsistencyTool) Description() string { - return "加载章节内容和全部状态数据(时间线、伏笔、关系、世界规则、角色状态),供你自行对照检查一致性" + return "加载章节内容和对照数据(世界规则、伏笔、关系、别名、最近摘要),供你检查一致性" } func (t *CheckConsistencyTool) Label() string { return "一致性检查" } @@ -55,18 +55,17 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage) result["content"] = content result["word_count"] = wordCount - // 状态数据(全部加载,Agent 自行决定怎么用) - if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 { - result["timeline"] = timeline + // 对照数据:保留全局性的一致性检查数据,避免重复加载 novel_context 已有的窗口数据 + if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 { + result["world_rules"] = rules } - if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 { + if foreshadow, _ := t.store.LoadActiveForeshadow(); len(foreshadow) > 0 { result["foreshadow_ledger"] = foreshadow } if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 { result["relationships"] = relationships } if chars, _ := t.store.LoadCharacters(); len(chars) > 0 { - result["characters"] = chars aliasMap := make(map[string]string) for _, c := range chars { for _, alias := range c.Aliases { @@ -77,12 +76,6 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage) result["alias_map"] = aliasMap } } - if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 { - result["recent_state_changes"] = changes - } - if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 { - result["world_rules"] = rules - } if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 { result["recent_summaries"] = summaries } diff --git a/tools/draft_chapter.go b/tools/draft_chapter.go index b3914df..2d1ac61 100644 --- a/tools/draft_chapter.go +++ b/tools/draft_chapter.go @@ -65,6 +65,7 @@ func (t *DraftChapterTool) Execute(_ context.Context, args json.RawMessage) (jso "chapter": a.Chapter, "mode": "append", "word_count": utf8.RuneCountInString(full), + "next_step": "自审后调用 commit_chapter 提交", }) default: // write if err := t.store.SaveDraft(a.Chapter, a.Content); err != nil { @@ -75,6 +76,7 @@ func (t *DraftChapterTool) Execute(_ context.Context, args json.RawMessage) (jso "chapter": a.Chapter, "mode": "write", "word_count": utf8.RuneCountInString(a.Content), + "next_step": "自审后调用 commit_chapter 提交", }) } } diff --git a/tools/novel_context.go b/tools/novel_context.go index 201bcc7..742ebbe 100644 --- a/tools/novel_context.go +++ b/tools/novel_context.go @@ -126,15 +126,9 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw warn("current_chapter_outline", err) } - // 摘要加载:分层 vs 扁平 + // 摘要加载:分层 vs 扁平窗口 if profile.Layered { t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow, warn) - } else if profile.FullContext { - if summaries, err := t.store.LoadAllSummaries(a.Chapter); err == nil && len(summaries) > 0 { - result["recent_summaries"] = summaries - } else { - warn("recent_summaries", err) - } } else { if summaries, err := t.store.LoadRecentSummaries(a.Chapter, profile.SummaryWindow); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries @@ -143,33 +137,17 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw } } - // 时间线:Layered 用窗口,其他按策略 - if profile.FullContext { - if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { - result["timeline"] = timeline - } else { - warn("timeline", err) - } + // 时间线:窗口加载 + if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 { + result["timeline"] = timeline } else { - if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 { - result["timeline"] = timeline - } else { - warn("timeline", err) - } + warn("timeline", err) } - // foreshadow:短篇全量,否则只取未回收条目 - if profile.FullContext { - if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 { - result["foreshadow_ledger"] = foreshadow - } else { - warn("foreshadow_ledger", err) - } + // foreshadow:只取未回收条目 + if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 { + result["foreshadow_ledger"] = foreshadow } else { - if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 { - result["foreshadow_ledger"] = foreshadow - } else { - warn("foreshadow_ledger", err) - } + warn("foreshadow_ledger", err) } // relationships:保持全量(pair-key 去重,数据量天然可控) if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 { @@ -177,8 +155,8 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw } else { warn("relationship_state", err) } - // 状态变化:最近 5 章的角色/实体状态变化 - if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 5); err == nil && len(changes) > 0 { + // 状态变化:最近 2 章 + if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 2); err == nil && len(changes) > 0 { result["recent_state_changes"] = changes } else { warn("recent_state_changes", err) @@ -231,6 +209,17 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw warn("chapter_plan", err) } + // 前章尾部:嵌入前一章末尾 ~800 字,Writer 无需额外调用 read_chapter 获取衔接上文 + if a.Chapter > 1 { + if prevText, err := t.store.LoadChapterText(a.Chapter - 1); err == nil && prevText != "" { + runes := []rune(prevText) + if len(runes) > 800 { + runes = runes[len(runes)-800:] + } + result["previous_tail"] = string(runes) + } + } + // 风格锚点:从前文提取代表性段落 if anchors := t.store.ExtractStyleAnchors(3); len(anchors) > 0 { result["style_anchors"] = anchors @@ -288,11 +277,20 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw warn("volume_summaries", err) } result["references"] = t.architectReferences() + + // 基础设定完备性检查 + result["foundation_status"] = t.foundationStatus() } if len(warnings) > 0 { result["_warnings"] = warnings } + + // 优先级预算:总大小超过阈值时自动裁剪低优先级数据 + if a.Chapter > 0 { + trimByBudget(result, 100*1024) // 100KB 预算 + } + result["_loading_summary"] = buildLoadingSummary(result, a.Chapter) return json.Marshal(result) } @@ -363,6 +361,9 @@ func buildLoadingSummary(result map[string]any, chapter int) string { if n := countSlice("recent_state_changes"); n > 0 { items = append(items, fmt.Sprintf("状态变化:%d", n)) } + if _, ok := result["previous_tail"]; ok { + items = append(items, "前章尾部:ok") + } // 参考资料 if refs, ok := result["references"].(map[string]string); ok && len(refs) > 0 { @@ -371,6 +372,9 @@ func buildLoadingSummary(result map[string]any, chapter int) string { if warnings, ok := result["_warnings"].([]string); ok && len(warnings) > 0 { items = append(items, fmt.Sprintf("告警:%d", len(warnings))) } + if trimmed, ok := result["_trimmed"].([]string); ok && len(trimmed) > 0 { + items = append(items, fmt.Sprintf("裁剪:%s", strings.Join(trimmed, ","))) + } if len(items) > 0 { parts = append(parts, strings.Join(items, " ")) @@ -520,15 +524,17 @@ func (t *ContextTool) writerReferences(chapter int) map[string]string { refs[k] = v } } - // 始终加载的核心参考 - add("chapter_guide", t.refs.ChapterGuide) + // 渐进式加载:始终保留核心参考,前 3 章额外加载完整写作指南 + add("consistency", t.refs.Consistency) add("hook_techniques", t.refs.HookTechniques) add("quality_checklist", t.refs.QualityChecklist) - add("consistency", t.refs.Consistency) - add("dialogue_writing", t.refs.DialogueWriting) - add("style_reference", t.refs.StyleReference) + if chapter <= 3 { + add("chapter_guide", t.refs.ChapterGuide) + add("dialogue_writing", t.refs.DialogueWriting) + add("style_reference", t.refs.StyleReference) + } - // 仅首章加载的补充参考(后续章节不再需要) + // 仅首章加载的补充参考 if chapter <= 1 { add("chapter_template", t.refs.ChapterTemplate) add("content_expansion", t.refs.ContentExpansion) @@ -551,6 +557,29 @@ func (t *ContextTool) architectReferences() map[string]string { return refs } +// foundationStatus 检查基础设定的完备性,返回缺失项列表。 +func (t *ContextTool) foundationStatus() map[string]any { + status := map[string]any{"ready": true} + var missing []string + if p, _ := t.store.LoadPremise(); p == "" { + missing = append(missing, "premise") + } + if o, _ := t.store.LoadOutline(); len(o) == 0 { + missing = append(missing, "outline") + } + if c, _ := t.store.LoadCharacters(); len(c) == 0 { + missing = append(missing, "characters") + } + if r, _ := t.store.LoadWorldRules(); len(r) == 0 { + missing = append(missing, "world_rules") + } + if len(missing) > 0 { + status["ready"] = false + status["missing"] = missing + } + return status +} + // ContextSummary 返回当前状态的简要摘要(供日志使用)。 func (t *ContextTool) ContextSummary() string { var parts []string @@ -568,3 +597,43 @@ func (t *ContextTool) ContextSummary() string { } return strings.Join(parts, ", ") } + +// trimByBudget 按优先级裁剪 result,使 JSON 总大小不超过 budget 字节。 +// 优先级(从低到高):references < voice_samples < style_anchors < previous_tail < timeline +// < recent_state_changes < foreshadow_ledger < relationship_state < 其余(不裁剪) +// 裁剪的 key 会记录到 result["_trimmed"] 供日志排查。 +func trimByBudget(result map[string]any, budget int) { + // 先测量当前大小 + data, err := json.Marshal(result) + if err != nil || len(data) <= budget { + return + } + + // 按优先级从低到高列出可裁剪的 key + trimOrder := []string{ + "references", + "voice_samples", + "style_anchors", + "previous_tail", + "timeline", + "recent_state_changes", + "foreshadow_ledger", + "relationship_state", + } + + var trimmed []string + for _, key := range trimOrder { + if _, ok := result[key]; !ok { + continue + } + delete(result, key) + trimmed = append(trimmed, key) + data, err = json.Marshal(result) + if err != nil || len(data) <= budget { + break + } + } + if len(trimmed) > 0 { + result["_trimmed"] = trimmed + } +} diff --git a/tools/save_foundation.go b/tools/save_foundation.go index ff5e4de..1bd8312 100644 --- a/tools/save_foundation.go +++ b/tools/save_foundation.go @@ -59,13 +59,14 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j } } + result := map[string]any{"saved": true, "type": a.Type, "scale": a.Scale} + switch a.Type { case "premise": if err := t.store.SavePremise(content); err != nil { return nil, fmt.Errorf("save premise: %w", err) } _ = t.store.UpdatePhase(domain.PhasePremise) - return json.Marshal(map[string]any{"saved": true, "type": "premise", "scale": a.Scale}) case "outline": var entries []domain.OutlineEntry @@ -76,14 +77,13 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j return nil, fmt.Errorf("save outline: %w", err) } _ = t.store.UpdatePhase(domain.PhaseOutline) - // 根据大纲长度自动设定总章节数 _ = t.store.SetTotalChapters(len(entries)) if domain.PlanningTier(a.Scale) != domain.PlanningTierLong { _ = t.store.SetLayered(false) _ = t.store.UpdateVolumeArc(0, 0) _ = t.store.ClearLayeredOutline() } - return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries), "scale": a.Scale}) + result["chapters"] = len(entries) case "layered_outline": var volumes []domain.VolumeOutline @@ -93,7 +93,6 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j if err := t.store.SaveLayeredOutline(volumes); err != nil { return nil, fmt.Errorf("save layered_outline: %w", err) } - // 展开为扁平大纲,兼容现有 GetChapterOutline flat := domain.FlattenOutline(volumes) if err := t.store.SaveOutline(flat); err != nil { return nil, fmt.Errorf("save flattened outline: %w", err) @@ -105,11 +104,8 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j if len(volumes) > 0 && len(volumes[0].Arcs) > 0 { _ = t.store.UpdateVolumeArc(volumes[0].Index, volumes[0].Arcs[0].Index) } - return json.Marshal(map[string]any{ - "saved": true, "type": "layered_outline", - "volumes": len(volumes), "chapters": total, - "scale": a.Scale, - }) + result["volumes"] = len(volumes) + result["chapters"] = total case "characters": var chars []domain.Character @@ -119,7 +115,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j if err := t.store.SaveCharacters(chars); err != nil { return nil, fmt.Errorf("save characters: %w", err) } - return json.Marshal(map[string]any{"saved": true, "type": "characters", "count": len(chars), "scale": a.Scale}) + result["count"] = len(chars) case "world_rules": var rules []domain.WorldRule @@ -129,11 +125,15 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j if err := t.store.SaveWorldRules(rules); err != nil { return nil, fmt.Errorf("save world_rules: %w", err) } - return json.Marshal(map[string]any{"saved": true, "type": "world_rules", "count": len(rules), "scale": a.Scale}) + result["count"] = len(rules) default: return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type) } + + // 返回剩余未完成项,引导 Architect 继续 + result["remaining"] = t.remaining() + return json.Marshal(result) } func normalizeFoundationContent(raw json.RawMessage) (string, error) { @@ -151,3 +151,21 @@ func normalizeFoundationContent(raw json.RawMessage) (string, error) { } return string(raw), nil } + +// remaining 检查基础设定中还缺少哪些必要项。 +func (t *SaveFoundationTool) remaining() []string { + var missing []string + if p, _ := t.store.LoadPremise(); p == "" { + missing = append(missing, "premise") + } + if o, _ := t.store.LoadOutline(); len(o) == 0 { + missing = append(missing, "outline") + } + if c, _ := t.store.LoadCharacters(); len(c) == 0 { + missing = append(missing, "characters") + } + if r, _ := t.store.LoadWorldRules(); len(r) == 0 { + missing = append(missing, "world_rules") + } + return missing +}