diff --git a/server/api/src/__tests__/agents/graphs/ingest/formatResearchForIngest.test.ts b/server/api/src/__tests__/agents/graphs/ingest/formatResearchForIngest.test.ts index fcb0afea..1bc7dd26 100644 --- a/server/api/src/__tests__/agents/graphs/ingest/formatResearchForIngest.test.ts +++ b/server/api/src/__tests__/agents/graphs/ingest/formatResearchForIngest.test.ts @@ -24,7 +24,7 @@ function baseState(): IngestPlannerStateType { iteration: 1, queries: [], sources: [], - evaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + evaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] }, createdAt: "2026-01-01T00:00:00.000Z", }, ], diff --git a/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts b/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts index a4f0ccaa..30a1bb17 100644 --- a/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts +++ b/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts @@ -102,7 +102,7 @@ function defaultMocks() { wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); evaluateSufficiency.mockImplementation(async (state: { iteration: number }) => ({ - lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] }, iteration: state.iteration + 1, phase: "research:evaluated", })); diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts index 9c0c8eda..70464f2e 100644 --- a/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts @@ -74,7 +74,6 @@ describe("humanReviewBrief", () => { }, ], appendToExisting: true, - researchMaxIterations: 4, }); const update = await humanReviewBrief(state({}), { configurable: {} } as never); @@ -97,7 +96,6 @@ describe("humanReviewBrief", () => { ]); expect(brief.summary).toContain("Target audience?"); expect(brief.summary).toContain("selected=opt-a"); - expect(update.maxIterations).toBe(4); }); it("accepts an empty answers array (explicit Brief skip)", async () => { @@ -111,13 +109,6 @@ describe("humanReviewBrief", () => { expect(brief.answers).toEqual([]); expect(brief.summary).toBe("(no brief provided)"); expect(brief.appendToExisting).toBe(false); - expect(update.maxIterations).toBeUndefined(); - }); - - it("rejects researchMaxIterations outside 1..5", async () => { - interrupt.mockReturnValueOnce({ answers: [], researchMaxIterations: 9 }); - - await expect(humanReviewBrief(state({}), { configurable: {} } as never)).rejects.toThrow(); }); it("rejects answers missing questionId", async () => { diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts index 46ba64f9..d5c259ed 100644 --- a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts @@ -124,7 +124,7 @@ function defaultMocks() { wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); evaluateSufficiency.mockImplementation(async (state: { iteration: number }) => ({ - lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] }, iteration: state.iteration + 1, phase: "research:evaluated", })); diff --git a/server/api/src/__tests__/agents/subgraphs/research/constants.test.ts b/server/api/src/__tests__/agents/subgraphs/research/constants.test.ts new file mode 100644 index 00000000..6631d2f6 --- /dev/null +++ b/server/api/src/__tests__/agents/subgraphs/research/constants.test.ts @@ -0,0 +1,38 @@ +/** + * Research loop iteration cap resolution tests. + */ +import { describe, expect, it } from "vitest"; +import { + INGEST_RESEARCH_GRAPH_ID, + RESEARCH_SAFETY_MAX_ITERATIONS, + clampIngestMaxIterations, + resolveResearchMaxIterations, +} from "../../../../agents/subgraphs/research/constants.js"; +import { WIKI_COMPOSE_GRAPH_ID } from "../../../../agents/graphs/wikiCompose/index.js"; + +describe("clampIngestMaxIterations", () => { + it("clamps ingest caps to 1..5 with default 3", () => { + expect(clampIngestMaxIterations(undefined)).toBe(3); + expect(clampIngestMaxIterations(99)).toBe(5); + expect(clampIngestMaxIterations(4)).toBe(4); + }); +}); + +describe("resolveResearchMaxIterations", () => { + it("honours ingest graph caps from state", () => { + expect(resolveResearchMaxIterations(INGEST_RESEARCH_GRAPH_ID, 4)).toBe(4); + expect(resolveResearchMaxIterations(INGEST_RESEARCH_GRAPH_ID, 99)).toBe(5); + }); + + it("uses the safety cap for Wiki Compose graphs regardless of legacy state", () => { + expect(resolveResearchMaxIterations(WIKI_COMPOSE_GRAPH_ID, undefined)).toBe( + RESEARCH_SAFETY_MAX_ITERATIONS, + ); + expect(resolveResearchMaxIterations(WIKI_COMPOSE_GRAPH_ID, 3)).toBe( + RESEARCH_SAFETY_MAX_ITERATIONS, + ); + expect(resolveResearchMaxIterations("wiki-compose-research", 3)).toBe( + RESEARCH_SAFETY_MAX_ITERATIONS, + ); + }); +}); diff --git a/server/api/src/__tests__/agents/subgraphs/research/nodes/compileBatch.test.ts b/server/api/src/__tests__/agents/subgraphs/research/nodes/compileBatch.test.ts index 005c3cb9..9ebb0986 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/nodes/compileBatch.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/nodes/compileBatch.test.ts @@ -1,16 +1,12 @@ /** * `compileBatch` unit tests. Pure projection node; no LLM. We verify: - * - `exitReason` = "score_threshold" when score >= 0.75. - * - `exitReason` = "max_iterations" otherwise. + * - `exitReason` = "score_threshold" when evaluation is sufficient. + * - `exitReason` = "safety_cap" for Wiki Compose iteration cap hits. + * - `exitReason` = "max_iterations" for ingest iteration cap hits. * - Batch fields are populated from state. - * - `dispatchCustomEvent` is called via the runnable config. */ import { describe, expect, it, vi } from "vitest"; -// The dispatch helper requires a proper LangChain callback manager which we -// don't set up here (`compileBatch` is a pure projection). Stub it so the -// node can dispatch into a no-op without a real callback runtime. -// dispatch ヘルパは callback manager 必須なので test では no-op に差し替える。 const { dispatchResearchBatch } = vi.hoisted(() => ({ dispatchResearchBatch: vi.fn(async () => undefined), })); @@ -20,9 +16,15 @@ vi.mock("../../../../../agents/subgraphs/research/nodes/shared/dispatchSseCustom dispatchResearchIteration: vi.fn(), })); -import { compileBatch } from "../../../../../agents/subgraphs/research/nodes/compileBatch.js"; +import { + compileBatch, + resolveResearchExitReason, +} from "../../../../../agents/subgraphs/research/nodes/compileBatch.js"; +import { GRAPH_CONTEXT_CONFIG_KEY } from "../../../../../agents/core/types/graphContext.js"; +import type { GraphContext } from "../../../../../agents/core/types/graphContext.js"; import type { ResearchLoopStateType } from "../../../../../agents/subgraphs/research/state.js"; import type { ResearchBatch } from "../../../../../agents/subgraphs/research/types.js"; +import type { Database } from "../../../../../types/index.js"; function state(overrides: Partial): ResearchLoopStateType { return { @@ -47,18 +49,75 @@ function state(overrides: Partial): ResearchLoopStateType }; } +function configForGraph(graphId: string) { + const ctx: GraphContext = { + threadId: "t", + sessionId: "t", + userId: "user-1", + pageId: "page-1", + graphId, + backend: "zedi_managed", + tier: "free", + db: {} as Database, + feature: "wiki_compose:research", + userEmail: null, + contentLocale: "ja", + }; + return { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: ctx } }; +} + +describe("resolveResearchExitReason", () => { + it("returns score_threshold when sufficient is true", () => { + expect( + resolveResearchExitReason( + state({ + lastEvaluation: { score: 0.2, sufficient: true, rationale: "ok", missingAspects: [] }, + }), + "wiki-compose", + ), + ).toBe("score_threshold"); + }); + + it("returns safety_cap for Wiki Compose iteration cap hits", () => { + expect( + resolveResearchExitReason( + state({ + lastEvaluation: { + score: 0.2, + sufficient: false, + rationale: "weak", + missingAspects: ["x"], + }, + }), + "wiki-compose", + ), + ).toBe("safety_cap"); + }); + + it("returns max_iterations for ingest iteration cap hits", () => { + expect( + resolveResearchExitReason( + state({ + lastEvaluation: { + score: 0.2, + sufficient: false, + rationale: "weak", + missingAspects: ["x"], + }, + }), + "ingest-planner", + ), + ).toBe("max_iterations"); + }); +}); + describe("compileBatch", () => { - it("uses score_threshold when last score >= 0.75", async () => { - const dispatcher = vi.fn(); - const config = { - configurable: { callbacks: undefined }, - callbacks: { handlers: [], inheritableHandlers: [], dispatchCustomEvent: dispatcher }, - }; + it("uses score_threshold when last evaluation is sufficient", async () => { const update = await compileBatch( - state({ lastEvaluation: { score: 0.85, rationale: "ok", missingAspects: [] } }), - // Loose config type — node only reads callback runtime, which LangGraph - // wires through the surrounding `streamEvents` / `invoke` call. - config as never, + state({ + lastEvaluation: { score: 0.85, sufficient: true, rationale: "ok", missingAspects: [] }, + }), + configForGraph("wiki-compose") as never, ); expect(update.exitReason).toBe("score_threshold"); const batches = update.batches as ResearchBatch[] | undefined; @@ -67,19 +126,32 @@ describe("compileBatch", () => { expect(batches?.[0]?.iteration).toBe(2); }); - it("uses max_iterations when no eval or score below threshold", async () => { + it("uses safety_cap for Wiki Compose when evaluation is insufficient", async () => { const update = await compileBatch( - state({ lastEvaluation: { score: 0.5, rationale: "weak", missingAspects: ["x"] } }), - { configurable: {} } as never, + state({ + lastEvaluation: { score: 0.5, sufficient: false, rationale: "weak", missingAspects: ["x"] }, + }), + configForGraph("wiki-compose-research") as never, + ); + expect(update.exitReason).toBe("safety_cap"); + }); + + it("uses max_iterations for ingest when evaluation is insufficient", async () => { + const update = await compileBatch( + state({ + lastEvaluation: { score: 0.5, sufficient: false, rationale: "weak", missingAspects: ["x"] }, + }), + configForGraph("ingest-planner") as never, ); expect(update.exitReason).toBe("max_iterations"); }); it("handles null evaluation gracefully", async () => { - const update = await compileBatch(state({ lastEvaluation: null }), { - configurable: {}, - } as never); - expect(update.exitReason).toBe("max_iterations"); + const update = await compileBatch( + state({ lastEvaluation: null }), + configForGraph("wiki-compose") as never, + ); + expect(update.exitReason).toBe("safety_cap"); const batches = update.batches as ResearchBatch[] | undefined; expect(batches?.[0]?.evaluation).toBeNull(); }); diff --git a/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts b/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts index afaa639a..071f45e8 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts @@ -32,6 +32,11 @@ vi.mock("../../../../../agents/subgraphs/research/nodes/shared/dispatchSseCustom })); import { planQueries } from "../../../../../agents/subgraphs/research/nodes/planQueries.js"; +import { + INGEST_RESEARCH_GRAPH_ID, + RESEARCH_SAFETY_MAX_ITERATIONS, +} from "../../../../../agents/subgraphs/research/constants.js"; +import { WIKI_COMPOSE_GRAPH_ID } from "../../../../../agents/graphs/wikiCompose/index.js"; import { GRAPH_CONTEXT_CONFIG_KEY } from "../../../../../agents/core/types/graphContext.js"; import type { GraphContext } from "../../../../../agents/core/types/graphContext.js"; import type { Database } from "../../../../../types/index.js"; @@ -93,9 +98,25 @@ afterEach(() => { describe("planQueries — additional research detection", () => { const config = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: fakeContext() } }; - it("clamps maxIterations to 1..5 (default 3)", async () => { + it("uses the safety cap for Wiki Compose even when legacy state has maxIterations: 3", async () => { + const ctx = fakeContext(); + ctx.graphId = WIKI_COMPOSE_GRAPH_ID; + const wikiConfig = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: ctx } }; + const update = await planQueries(state({ maxIterations: 3 }), wikiConfig as never); + expect(update.maxIterations).toBe(RESEARCH_SAFETY_MAX_ITERATIONS); + }); + + it("uses the safety cap when graph is standalone research", async () => { const update = await planQueries(state({ maxIterations: 99 }), config as never); - expect(update.maxIterations).toBe(5); + expect(update.maxIterations).toBe(RESEARCH_SAFETY_MAX_ITERATIONS); + }); + + it("honours ingest graph caps from state", async () => { + const ctx = fakeContext(); + ctx.graphId = INGEST_RESEARCH_GRAPH_ID; + const ingestConfig = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: ctx } }; + const update = await planQueries(state({ maxIterations: 4 }), ingestConfig as never); + expect(update.maxIterations).toBe(4); }); it("consumes state.additionalRequest and seeds carried-over sources", async () => { diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.conditional.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.conditional.test.ts index ac8fa778..e9320a86 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.conditional.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.conditional.test.ts @@ -37,7 +37,7 @@ describe("shouldRefine", () => { state({ iteration: 1, maxIterations: 5, - lastEvaluation: { score: 0.75, rationale: "ok", missingAspects: [] }, + lastEvaluation: { score: 0.75, sufficient: true, rationale: "ok", missingAspects: [] }, }), ), ).toBe("compile"); @@ -49,7 +49,7 @@ describe("shouldRefine", () => { state({ iteration: 1, maxIterations: 5, - lastEvaluation: { score: 0.95, rationale: "great", missingAspects: [] }, + lastEvaluation: { score: 0.95, sufficient: true, rationale: "great", missingAspects: [] }, }), ), ).toBe("compile"); @@ -61,7 +61,12 @@ describe("shouldRefine", () => { state({ iteration: 1, maxIterations: 3, - lastEvaluation: { score: 0.5, rationale: "weak", missingAspects: ["x"] }, + lastEvaluation: { + score: 0.5, + sufficient: false, + rationale: "weak", + missingAspects: ["x"], + }, }), ), ).toBe("refine"); @@ -73,7 +78,12 @@ describe("shouldRefine", () => { state({ iteration: 3, maxIterations: 3, - lastEvaluation: { score: 0.4, rationale: "weak", missingAspects: ["x", "y"] }, + lastEvaluation: { + score: 0.4, + sufficient: false, + rationale: "weak", + missingAspects: ["x", "y"], + }, }), ), ).toBe("compile"); @@ -83,6 +93,18 @@ describe("shouldRefine", () => { expect(shouldRefine(state({ iteration: 4, maxIterations: 3 }))).toBe("compile"); }); + it("compiles when sufficient is true even if score is below threshold", () => { + expect( + shouldRefine( + state({ + iteration: 1, + maxIterations: 5, + lastEvaluation: { score: 0.4, sufficient: true, rationale: "ok", missingAspects: [] }, + }), + ), + ).toBe("compile"); + }); + it("refines when there's no evaluation yet and iterations remain", () => { // Defensive: if evaluate_sufficiency hasn't run, treat as "not enough yet". // evaluation 未走の保険 — まだ充足してないとみなす。 diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts index c0ffdb53..c72f5abf 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts @@ -101,7 +101,7 @@ describe("researchLoopSubgraph — interrupt at human_review_research", () => { wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); evaluateSufficiency.mockImplementation(async (state, _c) => ({ - lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] }, iteration: state.iteration + 1, phase: "research:evaluated", })); diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts index 4b1fcf44..873aeeb1 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts @@ -124,7 +124,7 @@ describe("researchLoopSubgraph — autonomous loop", () => { evaluateSufficiency.mockImplementation(async (state, _config) => { evaluatedTimes += 1; return { - lastEvaluation: { score: 0.1, rationale: "weak", missingAspects: ["x"] }, + lastEvaluation: { score: 0.1, sufficient: false, rationale: "weak", missingAspects: ["x"] }, iteration: state.iteration + 1, phase: "research:evaluated", }; @@ -194,7 +194,7 @@ describe("researchLoopSubgraph — autonomous loop", () => { wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); evaluateSufficiency.mockImplementation(async (state, _c) => ({ - lastEvaluation: { score: 0.9, rationale: "great", missingAspects: [] }, + lastEvaluation: { score: 0.9, sufficient: true, rationale: "great", missingAspects: [] }, iteration: state.iteration + 1, phase: "research:evaluated", })); diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts index edd3a053..ecc5b0c9 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts @@ -138,6 +138,7 @@ describe("researchLoopSubgraph — all LLM calls go through ZediChatModel", () = const score = evaluateCall >= 2 ? 0.9 : 0.1; return fakeModel(async () => ({ score, + sufficient: score >= 0.75, rationale: "auto", missingAspects: score < 0.75 ? ["x"] : [], })); diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts index 0385ff89..4f328780 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts @@ -105,7 +105,7 @@ describe("researchLoopSubgraph — resume projects approvedResearch", () => { wikiSearch.mockImplementation(async () => ({ pendingSources: pending.slice(2) })); fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); evaluateSufficiency.mockImplementation(async (state, _c) => ({ - lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] }, iteration: state.iteration + 1, phase: "research:evaluated", })); @@ -189,7 +189,7 @@ describe("researchLoopSubgraph — resume projects approvedResearch", () => { wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); evaluateSufficiency.mockImplementation(async (state, _c) => ({ - lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] }, iteration: state.iteration + 1, phase: "research:evaluated", })); diff --git a/server/api/src/agents/core/types/sseEvents.ts b/server/api/src/agents/core/types/sseEvents.ts index f2d83d92..56df62ce 100644 --- a/server/api/src/agents/core/types/sseEvents.ts +++ b/server/api/src/agents/core/types/sseEvents.ts @@ -164,7 +164,7 @@ export interface SseResearchBatchEvent { /** Last evaluation score (null only if compile fired before any evaluate). */ score: number | null; /** Reason the loop exited. */ - exitReason: "score_threshold" | "max_iterations"; + exitReason: "score_threshold" | "max_iterations" | "safety_cap"; } /** diff --git a/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts b/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts index 5ca4579e..076f8766 100644 --- a/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts +++ b/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts @@ -7,14 +7,9 @@ import { HumanMessage } from "@langchain/core/messages"; import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import { clampIngestMaxIterations } from "../../../subgraphs/research/constants.js"; import type { IngestPlannerStateType, IngestPlannerStateUpdate } from "../state.js"; -function clampMaxIterations(raw: number): number { - if (!Number.isFinite(raw)) return 3; - const truncated = Math.trunc(raw); - return Math.min(Math.max(truncated, 1), 5); -} - /** * Project graph run input into ingest + research seed state. * @@ -34,7 +29,7 @@ export async function prepareIngest( const candidates = state.candidates; const userSchema = state.userSchema; - const maxIterations = clampMaxIterations(state.maxIterations); + const maxIterations = clampIngestMaxIterations(state.maxIterations); for (const [i, c] of candidates.entries()) { if (!c?.id?.trim() || typeof c.title !== "string" || !c.title.trim()) { diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts b/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts index 831ea038..941726c3 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts @@ -4,13 +4,10 @@ * Brief 質問群を `interrupt(value)` でユーザーに渡し、`PATCH .../resume` の * 結果を `briefResumeSchema` で検証して `brief` を state に確定する。 * 既存本文ありで「追記」を選んだ場合は `appendToExisting=true` が立ち、Draft - * フェーズがそれを読んで挙動を切り替える。`researchMaxIterations` (1..5) が - * 指定されていれば、後段の Research subgraph に渡るようミラーする。 + * フェーズがそれを読んで挙動を切り替える。 * * Halts the graph at the Brief interrupt and projects the user's answers into - * `state.brief`. The resume payload's `researchMaxIterations` (when present) - * is mirrored to `state.researchMaxIterations` so the research subgraph node - * picks it up via its own state slot when invoked. + * `state.brief`. */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { interrupt } from "@langchain/langgraph"; @@ -127,11 +124,5 @@ export async function humanReviewBrief( brief, phase: "brief:completed", }; - if (parsed.researchMaxIterations !== undefined) { - // Mirror onto the canonical research subgraph channel name so the - // composed research node picks it up via shared state. - // research subgraph と共有する `maxIterations` チャネルに反映する。 - update.maxIterations = parsed.researchMaxIterations; - } return update; } diff --git a/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts b/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts index dec5f9d4..8fa16fa0 100644 --- a/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts +++ b/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts @@ -16,13 +16,11 @@ import { z } from "zod"; * * - `answers` — 必須。空配列でも可(Brief をスキップしたケース)。 * - `appendToExisting` — 本文ありページで「追記」を選んだ場合 true。 - * - `researchMaxIterations` — Brief 内で 1..5 にユーザーが調整した場合のみ。 * * Validates the resume payload at the Brief interrupt. `answers` is required * even when empty (the user may explicitly skip Brief by submitting an empty * array). Default for `appendToExisting` is `false` (replace-mode is the - * historical Wiki Compose behaviour); `researchMaxIterations` is clamped to - * 1..5 by the schema so the graph never sees an out-of-range value. + * historical Wiki Compose behaviour). */ export const briefResumeSchema = z.object({ answers: z @@ -35,7 +33,6 @@ export const briefResumeSchema = z.object({ ) .default([]), appendToExisting: z.boolean().optional().default(false), - researchMaxIterations: z.number().int().min(1).max(5).optional(), }); export type BriefResumeParsed = z.infer; diff --git a/server/api/src/agents/graphs/wikiCompose/state.ts b/server/api/src/agents/graphs/wikiCompose/state.ts index 8960d4cd..1d84fea6 100644 --- a/server/api/src/agents/graphs/wikiCompose/state.ts +++ b/server/api/src/agents/graphs/wikiCompose/state.ts @@ -19,6 +19,7 @@ */ import { Annotation } from "@langchain/langgraph"; import { BaseState } from "../../core/state/baseState.js"; +import { RESEARCH_SAFETY_MAX_ITERATIONS } from "../../subgraphs/research/constants.js"; import type { AdditionalResearchRequest, Evaluation, @@ -155,10 +156,10 @@ export const WikiComposeState = Annotation.Root({ reducer: (_prev, next) => next, default: () => 0, }), - /** ループ上限(Brief で 1..5 にユーザー設定可、デフォルト 3)。 */ + /** ループ上限(自律調査の安全 cap。ingest 連携時のみ 1..5 の明示 cap あり)。 */ maxIterations: Annotation({ reducer: (prev, next) => next ?? prev, - default: () => 3, + default: () => RESEARCH_SAFETY_MAX_ITERATIONS, }), /** Research subgraph 内の直近クエリ。 */ queries: Annotation({ diff --git a/server/api/src/agents/graphs/wikiCompose/types.ts b/server/api/src/agents/graphs/wikiCompose/types.ts index a80c0114..cf39622b 100644 --- a/server/api/src/agents/graphs/wikiCompose/types.ts +++ b/server/api/src/agents/graphs/wikiCompose/types.ts @@ -272,8 +272,6 @@ export interface BriefResumeInput { answers: BriefAnswer[]; /** True when the user chose "append to existing body" (U2). */ appendToExisting?: boolean; - /** Optional override for the research loop's max iterations (1..5). */ - researchMaxIterations?: number; } /** Resume payload for the outline interrupt. */ diff --git a/server/api/src/agents/runner/sseMapper.ts b/server/api/src/agents/runner/sseMapper.ts index 3855f972..60d62a0c 100644 --- a/server/api/src/agents/runner/sseMapper.ts +++ b/server/api/src/agents/runner/sseMapper.ts @@ -305,7 +305,9 @@ function mapResearchBatch(data: Record): SseResearchBatchEvent[ const sourceCount = typeof data.sourceCount === "number" ? data.sourceCount : null; const score = data.score === null || typeof data.score === "number" ? data.score : null; const exitReason = - data.exitReason === "score_threshold" || data.exitReason === "max_iterations" + data.exitReason === "score_threshold" || + data.exitReason === "max_iterations" || + data.exitReason === "safety_cap" ? data.exitReason : null; if (batchId === null || iteration === null || sourceCount === null || exitReason === null) { diff --git a/server/api/src/agents/subgraphs/research/constants.ts b/server/api/src/agents/subgraphs/research/constants.ts new file mode 100644 index 00000000..f7467885 --- /dev/null +++ b/server/api/src/agents/subgraphs/research/constants.ts @@ -0,0 +1,68 @@ +/** + * Research loop limits and shared thresholds for Wiki Compose / ingest graphs. + * Wiki Compose / ingest グラフ共通の調査ループ定数。 + */ + +/** + * Hard safety cap for autonomous Wiki Compose research loops. The evaluator + * LLM decides when sources are sufficient; this constant prevents runaway loops. + * + * 自律調査の安全上限。充足度評価 LLM が十分と判断するまでループし、 + * 無限ループ防止のためだけにこの上限を使う。 + */ +export const RESEARCH_SAFETY_MAX_ITERATIONS = 10; + +/** + * Score at or above which research is treated as sufficient when the evaluator + * does not set {@link Evaluation.sufficient} explicitly. + * + * 評価 LLM が `sufficient` を返さない場合のフォールバック閾値。 + */ +export const RESEARCH_SUFFICIENCY_SCORE_THRESHOLD = 0.75; + +/** + * Ingest planner graph id. Keep in sync with + * {@link INGEST_PLANNER_GRAPH_ID} in `graphs/ingest/ingestPlannerGraph.ts`. + * + * ingest グラフ ID。ingest 側の定数と文字列一致を維持する。 + */ +export const INGEST_RESEARCH_GRAPH_ID = "ingest-planner" as const; + +/** Ingest API explicit iteration cap range (1..5). / ingest 明示 cap の範囲。 */ +export const INGEST_EXPLICIT_MAX_ITERATIONS_MIN = 1; +export const INGEST_EXPLICIT_MAX_ITERATIONS_MAX = 5; + +/** + * Clamp ingest `maxIterations` input to 1..5 (default 3 when invalid). + * + * ingest の `maxIterations` を 1..5 にクランプする。 + * + * @param raw Value from ingest graph state / run input. + */ +export function clampIngestMaxIterations(raw: unknown): number { + if (typeof raw !== "number" || !Number.isFinite(raw)) return 3; + const truncated = Math.trunc(raw); + return Math.min( + Math.max(truncated, INGEST_EXPLICIT_MAX_ITERATIONS_MIN), + INGEST_EXPLICIT_MAX_ITERATIONS_MAX, + ); +} + +/** + * Resolve the iteration cap for the research loop from the owning graph id. + * + * - ingest planner → clamped caller cap (1..5) + * - Wiki Compose / standalone research → {@link RESEARCH_SAFETY_MAX_ITERATIONS} + * + * Legacy checkpoints with `maxIterations: 3` on Wiki Compose graphs are ignored; + * only ingest uses state-provided caps. + * + * @param graphId Owning graph id from {@link GraphContext}. + * @param stateMaxIterations Current `state.maxIterations` (ingest only). + */ +export function resolveResearchMaxIterations(graphId: string, stateMaxIterations: unknown): number { + if (graphId === INGEST_RESEARCH_GRAPH_ID) { + return clampIngestMaxIterations(stateMaxIterations); + } + return RESEARCH_SAFETY_MAX_ITERATIONS; +} diff --git a/server/api/src/agents/subgraphs/research/nodes/compileBatch.ts b/server/api/src/agents/subgraphs/research/nodes/compileBatch.ts index b9561dfc..b952d428 100644 --- a/server/api/src/agents/subgraphs/research/nodes/compileBatch.ts +++ b/server/api/src/agents/subgraphs/research/nodes/compileBatch.ts @@ -8,9 +8,26 @@ */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { randomUUID } from "node:crypto"; +import { INGEST_RESEARCH_GRAPH_ID } from "../constants.js"; +import { isResearchSufficient } from "../shouldRefine.js"; +import { getGraphContext } from "./shared/getGraphContext.js"; import { dispatchResearchBatch } from "./shared/dispatchSseCustom.js"; import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js"; -import type { ExitReason, ResearchBatch } from "../types.js"; +import type { ResearchBatch, ResearchLoopCompileExitReason } from "../types.js"; + +/** + * Derive the loop exit reason from evaluation + graph ownership. + * + * @param state Current research-loop state after `evaluate_sufficiency`. + * @param graphId Owning graph id from {@link GraphContext}. + */ +export function resolveResearchExitReason( + state: ResearchLoopStateType, + graphId: string, +): ResearchLoopCompileExitReason { + if (isResearchSufficient(state.lastEvaluation)) return "score_threshold"; + return graphId === INGEST_RESEARCH_GRAPH_ID ? "max_iterations" : "safety_cap"; +} /** * `compile_batch` node — pure projection that freezes the current state into a @@ -28,9 +45,9 @@ export async function compileBatch( state: ResearchLoopStateType, config: LangGraphRunnableConfig, ): Promise { + const ctx = getGraphContext(config); const score = state.lastEvaluation?.score ?? null; - const exitReason: ExitReason = - score !== null && score >= 0.75 ? "score_threshold" : "max_iterations"; + const exitReason = resolveResearchExitReason(state, ctx.graphId); const batch: ResearchBatch = { id: randomUUID(), iteration: state.iteration, diff --git a/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts b/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts index aefb2cae..181f76eb 100644 --- a/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts +++ b/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts @@ -6,7 +6,7 @@ * 現在の `pendingSources` が brief を満たしているかを LLM で評価し、 * `score` (0..1) と `missingAspects` を返す。post-increment した `iteration` * を返すことで、後段の `shouldRefine` がループ終了条件 - * (`score >= 0.75 || iteration >= maxIterations`) を正しく判定できる。 + * (`score >= threshold || iteration >= cap`) を正しく判定できる。 */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { z } from "zod"; @@ -15,19 +15,24 @@ import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; import { dispatchResearchEvaluation } from "./shared/dispatchSseCustom.js"; +import { RESEARCH_SUFFICIENCY_SCORE_THRESHOLD } from "../constants.js"; import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js"; import type { Evaluation } from "../types.js"; export const evaluationSchema = z.object({ score: z.number().min(0).max(1), + sufficient: z.boolean(), rationale: z.string().min(1).max(500), missingAspects: z.array(z.string().min(1)).max(5), }); const SYSTEM_PROMPT = "You are evaluating whether the research sources collected so far are sufficient " + - "to write the requested wiki article. Score 0..1 (≥0.75 means 'good enough'), " + - "give a short rationale, and list up to 5 missing aspects. Output JSON only."; + "to write the requested wiki article. Set `sufficient` to true when coverage is " + + "good enough to proceed without another research pass. Also provide a 0..1 score " + + `(>= ${RESEARCH_SUFFICIENCY_SCORE_THRESHOLD} typically means sufficient), a short ` + + "rationale, and up to 5 missing aspects that would still matter for the article. " + + "Output JSON only."; function buildUserPrompt(state: ResearchLoopStateType): string { const brief = state.messages @@ -49,7 +54,8 @@ function buildUserPrompt(state: ResearchLoopStateType): string { `[Sources collected: ${state.pendingSources.length}]`, ...sourceLines, "", - `Iteration so far: ${state.iteration} / ${state.maxIterations}`, + `Research iteration completed so far: ${state.iteration}`, + "Continue refining only if important gaps remain for the brief.", ].join("\n"); } @@ -97,6 +103,7 @@ export async function evaluateSufficiency( ]); const evaluation: Evaluation = { score: parsed.score, + sufficient: parsed.sufficient, rationale: parsed.rationale, missingAspects: parsed.missingAspects, }; diff --git a/server/api/src/agents/subgraphs/research/nodes/planQueries.ts b/server/api/src/agents/subgraphs/research/nodes/planQueries.ts index 1e730584..679e1a1d 100644 --- a/server/api/src/agents/subgraphs/research/nodes/planQueries.ts +++ b/server/api/src/agents/subgraphs/research/nodes/planQueries.ts @@ -2,7 +2,7 @@ * `plan_queries` — generates the initial query set for the research loop. * * 調査ループの最初のノード。Brief / 指示メッセージから 1〜8 件の調査クエリを - * 生成し、`maxIterations` を 1..5 にクランプする。"additional_research" 入力で + * 生成する。"additional_research" 入力で * 既存セッションの追加調査として呼ばれた場合、`iteration / lastEvaluation / * exitReason` をリセットし、`carryOverApprovedIds` で `pendingSources` を初期化 * する(issue #949 の追加調査 API パス)。 @@ -52,12 +52,7 @@ const SYSTEM_PROMPT = "and 'web' for queries needing fresh public information. Output JSON only."; import type { AdditionalResearchRequest } from "../types.js"; - -function clampMaxIterations(raw: unknown): number { - if (typeof raw !== "number" || !Number.isFinite(raw)) return 3; - const truncated = Math.trunc(raw); - return Math.min(Math.max(truncated, 1), 5); -} +import { resolveResearchMaxIterations } from "../constants.js"; function briefFromState( state: ResearchLoopStateType, @@ -97,9 +92,8 @@ export async function planQueries( const additional = state.additionalRequest ?? null; const brief = briefFromState(state, additional); - // Resolve maxIterations: input override > existing state > default(3); clamp 1..5. - // maxIterations は既存 state を優先しつつ 1..5 にクランプ。 - const maxIterations = clampMaxIterations(state.maxIterations ?? 3); + // maxIterations: ingest uses caller cap; Wiki Compose always uses safety cap. + const maxIterations = resolveResearchMaxIterations(ctx.graphId, state.maxIterations); const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db); const model = await createZediChatModel({ diff --git a/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts b/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts index 95738e13..f3a5ee7d 100644 --- a/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts +++ b/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts @@ -30,7 +30,7 @@ function buildUserPrompt(state: ResearchLoopStateType): string { const prior = state.queries.map((q) => `- ${q.query} (${q.channels.join("/")})`); const sourceTitles = state.pendingSources.map((s) => `- [${s.kind}] ${s.title}`); return [ - `[Iteration ${state.iteration} / ${state.maxIterations}]`, + `[Research iteration ${state.iteration}]`, `Previous evaluation score: ${evaluation?.score ?? "n/a"}`, "", "[Missing aspects to address]", diff --git a/server/api/src/agents/subgraphs/research/nodes/shared/dispatchSseCustom.ts b/server/api/src/agents/subgraphs/research/nodes/shared/dispatchSseCustom.ts index 02da4b9a..92681a59 100644 --- a/server/api/src/agents/subgraphs/research/nodes/shared/dispatchSseCustom.ts +++ b/server/api/src/agents/subgraphs/research/nodes/shared/dispatchSseCustom.ts @@ -7,6 +7,7 @@ */ import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import type { ResearchLoopCompileExitReason } from "../../types.js"; /** Payload shape for `research_iteration` custom events. */ export interface ResearchIterationPayload { @@ -29,7 +30,7 @@ export interface ResearchBatchPayload { iteration: number; sourceCount: number; score: number | null; - exitReason: "score_threshold" | "max_iterations"; + exitReason: ResearchLoopCompileExitReason; } /** diff --git a/server/api/src/agents/subgraphs/research/researchGraph.ts b/server/api/src/agents/subgraphs/research/researchGraph.ts index bfb91d73..2dfe3f2d 100644 --- a/server/api/src/agents/subgraphs/research/researchGraph.ts +++ b/server/api/src/agents/subgraphs/research/researchGraph.ts @@ -5,7 +5,8 @@ * `fetch_articles` → `evaluate_sufficiency` を 1 イテレーションとし、 * `shouldRefine` の判定で `refine_queries` (= 次ループ) か `compile_batch` → * `human_review_research` (= HITL 中断) のいずれかに分岐する。終了条件: - * `score >= 0.75` OR `iteration >= maxIterations` (default 3, clamp 1..5)。 + * 評価 LLM が `score >= 0.75` と判断したとき、または安全上限 + * {@link RESEARCH_SAFETY_MAX_ITERATIONS}(ingest が 1..5 を明示した場合はその cap)。 * * Cyclic LangGraph with a parallel fan-out (`web_search ∥ wiki_search`) and a * conditional edge after `evaluate_sufficiency`. The HITL stop is implemented @@ -78,9 +79,10 @@ export function registerResearchLoopGraph(): void { phase: "research", description: "Wiki Compose P1: autonomous research loop. Plans queries, runs web + wiki search, " + - "fetches articles, evaluates sufficiency, optionally refines and re-loops up to " + - "maxIterations (1..5, default 3), then interrupts at human_review_research for " + - "HITL source approval. Resume payload: { approvedSourceIds, rejectedSourceIds?, note? }.", + "fetches articles, evaluates sufficiency, optionally refines and re-loops until the " + + "evaluator LLM deems sources sufficient (score >= 0.75) or a safety cap is reached, " + + "then interrupts at human_review_research for HITL source approval. " + + "Resume payload: { approvedSourceIds, rejectedSourceIds?, note? }.", factory, }); } diff --git a/server/api/src/agents/subgraphs/research/shouldRefine.ts b/server/api/src/agents/subgraphs/research/shouldRefine.ts index 1368b1ee..0523d09a 100644 --- a/server/api/src/agents/subgraphs/research/shouldRefine.ts +++ b/server/api/src/agents/subgraphs/research/shouldRefine.ts @@ -1,18 +1,34 @@ /** * Research loop exit predicate (`evaluate_sufficiency` → refine | compile). */ +import { RESEARCH_SUFFICIENCY_SCORE_THRESHOLD } from "./constants.js"; import type { ResearchLoopStateType } from "./state.js"; +import type { Evaluation } from "./types.js"; + +export { RESEARCH_SUFFICIENCY_SCORE_THRESHOLD }; + +/** + * Whether the latest evaluation considers research sufficient to compile. + * + * Prefers the evaluator's explicit `sufficient` flag; falls back to score threshold. + * + * @param evaluation Latest evaluation from `evaluate_sufficiency`, if any. + */ +export function isResearchSufficient(evaluation: Evaluation | null | undefined): boolean { + if (!evaluation) return false; + if (evaluation.sufficient) return true; + return evaluation.score >= RESEARCH_SUFFICIENCY_SCORE_THRESHOLD; +} /** * 終了条件判定。`evaluate_sufficiency` の直後に呼ばれる。 * - * - `score >= 0.75` → `"compile"` - * - `iteration >= maxIterations` → `"compile"` + * - evaluator marks sufficient → `"compile"` + * - `iteration >= maxIterations` → `"compile"` (ingest cap or safety cap) * - otherwise → `"refine"` */ export function shouldRefine(state: ResearchLoopStateType): "refine" | "compile" { - const score = state.lastEvaluation?.score; - if (typeof score === "number" && score >= 0.75) return "compile"; + if (isResearchSufficient(state.lastEvaluation)) return "compile"; if (state.iteration >= state.maxIterations) return "compile"; return "refine"; } diff --git a/server/api/src/agents/subgraphs/research/state.ts b/server/api/src/agents/subgraphs/research/state.ts index 425459bf..174ef929 100644 --- a/server/api/src/agents/subgraphs/research/state.ts +++ b/server/api/src/agents/subgraphs/research/state.ts @@ -15,6 +15,7 @@ */ import { Annotation } from "@langchain/langgraph"; import { BaseState } from "../../core/state/baseState.js"; +import { RESEARCH_SAFETY_MAX_ITERATIONS } from "./constants.js"; import type { AdditionalResearchRequest, Evaluation, @@ -61,10 +62,10 @@ export const ResearchLoopState = Annotation.Root({ reducer: (_prev, next) => next, default: () => 0, }), - /** ループ回数上限(1..5、デフォルト 3)。`plan_queries` で clamp 確定。 */ + /** ループ回数上限。ingest が 1..5 を明示した場合はその cap、それ以外は安全上限。 */ maxIterations: Annotation({ reducer: (prev, next) => next ?? prev, - default: () => 3, + default: () => RESEARCH_SAFETY_MAX_ITERATIONS, }), /** 直近のクエリリスト。`plan_queries` / `refine_queries` が全置換する。 */ queries: Annotation({ diff --git a/server/api/src/agents/subgraphs/research/types.ts b/server/api/src/agents/subgraphs/research/types.ts index 036df588..e07e8ae5 100644 --- a/server/api/src/agents/subgraphs/research/types.ts +++ b/server/api/src/agents/subgraphs/research/types.ts @@ -82,15 +82,18 @@ export interface PlannedQuery { } /** - * `evaluate_sufficiency` ノードの出力。`score >= 0.75` で `compile_batch` 側へ + * `evaluate_sufficiency` ノードの出力。`sufficient === true` または + * `score >= {@link RESEARCH_SUFFICIENCY_SCORE_THRESHOLD}` で `compile_batch` 側へ * 分岐する({@link ./researchGraph.ts} の `shouldRefine`)。 * * Output of `evaluate_sufficiency`. The conditional edge uses - * `score >= 0.75` as the exit predicate. + * {@link Evaluation.sufficient} first, then the score threshold. */ export interface Evaluation { - /** 0..1. ≥ 0.75 → exit; otherwise refine. */ + /** 0..1 confidence score; used when {@link sufficient} is absent. */ score: number; + /** Explicit stop signal from the evaluator LLM. */ + sufficient: boolean; /** Short natural-language rationale for the score. */ rationale: string; /** Up to 5 short labels for what's still missing. */ @@ -120,13 +123,24 @@ export interface ResearchBatch { } /** - * ループ終了理由。`compile_batch` で確定し、HITL に渡される。 + * Reasons the research loop exited at `compile_batch` (emitted via SSE). * - * Reason the loop exited; set by `compile_batch`. + * `compile_batch` で確定し SSE / HITL に渡される終了理由。 */ -export type ExitReason = +export type ResearchLoopCompileExitReason = | "score_threshold" + /** Ingest planner hit its explicit 1..5 iteration cap. */ | "max_iterations" + /** Autonomous Wiki Compose hit {@link RESEARCH_SAFETY_MAX_ITERATIONS}. */ + | "safety_cap"; + +/** + * ループ終了理由。`compile_batch` で確定し、HITL に渡される。 + * + * Reason the loop exited; set by `compile_batch` or orchestrator skip nodes. + */ +export type ExitReason = + | ResearchLoopCompileExitReason | "manual_stop" /** Orchestrator skipped the research loop after Brief (#953). */ | "brief_skip"; diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index 3763a8cf..3be84a3a 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -16,9 +16,9 @@ * * `wiki-compose-research` (#949 / P1): * - `POST /run` body.input shapes: - * - Initial run: `{ messages?: [...], maxIterations?: number }` (or any + * - Initial run: `{ messages?: [...] }` (or any * object; the graph reads `state.messages` set by LangGraph from - * `body.input`). + * `body.input`). Research depth is decided autonomously by the evaluator LLM. * - Additional research (re-run on a *new* session of the same graph id): * `{ kind: "additional_research", instruction: string, brief?: string, * carryOverApprovedIds?: string[] }` diff --git a/src/components/wikiCompose/ComposePanel.tsx b/src/components/wikiCompose/ComposePanel.tsx index 481e1a3e..65e0fa28 100644 --- a/src/components/wikiCompose/ComposePanel.tsx +++ b/src/components/wikiCompose/ComposePanel.tsx @@ -51,11 +51,7 @@ export interface ComposePanelProps { activity: ComposeActivity[]; - onSubmitBrief: (input: { - answers: BriefAnswer[]; - appendToExisting?: boolean; - researchMaxIterations?: number; - }) => Promise; + onSubmitBrief: (input: { answers: BriefAnswer[]; appendToExisting?: boolean }) => Promise; onSubmitResearchApproval: (input: { approvedSourceIds: string[]; rejectedSourceIds?: string[]; diff --git a/src/components/wikiCompose/DialogueSection.tsx b/src/components/wikiCompose/DialogueSection.tsx index 5d9a0f20..cbe8c2f5 100644 --- a/src/components/wikiCompose/DialogueSection.tsx +++ b/src/components/wikiCompose/DialogueSection.tsx @@ -11,7 +11,7 @@ */ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card, CardContent, CardHeader, CardTitle, Slider } from "@zedi/ui"; +import { Button, Card, CardContent, CardHeader, CardTitle } from "@zedi/ui"; import { Sparkles, RefreshCw, ArrowRight } from "lucide-react"; import type { BriefAnswer, @@ -30,11 +30,7 @@ export interface DialogueSectionProps { outlineProposal: OutlineSection[]; isStreaming: boolean; /** Brief submission. */ - onSubmitBrief: (input: { - answers: BriefAnswer[]; - appendToExisting?: boolean; - researchMaxIterations?: number; - }) => Promise; + onSubmitBrief: (input: { answers: BriefAnswer[]; appendToExisting?: boolean }) => Promise; /** Structure submission. */ onSubmitOutline: (input: { sections: OutlineSection[] }) => Promise; } @@ -70,7 +66,6 @@ export const DialogueSection: React.FC = ({ const [appendToExisting, setAppendToExisting] = useState( Boolean(pageSnapshot?.hasContent), ); - const [maxIterations, setMaxIterations] = useState(3); const [submitting, setSubmitting] = useState(false); const canSubmitBrief = useMemo( @@ -139,31 +134,6 @@ export const DialogueSection: React.FC = ({ ) : null} - - - - {t("wikiCompose.brief.researchDepthTitle")} - - - -
- {t("wikiCompose.brief.researchQuick")} - - {t("wikiCompose.brief.iterationCount", { count: maxIterations })} - - {t("wikiCompose.brief.researchDeep")} -
- setMaxIterations(v[0] ?? 3)} - /> -
-
-