Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ describe("humanReviewBrief", () => {
},
],
appendToExisting: true,
researchMaxIterations: 4,
});

const update = await humanReviewBrief(state({}), { configurable: {} } as never);
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Research loop iteration cap resolution tests.
*/
import { describe, expect, it } from "vitest";
import {
RESEARCH_SAFETY_MAX_ITERATIONS,
resolveResearchMaxIterations,
} from "../../../../agents/subgraphs/research/constants.js";

describe("resolveResearchMaxIterations", () => {
it("honours explicit ingest caps in 1..5", () => {
expect(resolveResearchMaxIterations(3)).toBe(3);
expect(resolveResearchMaxIterations(1)).toBe(1);
expect(resolveResearchMaxIterations(5)).toBe(5);
});

it("uses the autonomous safety cap for wiki compose defaults", () => {
expect(resolveResearchMaxIterations(undefined)).toBe(RESEARCH_SAFETY_MAX_ITERATIONS);
expect(resolveResearchMaxIterations(10)).toBe(RESEARCH_SAFETY_MAX_ITERATIONS);
expect(resolveResearchMaxIterations(99)).toBe(RESEARCH_SAFETY_MAX_ITERATIONS);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock("../../../../../agents/subgraphs/research/nodes/shared/dispatchSseCustom
}));

import { planQueries } from "../../../../../agents/subgraphs/research/nodes/planQueries.js";
import { RESEARCH_SAFETY_MAX_ITERATIONS } from "../../../../../agents/subgraphs/research/constants.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";
Expand Down Expand Up @@ -93,9 +94,14 @@ 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 autonomous safety cap when no explicit ingest cap is set", 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 explicit ingest caps in 1..5", async () => {
const update = await planQueries(state({ maxIterations: 4 }), config as never);
expect(update.maxIterations).toBe(4);
});

it("consumes state.additionalRequest and seeds carried-over sources", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
5 changes: 1 addition & 4 deletions server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<typeof briefResumeSchema>;
Expand Down
5 changes: 3 additions & 2 deletions server/api/src/agents/graphs/wikiCompose/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<number>({
reducer: (prev, next) => next ?? prev,
default: () => 3,
default: () => RESEARCH_SAFETY_MAX_ITERATIONS,
}),
/** Research subgraph 内の直近クエリ。 */
queries: Annotation<PlannedQuery[]>({
Expand Down
2 changes: 0 additions & 2 deletions server/api/src/agents/graphs/wikiCompose/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
44 changes: 44 additions & 0 deletions server/api/src/agents/subgraphs/research/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Research loop limits for Wiki Compose autonomous exploration.
* Wiki Compose 自律調査ループの上限定数。
*/

/**
* Hard safety cap for autonomous research loops. Wiki Compose no longer exposes
* a user-facing iteration slider; the evaluator LLM decides when sources are
* sufficient (`score >= 0.75`). This constant prevents runaway loops only.
*
* ユーザー向けの調査回数設定は廃止。充足度評価 LLM が十分と判断するまで
* ループし、無限ループ防止のためだけにこの上限を使う。
*/
export const RESEARCH_SAFETY_MAX_ITERATIONS = 10;

/**
* Explicit iteration cap range accepted from ingest / legacy API callers (1..5).
* Values outside this range fall back to {@link RESEARCH_SAFETY_MAX_ITERATIONS}.
*
* ingest 等が明示的に渡す回数上限(1..5)。範囲外は自律モードの安全上限へ。
*/
export const INGEST_EXPLICIT_MAX_ITERATIONS_MIN = 1;
export const INGEST_EXPLICIT_MAX_ITERATIONS_MAX = 5;

/**
* Resolve the iteration cap for the research loop.
*
* - `1..5` → honour explicit caller cap (ingest planner).
* - otherwise → {@link RESEARCH_SAFETY_MAX_ITERATIONS} (autonomous Wiki Compose).
*
* @param raw Value from graph state before `plan_queries` runs.
*/
export function resolveResearchMaxIterations(raw: unknown): number {
if (typeof raw === "number" && Number.isFinite(raw)) {
const truncated = Math.trunc(raw);
if (
truncated >= INGEST_EXPLICIT_MAX_ITERATIONS_MIN &&
truncated <= INGEST_EXPLICIT_MAX_ITERATIONS_MAX
) {
return truncated;
}
}
return RESEARCH_SAFETY_MAX_ITERATIONS;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { randomUUID } from "node:crypto";
import { dispatchResearchBatch } from "./shared/dispatchSseCustom.js";
import { RESEARCH_SUFFICIENCY_SCORE_THRESHOLD } from "../shouldRefine.js";
import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js";
import type { ExitReason, ResearchBatch } from "../types.js";

Expand All @@ -30,7 +31,9 @@ export async function compileBatch(
): Promise<ResearchLoopStateUpdate> {
const score = state.lastEvaluation?.score ?? null;
const exitReason: ExitReason =
score !== null && score >= 0.75 ? "score_threshold" : "max_iterations";
score !== null && score >= RESEARCH_SUFFICIENCY_SCORE_THRESHOLD
? "score_threshold"
: "max_iterations";
const batch: ResearchBatch = {
id: randomUUID(),
iteration: state.iteration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,6 +15,7 @@ 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 "../shouldRefine.js";
import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js";
import type { Evaluation } from "../types.js";

Expand All @@ -26,8 +27,10 @@ export const evaluationSchema = z.object({

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. Autonomously decide when coverage is good " +
`enough to proceed — score >= ${RESEARCH_SUFFICIENCY_SCORE_THRESHOLD} means sufficient. ` +
"Give a short rationale and list up to 5 missing aspects that would still matter " +
"for the article. Output JSON only.";

function buildUserPrompt(state: ResearchLoopStateType): string {
const brief = state.messages
Expand All @@ -49,7 +52,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");
}

Expand Down
15 changes: 5 additions & 10 deletions server/api/src/agents/subgraphs/research/nodes/planQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 パス)。
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -97,9 +92,9 @@ 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);
// Resolve maxIterations: explicit ingest cap (1..5) or autonomous safety cap.
// maxIterations は ingest の明示指定 (1..5) か、自律調査の安全上限
const maxIterations = resolveResearchMaxIterations(state.maxIterations);

const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db);
const model = await createZediChatModel({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
10 changes: 6 additions & 4 deletions server/api/src/agents/subgraphs/research/researchGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
9 changes: 6 additions & 3 deletions server/api/src/agents/subgraphs/research/shouldRefine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
*/
import type { ResearchLoopStateType } from "./state.js";

/** Score at or above which the evaluator considers research sufficient. */
export const RESEARCH_SUFFICIENCY_SCORE_THRESHOLD = 0.75;

/**
* 終了条件判定。`evaluate_sufficiency` の直後に呼ばれる。
*
* - `score >= 0.75` → `"compile"`
* - `iteration >= maxIterations` → `"compile"`
* - `score >= 0.75` → `"compile"` (agent decided sources are sufficient)
* - `iteration >= maxIterations` → `"compile"` (explicit 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 (typeof score === "number" && score >= RESEARCH_SUFFICIENCY_SCORE_THRESHOLD) return "compile";
if (state.iteration >= state.maxIterations) return "compile";
return "refine";
}
5 changes: 3 additions & 2 deletions server/api/src/agents/subgraphs/research/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<number>({
reducer: (prev, next) => next ?? prev,
default: () => 3,
default: () => RESEARCH_SAFETY_MAX_ITERATIONS,
}),
/** 直近のクエリリスト。`plan_queries` / `refine_queries` が全置換する。 */
queries: Annotation<PlannedQuery[]>({
Expand Down
4 changes: 2 additions & 2 deletions server/api/src/routes/composeSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }`
Expand Down
Loading
Loading