Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .changeset/reliable-plan-artifact.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@planningo/duul": patch
---

Improve plan-file workflow guidance so callers replace and verify each plan revision as one snapshot instead of retrying stale text edits.
Improve plan-file workflow guidance so callers replace and verify each plan revision as one snapshot instead of retrying stale text edits. Bound MCP review latency, return timeout fallbacks, and support opt-in local diagnostic logs.
12 changes: 12 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ DUUL은 `~/.codex/auth.json`을 읽고(`CODEX_HOME`으로 경로 변경 가능):

기본값은 **미설정(무제한)**입니다 — 초기 측정에서 200KB 기본 cap이 code_review의 약 1/3을 불필요한 REVISE로 몰아 라운드가 오히려 늘었습니다. cap을 쓰고 싶다면 명시적으로 설정하세요. 비용 민감한 사용자는 `200000`–`500000` 범위에서 시작해 리뷰 복잡도에 따라 조정하는 것을 권장합니다.

#### 리뷰 지연 시간 및 진단 로그

각 MCP 리뷰는 기본적으로 총 **75초 제한**과 최대 **3회 파일 탐색 라운드**를 가집니다. 제한에 도달하면 호출자를 계속 기다리게 하지 않고 `tool_exhaustion_reason: "timeout"`인 구조화된 `incomplete` 결과를 반환합니다. 더 긴 검토가 허용될 때만 오버라이드하세요.

| 변수 | 기본값 | 범위 | 설명 |
|---|---:|---:|---|
| `DUUL_REVIEW_TIMEOUT_MS` | `75000` | `10000`–`600000` | MCP 리뷰 한 번의 총 시간 예산 |
| `DUUL_MAX_TOOL_ROUNDS` | `3` | `0`–`10` | 호출당 리뷰어 파일 탐색 라운드 수 |
| `DUUL_LOG_FILE` | _(미설정)_ | — | DUUL stderr 진단을 기록할 선택적 로컬 파일. 프로바이더 오류에 사용자 입력이 포함될 수 있음. |

실시간 로그가 필요하면 `DUUL_LOG_FILE`을 `.duul/duul.log`처럼 gitignore된 워크스페이스 경로로 설정하고 MCP 세션을 재시작한 뒤 `tail -f .duul/duul.log`을 실행하세요.

#### 요청별 오버라이드

개별 리뷰 호출에서 `max_review_iterations` 입력 파라미터로 반복 제한을 오버라이드할 수 있습니다 (범위: 1–20). 환경 변수보다 우선합니다.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ Opt-in cap on the total bytes the reviewer can pull from the workspace via its f

Unset by default: early measurements showed a 200KB default tripped ~1/3 of code reviews into spurious REVISEs, which actually cost more rounds. If you want the cap, set it explicitly — `200000`–`500000` is a reasonable starting range for cost-conscious setups. Raise or lower based on how complex your typical review is.

#### Review Latency and Diagnostics

Each MCP review has a **75-second total deadline** and at most **3 reviewer file-exploration rounds** by default. On expiry, DUUL returns a structured `incomplete` result with `tool_exhaustion_reason: "timeout"` rather than leaving the MCP caller waiting. Override only when a longer review is acceptable:

| Variable | Default | Range | Description |
|---|---:|---:|---|
| `DUUL_REVIEW_TIMEOUT_MS` | `75000` | `10000`–`600000` | Total time budget for one MCP review call. |
| `DUUL_MAX_TOOL_ROUNDS` | `3` | `0`–`10` | Reviewer file-exploration rounds per call. |
| `DUUL_LOG_FILE` | _(unset)_ | — | Optional local file that receives DUUL stderr diagnostics. Logs may contain user-supplied provider errors. |

For a live local log, set `DUUL_LOG_FILE` to a gitignored workspace path such as `.duul/duul.log`, restart the MCP session, then run `tail -f .duul/duul.log`.

#### Per-Request Override

