mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
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:
1 parent
1d952d58b1
commit
294b2e3aac
3 files changed
+137
-6
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user