fix(exec): avoid truncated verdicts from reasoning reviewers (#150200)

* feat(exec): configure the approval reviewer completion budget

The exec auto-reviewer's completion budget is a fixed 1,024 tokens clamped to
the reviewer model's advertised maxTokens. Since #147192 the reviewer can be
asked to think first, and a local reasoning model easily spends the whole
budget on its reasoning before it reaches the JSON verdict, so the review
comes back truncated and falls back to human approval.

Add `tools.exec.reviewer.maxTokens` (also under
`agents.entries.<id>.tools.exec.reviewer`). It replaces the 1,024 default and
keeps the downward clamp to the model's maxTokens; non-positive or fractional
values are ignored. Schema, labels, help text, docs and the generated config
baseline follow.

* test(exec): document reviewer completion isolation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Aron Hsiao <leapdragon@gmail.com>
This commit is contained in:
leapdragonandPeter Steinberger authored and GitHub committed 2026-10-10 19:44:02 -07:00
1 parent 1d952d58b1
commit 294b2e3aac
3 files changed
+137 -6

No files matched your search

+2
View File
@@ -156,6 +156,8 @@ Set `tools.exec.reviewer.thinking` to `minimal`, `low`, `medium`, `high`, `xhigh
Set `tools.exec.reviewer.fastMode` to `true` to request Fast processing on supported OpenAI Responses and ChatGPT/OAuth routes, or `false` for standard processing. For example, `reviewer: { model: "openai/gpt-5.6-terra", thinking: "low", fastMode: true }` requests both low reasoning effort and priority processing. Omit `fastMode` to preserve provider defaults. This setting is independent of the main agent's Fast mode and is also available per agent. Priority processing may cost more and remains subject to provider/model availability; other providers may ignore the setting.
Reviewers marked as reasoning models automatically receive a bounded thinking allowance on top of the 1,024-token verdict budget, based on `thinking` (medium when omitted). The request stays within the model's advertised output limit. Non-reasoning reviewers keep the 1,024-token budget. No separate completion-budget setting is needed; an exhausted budget still falls back to human approval.
Model preparation and completion each receive the configured `tools.exec.reviewer.timeoutMs` budget. A timeout returns to human approval immediately. Pending preparation and provider cleanup remain owned until they settle. Preparation that finishes after its timeout does not start a review.
For embedded agent runs, the reviewer receives a bounded, redacted excerpt of the current conversation: user requests, assistant text, tool calls, and tool results, labeled by origin. It uses this context to judge whether a command serves the user's request. The excerpt is untrusted evidence, not instructions. Conversation context is unavailable for direct node `system.run` calls and widgets.
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import type { Model } from "../llm/types.js";
import { createModelExecAutoReviewer, type ExecReviewerConfig } from "./exec-auto-reviewer.js";
import {
acquireSimpleCompletionModelForAgent,
completeWithPreparedSimpleCompletionModel,
} from "./simple-completion-runtime.js";
// mock-isolation: keep model acquisition and provider I/O outside this completion-budget unit test.
vi.mock("./simple-completion-runtime.js", () => ({
acquireSimpleCompletionModelForAgent: vi.fn(),
completeWithPreparedSimpleCompletionModel: vi.fn(),
}));
const input = {
command: "git status",
host: "gateway" as const,
reason: "approval-required" as const,
analysis: { parsed: true, allowlistMatched: false, inlineEval: false },
};
const model: Model = {
provider: "ollama",
id: "reviewer",
name: "Reviewer",
api: "ollama",
baseUrl: "http://localhost:11434",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
maxTokens: 8_192,
};
async function review(params: {
reasoning: boolean;
maxTokens: number;
thinking?: ExecReviewerConfig["thinking"];
}) {
vi.mocked(acquireSimpleCompletionModelForAgent).mockResolvedValue({
selection: { provider: model.provider, modelId: model.id, agentDir: "/agent" },
model: { ...model, reasoning: params.reasoning, maxTokens: params.maxTokens },
auth: { mode: "api-key", source: "local" },
[Symbol.asyncDispose]: async () => {},
});
const complete = vi.mocked(completeWithPreparedSimpleCompletionModel);
complete.mockReset();
complete.mockImplementation(async ({ options }) => {
// A reasoning response can consume the old limit before producing any verdict text.
const exhausted = params.reasoning && (options?.maxTokens ?? 0) <= 1_024;
return {
role: "assistant",
api: model.api,
provider: model.provider,
model: model.id,
timestamp: 0,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: exhausted ? "length" : "stop",
content: exhausted
? []
: [{ type: "text", text: '{"decision":"allow","risk":"low","rationale":"read-only"}' }],
};
});
const reviewer = createModelExecAutoReviewer({
cfg: {},
reviewer: { thinking: params.thinking },
});
return { decision: await reviewer(input), complete };
}
describe("exec auto-reviewer automatic completion budget", () => {
it.each<{ thinking?: ExecReviewerConfig["thinking"]; maxTokens: number; expected: number }>([
{ thinking: "minimal", maxTokens: 32_768, expected: 2_048 },
{ thinking: "low", maxTokens: 8_192, expected: 3_072 },
{ thinking: "medium", maxTokens: 32_768, expected: 9_216 },
{ thinking: "high", maxTokens: 32_768, expected: 17_408 },
{ thinking: "xhigh", maxTokens: 32_768, expected: 17_408 },
{ thinking: "max", maxTokens: 65_536, expected: 33_792 },
{ maxTokens: 8_192, expected: 8_192 },
{ thinking: "low", maxTokens: 1_500.5, expected: 1_500 },
{ thinking: "low", maxTokens: Number.NaN, expected: 3_072 },
])("completes a verdict with $thinking thinking and model cap $maxTokens", async (params) => {
const { decision, complete } = await review({ ...params, reasoning: true });
expect(decision).toMatchObject({ decision: "allow-once", risk: "low" });
expect(complete.mock.calls[0]?.[0].options?.maxTokens).toBe(params.expected);
});
it.each([500, 8_192, Number.NaN])(
"preserves non-reasoning budget with model cap %s",
async (maxTokens) => {
const { decision, complete } = await review({
reasoning: false,
thinking: "high",
maxTokens,
});
expect(decision).toMatchObject({ decision: "allow-once" });
expect(complete.mock.calls[0]?.[0].options?.maxTokens).toBe(maxTokens === 500 ? 500 : 1_024);
},
);
it("defers to human approval when the model cap cannot fit thinking and a verdict", async () => {
const { decision, complete } = await review({
reasoning: true,
thinking: "low",
maxTokens: 500,
});
expect(decision).toMatchObject({
decision: "ask",
rationale: expect.stringContaining("length"),
});
expect(complete.mock.calls[0]?.[0].options?.maxTokens).toBe(500);
});
});
+16 -6
View File
@@ -1,3 +1,4 @@
import { adjustMaxTokensForThinking } from "@openclaw/ai/internal/shared";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { z } from "zod";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -10,6 +11,7 @@ import {
type ExecAutoReviewDecision,
type ExecAutoReviewInput,
} from "../infra/exec-auto-review.js";
import type { Model } from "../llm/types.js";
import { AsyncWorkScope, captureAsyncWorkTracker } from "../shared/async-work-scope.js";
import { createDeferredCore, type Deferred } from "../shared/deferred.js";
import { resolveAmbientOwnerAgentId } from "./agent-scope-config.js";
@@ -276,11 +278,19 @@ function extractCompletionFailure(
return `model stopped without a complete response (${stopReason ?? "unknown"})`;
}
function resolveExecReviewerMaxTokens(modelMaxTokens?: number): number {
if (typeof modelMaxTokens === "number" && Number.isFinite(modelMaxTokens) && modelMaxTokens > 0) {
return Math.max(1, Math.floor(Math.min(EXEC_REVIEWER_MAX_TOKENS, modelMaxTokens)));
}
return EXEC_REVIEWER_MAX_TOKENS;
function resolveExecReviewerMaxTokens(
model: Model,
thinking?: ExecReviewerConfig["thinking"],
): number {
const modelMaxTokens =
Number.isFinite(model.maxTokens) && model.maxTokens > 0
? Math.max(1, Math.floor(model.maxTokens))
: Number.POSITIVE_INFINITY;
// Reasoning can share the completion limit with the verdict, including provider defaults.
return model.reasoning
? adjustMaxTokensForThinking(EXEC_REVIEWER_MAX_TOKENS, modelMaxTokens, thinking ?? "medium")
.maxTokens
: Math.min(EXEC_REVIEWER_MAX_TOKENS, modelMaxTokens);
}
async function raceWithReviewerTimeout<T>(
@@ -422,7 +432,7 @@ export function createModelExecAutoReviewer(params: {
],
},
options: {
maxTokens: resolveExecReviewerMaxTokens(prepared.model.maxTokens),
maxTokens: resolveExecReviewerMaxTokens(prepared.model, params.reviewer?.thinking),
temperature: 0,
...(params.reviewer?.thinking ? { reasoning: params.reviewer.thinking } : {}),
...(params.reviewer?.fastMode !== undefined