You can also override the iteration limit on individual review calls via the `max_review_iterations` input parameter (range: 1–20). This takes priority over the environment variable.
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { registerPlanReviewTool } from './tools/plan-review.js';
import { registerCodeReviewTool } from './tools/code-review.js';
import { registerExecutionPartitionTool } from './tools/execution-partition.js';
import { enableFileDiagnostics } from './services/diagnostics.js';

const SERVER_INSTRUCTIONS = `
DUUL — Dual-phase Upfront-plan & Unit-verify Loop.
Expand All @@ -14,6 +15,8 @@ read it back, then submit plan_file. Do not chain exact-text edits. On an edit m
fresh snapshot; only one agent may modify the plan artifact at a time.
`.trim();

enableFileDiagnostics();

const server = new McpServer(
{ name: 'duul', version: '1.0.0' },
{ instructions: SERVER_INSTRUCTIONS },
Expand Down
2 changes: 1 addition & 1 deletion src/schemas/code-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export const CodeReviewOutputSchema = z.object({
missing_context: z.array(z.string()).nullable().describe('Files or context the reviewer could not access'),
evidence_files: z.array(z.string()).nullable().describe('Files the reviewer examined as evidence'),
used_tools: z.array(z.string()).nullable().describe('Tool calls made during review'),
tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit']).nullable().describe(
tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit', 'timeout']).nullable().describe(
'If review_status is incomplete, the reason why the tool loop was exhausted',
),
user_original_request_echo: z.string().nullable().describe(
Expand Down
2 changes: 1 addition & 1 deletion src/schemas/plan-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export const PlanReviewOutputSchema = z.object({
missing_context: z.array(z.string()).nullable().describe('Files or context the reviewer could not access'),
evidence_files: z.array(z.string()).nullable().describe('Files the reviewer examined as evidence'),
used_tools: z.array(z.string()).nullable().describe('Tool calls made during review'),
tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit']).nullable().describe(
tool_exhaustion_reason: z.enum(['budget', 'repeat', 'round_limit', 'timeout']).nullable().describe(
'If review_status is incomplete, the reason why the tool loop was exhausted',
),
parallelization_hint: z.enum(['serial', 'parallel', 'hybrid']).nullable().describe(
Expand Down
22 changes: 22 additions & 0 deletions src/services/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { appendFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { format } from 'node:util';

/**
* Mirror stderr diagnostics to an explicitly configured local file.
* Logging remains opt-in because provider errors may include user-supplied text.
*/
export function enableFileDiagnostics(): void {
const logFile = process.env.DUUL_LOG_FILE;
if (!logFile) return;

const stderr = console.error.bind(console);
console.error = (...args: unknown[]) => {
stderr(...args);
void mkdir(dirname(logFile), { recursive: true })
.then(() => appendFile(logFile, `${new Date().toISOString()} ${format(...args)}\n`))
.catch(() => undefined);
};

console.error(`[duul] File diagnostics enabled: ${logFile}`);
}
19 changes: 12 additions & 7 deletions src/services/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import type {
TokenUsage,
} from './types.js';
import { estimateCost } from '../pricing.js';
import { getMaxToolRounds, remainingReviewMs, reviewDeadlineFromNow, ReviewTimeoutError } from '../review-runtime.js';

const MAX_INPUT_CHARS = 400_000;
const MAX_TOOL_ROUNDS = 10;
const MAX_RETRIES = 3;
const MAX_REPEAT_CALLS = 3;

Expand Down Expand Up @@ -215,6 +215,7 @@ export class AnthropicProvider implements ReviewerProvider {
async review<T extends z.ZodType>(
options: ReviewCallOptions<T>,
): Promise<ReviewCallResult<z.infer<T>>> {
const deadline = reviewDeadlineFromNow();
const { systemPrompt, userMessage, outputSchema, workspaceScope, conversationHistory } = options;

const effectiveRoot = workspaceScope?.root ?? null;
Expand Down Expand Up @@ -292,7 +293,7 @@ export class AnthropicProvider implements ReviewerProvider {
{ role: 'user' as const, content: userMessage },
];

let body = await this.apiCallWithRetry(systemBlocks, messages, tools);
let body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline);
accumulateUsage(body);
console.error(`[duul] response.id=${body.id} model=${this.model} provider=anthropic`);

Expand Down Expand Up @@ -330,7 +331,7 @@ export class AnthropicProvider implements ReviewerProvider {
const callCounts = new Map<string, number>();
const byteBudget = createReviewerByteBudget();

for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
for (let round = 0; round < getMaxToolRounds(); round++) {
const toolUses = body.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
if (toolUses.length === 0 || body.stop_reason !== 'tool_use') break;

Expand Down Expand Up @@ -377,7 +378,7 @@ export class AnthropicProvider implements ReviewerProvider {
messages.push({ role: 'user', content: toolResults });
conversationTurns.push({ role: 'user' as const, content: toolResults });

body = await this.apiCallWithRetry(systemBlocks, messages, tools);
body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline);
accumulateUsage(body);
conversationTurns.push({ role: 'assistant' as const, content: body.content });
console.error(`[duul] response.id=${body.id} (after tool round ${round + 1})`);
Expand All @@ -393,7 +394,7 @@ export class AnthropicProvider implements ReviewerProvider {
}));
messages.push({ role: 'assistant', content: body.content });
messages.push({ role: 'user', content: stopResults });
body = await this.apiCallWithRetry(systemBlocks, messages, tools);
body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline);
accumulateUsage(body);
conversationTurns.push({ role: 'user' as const, content: stopResults });
conversationTurns.push({ role: 'assistant' as const, content: body.content });
Expand All @@ -412,7 +413,7 @@ export class AnthropicProvider implements ReviewerProvider {
}));
messages.push({ role: 'assistant', content: body.content });
messages.push({ role: 'user', content: stopResults });
body = await this.apiCallWithRetry(systemBlocks, messages, tools);
body = await this.apiCallWithRetry(systemBlocks, messages, tools, deadline);
accumulateUsage(body);
conversationTurns.push({ role: 'user' as const, content: stopResults });
conversationTurns.push({ role: 'assistant' as const, content: body.content });
Expand Down Expand Up @@ -448,10 +449,13 @@ export class AnthropicProvider implements ReviewerProvider {
system: SystemBlock[],
messages: AnthropicMessage[],
tools?: AnthropicTool[],
deadline?: number,
): Promise<AnthropicResponse> {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const remaining = deadline === undefined ? 120_000 : remainingReviewMs(deadline);
if (remaining === 0) throw new ReviewTimeoutError();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
const timeout = setTimeout(() => controller.abort(), Math.min(120_000, remaining));

try {
const response = await fetch(`${this.baseUrl}/v1/messages`, {
Expand Down Expand Up @@ -489,6 +493,7 @@ export class AnthropicProvider implements ReviewerProvider {
return await response.json() as AnthropicResponse;
} catch (error: unknown) {
clearTimeout(timeout);
if (deadline !== undefined && remainingReviewMs(deadline) === 0) throw new ReviewTimeoutError();
if (attempt < MAX_RETRIES - 1 && error instanceof Error && error.name === 'AbortError') {
const delay = 1000 * Math.pow(2, attempt);
console.error(`[duul] Anthropic retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (timeout)`);
Expand Down
19 changes: 12 additions & 7 deletions src/services/providers/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import type {
TokenUsage,
} from './types.js';
import { estimateCost } from '../pricing.js';
import { getMaxToolRounds, remainingReviewMs, reviewDeadlineFromNow, ReviewTimeoutError } from '../review-runtime.js';

const MAX_INPUT_CHARS = 400_000;
const MAX_TOOL_ROUNDS = 10;
const MAX_RETRIES = 3;
const MAX_REPEAT_CALLS = 3;

Expand Down Expand Up @@ -183,6 +183,7 @@ export class GoogleProvider implements ReviewerProvider {
async review<T extends z.ZodType>(
options: ReviewCallOptions<T>,
): Promise<ReviewCallResult<z.infer<T>>> {
const deadline = reviewDeadlineFromNow();
const { systemPrompt, userMessage, outputSchema, workspaceScope } = options;

const effectiveRoot = workspaceScope?.root ?? null;
Expand Down Expand Up @@ -222,7 +223,7 @@ export class GoogleProvider implements ReviewerProvider {
{ role: 'user', parts: [{ text: userMessage }] },
];

let body = await this.apiCallWithRetry(enhancedSystem, contents, tools);
let body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline);
accumulateUsage(body);
console.error(`[duul] Gemini response received, model=${this.model} provider=google`);

Expand Down Expand Up @@ -257,7 +258,7 @@ export class GoogleProvider implements ReviewerProvider {
const callCounts = new Map<string, number>();
const byteBudget = createReviewerByteBudget();

for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
for (let round = 0; round < getMaxToolRounds(); round++) {
const parts = body.candidates?.[0]?.content?.parts ?? [];
const functionCalls = parts.filter((p): p is FunctionCallPart => 'functionCall' in p);
if (functionCalls.length === 0) break;
Expand Down Expand Up @@ -306,7 +307,7 @@ export class GoogleProvider implements ReviewerProvider {

contents.push({ role: 'user', parts: responseParts });

body = await this.apiCallWithRetry(enhancedSystem, contents, tools);
body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline);
accumulateUsage(body);
console.error(`[duul] Gemini response (after tool round ${round + 1})`);

Expand All @@ -319,7 +320,7 @@ export class GoogleProvider implements ReviewerProvider {
functionResponse: { name: p.functionCall.name, response: { output: 'No more file reads allowed. Produce your final verdict now.' } },
}));
contents.push({ role: 'user', parts: stopParts });
body = await this.apiCallWithRetry(enhancedSystem, contents, tools);
body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline);
accumulateUsage(body);
}
break;
Expand All @@ -334,7 +335,7 @@ export class GoogleProvider implements ReviewerProvider {
functionResponse: { name: p.functionCall.name, response: { output: 'Tool call limit reached. Produce your final verdict now.' } },
}));
contents.push({ role: 'user', parts: stopParts });
body = await this.apiCallWithRetry(enhancedSystem, contents, tools);
body = await this.apiCallWithRetry(enhancedSystem, contents, tools, deadline);
accumulateUsage(body);
}
}
Expand Down Expand Up @@ -366,10 +367,13 @@ export class GoogleProvider implements ReviewerProvider {
system: string,
contents: GeminiContent[],
tools?: typeof GOOGLE_TOOLS,
deadline?: number,
): Promise<GeminiResponse> {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const remaining = deadline === undefined ? 120_000 : remainingReviewMs(deadline);
if (remaining === 0) throw new ReviewTimeoutError();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
const timeout = setTimeout(() => controller.abort(), Math.min(120_000, remaining));

try {
const url = `${this.baseUrl}/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
Expand Down Expand Up @@ -407,6 +411,7 @@ export class GoogleProvider implements ReviewerProvider {
return await response.json() as GeminiResponse;
} catch (error: unknown) {
clearTimeout(timeout);
if (deadline !== undefined && remainingReviewMs(deadline) === 0) throw new ReviewTimeoutError();
if (attempt < MAX_RETRIES - 1 && error instanceof Error && error.name === 'AbortError') {
const delay = 1000 * Math.pow(2, attempt);
console.error(`[duul] Google retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (timeout)`);
Expand Down
20 changes: 12 additions & 8 deletions src/services/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import type {
ConversationTurn,
} from './types.js';
import { estimateCost } from '../pricing.js';
import { getMaxToolRounds, remainingReviewMs, reviewDeadlineFromNow, ReviewTimeoutError } from '../review-runtime.js';

const MAX_INPUT_CHARS = 400_000;
const MAX_TOOL_ROUNDS = 10;
const MAX_RETRIES = 3;
const MAX_REPEAT_CALLS = 3;

Expand Down Expand Up @@ -265,6 +265,7 @@ export class OpenAIProvider implements ReviewerProvider {
async review<T extends z.ZodType>(
options: ReviewCallOptions<T>,
): Promise<ReviewCallResult<z.infer<T>>> {
const deadline = reviewDeadlineFromNow();
const { systemPrompt, userMessage, schemaName, outputSchema, workspaceScope, previousReviewId, conversationHistory } = options;

validateInputLength(systemPrompt, userMessage);
Expand Down Expand Up @@ -334,12 +335,12 @@ export class OpenAIProvider implements ReviewerProvider {
inputItems.push({ role: 'user' as const, content: [{ type: 'input_text' as const, text: userMessage }] });

let response = this.stateless
? await this.apiCallWithRetry({ ...baseParams, input: inputItems })
? await this.apiCallWithRetry({ ...baseParams, input: inputItems }, deadline)
: await this.apiCallWithRetry({
...baseParams,
input: inputItems,
...(previousReviewId ? { previous_response_id: previousReviewId } : {}),
});
}, deadline);

accumulateUsage(response);
console.error(`[duul] response.id=${response.id} model=${this.model} provider=openai`);
Expand All @@ -350,9 +351,9 @@ export class OpenAIProvider implements ReviewerProvider {
const continueConversation = async (newItems: unknown[]): Promise<OpenAI.Responses.Response> => {
if (this.stateless) {
inputItems.push(...response.output, ...newItems);
return this.apiCallWithRetry({ ...baseParams, input: inputItems });
return this.apiCallWithRetry({ ...baseParams, input: inputItems }, deadline);
}
return this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: newItems });
return this.apiCallWithRetry({ ...baseParams, previous_response_id: response.id, input: newItems }, deadline);
};

// Agentic tool-calling loop
Expand Down Expand Up @@ -389,7 +390,7 @@ export class OpenAIProvider implements ReviewerProvider {
const callCounts = new Map<string, number>();
const byteBudget = createReviewerByteBudget();

for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
for (let round = 0; round < getMaxToolRounds(); round++) {
const functionCalls = this.getFunctionCalls(response);
if (functionCalls.length === 0) break;

Expand Down Expand Up @@ -493,11 +494,13 @@ export class OpenAIProvider implements ReviewerProvider {
throw new Error('Review failed: could not obtain structured verdict after tool loop.');
}

private async apiCallWithRetry(params: Record<string, unknown>): Promise<OpenAI.Responses.Response> {
private async apiCallWithRetry(params: Record<string, unknown>, deadline: number): Promise<OpenAI.Responses.Response> {
let refreshedOnce = false;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const remaining = remainingReviewMs(deadline);
if (remaining === 0) throw new ReviewTimeoutError();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
const timeout = setTimeout(() => controller.abort(), Math.min(120_000, remaining));
try {
let response: OpenAI.Responses.Response;
if (this.stateless) {
Expand All @@ -518,6 +521,7 @@ export class OpenAIProvider implements ReviewerProvider {
return response;
} catch (error: unknown) {
clearTimeout(timeout);
if (remainingReviewMs(deadline) === 0) throw new ReviewTimeoutError();
const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : undefined;

// ChatGPT token expired mid-review: refresh once and retry immediately.
Expand Down
2 changes: 1 addition & 1 deletion src/services/providers/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { z } from 'zod';
import type { WorkspaceScope } from '../filesystem.js';

export type ExhaustionReason = 'budget' | 'repeat' | 'round_limit';
export type ExhaustionReason = 'budget' | 'repeat' | 'round_limit' | 'timeout';

/**
* Token usage from a single review call.
Expand Down
Loading
Loading