diff --git a/docs/guides/cli.md b/docs/guides/cli.md index e72ff37..d0c432e 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -173,6 +173,12 @@ See [config-surface.md](../references/config-surface.md) for the full override s - `standard`: recommended default for most tasks. - `intensive`: best for complex or high-risk tasks (large refactors, flaky tests, heavy verification). +For `glm run`, task intent is routed separately from prompt lane: + +- review-style tasks automatically use a review overlay that prioritizes findings, regressions, and missing tests +- delivery tasks keep the normal change-oriented overlay +- verifier harness stays disabled unless `--loop` is enabled, even if `--mode intensive` is selected manually + Defaults: - `glm chat`: `standard` @@ -181,7 +187,7 @@ Defaults: You can override these defaults via `glm config set taskLaneDefault `. -When `taskLaneDefault=auto`, `glm run` will pick `direct` for trivial tasks (docs/lint/format) and `standard` otherwise. `glm run --loop` still forces `intensive`. +When `taskLaneDefault=auto`, `glm run` will pick `direct` for trivial tasks (docs/lint/format), `standard` for normal delivery work, and a review overlay for review-style tasks. `glm run --loop` still forces `intensive` and enables the verifier harness. ## Approvals (`--yolo` and `/approval`) diff --git a/docs/guides/cli.zh.md b/docs/guides/cli.zh.md index 3916ec7..f0dc97b 100644 --- a/docs/guides/cli.zh.md +++ b/docs/guides/cli.zh.md @@ -173,6 +173,12 @@ glm auth login - `standard`:默认推荐;对一般开发任务会先做简短计划,再实现并在可行时验证。 - `intensive`:适合复杂或高风险任务(大范围重构、测试不稳定、需要更多自检),更强调明确计划和严格验证。 +对 `glm run` 来说,任务意图会与 prompt lane 分开判断: + +- review 类任务会自动切到 review overlay,更强调 findings、回归风险和缺失测试 +- delivery 类任务仍使用面向改动交付的 overlay +- verifier harness 只有在启用 `--loop` 时才会开启;即使手动指定 `--mode intensive`,也不会隐式开启 verifier harness + 默认行为: - `glm chat` 默认 `standard` @@ -181,7 +187,7 @@ glm auth login 你也可以通过 `glm config set taskLaneDefault ` 覆盖上述默认值。 -当 `taskLaneDefault=auto` 时,`glm run` 会对简单任务(如文档、lint、format)自动选择 `direct`,其他情况选择 `standard`;`glm run --loop` 仍会强制使用 `intensive`。 +当 `taskLaneDefault=auto` 时,`glm run` 会对简单任务(如文档、lint、format)自动选择 `direct`,对普通交付任务选择 `standard`,对 review 类任务追加 review overlay;`glm run --loop` 仍会强制使用 `intensive`,并启用 verifier harness。 ## 审批(`--yolo` 与 `/approval`) diff --git a/docs/references/config-surface.md b/docs/references/config-surface.md index e4be546..88c070a 100644 --- a/docs/references/config-surface.md +++ b/docs/references/config-surface.md @@ -192,6 +192,8 @@ The CLI influences runtime behavior via flags. `glm inspect --json` is the easie - Selects the prompt lane overlay (execution style) used by `glm chat` and `glm run`. - This affects how the model is instructed to work (plan-first vs. direct, verification emphasis). - It does **not** enable/disable the loop. Use `--loop` and `loop.*` config keys for loop behavior. + - For `glm run`, task intent is resolved separately. Review-style tasks receive a review overlay, while delivery tasks receive the normal change-oriented overlay. + - Manual `--mode intensive` does not enable verifier harness by itself. Verifier harness is only active when `--loop` is enabled. - Defaults: - `glm chat`: `standard` - `glm run`: `standard` diff --git a/docs/references/config-surface.zh.md b/docs/references/config-surface.zh.md index 4c8ce4e..ed937d1 100644 --- a/docs/references/config-surface.zh.md +++ b/docs/references/config-surface.zh.md @@ -192,6 +192,8 @@ CLI 会通过 flags 影响 runtime 行为。排查时建议直接运行 `glm ins - 选择 `glm chat` / `glm run` 使用的 prompt lane(执行风格)。 - 会影响模型被如何引导工作(是否先计划、是否强调验证等)。 - 不会启用或关闭 loop。loop 仍由 `--loop` 与 `loop.*` 配置控制。 + - 对 `glm run` 而言,任务意图会独立解析。review 类任务会使用 review overlay,delivery 类任务会继续使用面向改动交付的 overlay。 + - 手动指定 `--mode intensive` 不会单独开启 verifier harness;只有启用 `--loop` 时 verifier harness 才会生效。 - 默认值: - `glm chat`:`standard` - `glm run`:`standard` diff --git a/src/commands/run.ts b/src/commands/run.ts index 5897a83..4b22234 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -14,7 +14,7 @@ import { type SingleTaskExecutionResult, type LoopTaskExecutionResult, } from "../runtime/run-runtime.js"; -import { routePromptModeForTask } from "../runtime/task-router.js"; +import { routeTaskExecutionForRun } from "../runtime/task-router.js"; import { createGlmRuntime, withPreservedProcessCwd, @@ -110,14 +110,12 @@ export async function runRunCommand(input: RunCommandInput): Promise { }, async () => { const configuredLane = fileConfig.taskLaneDefault ?? "auto"; - const promptMode = - input.promptMode ?? - (configuredLane === "auto" - ? routePromptModeForTask({ - task: input.task, - loopEnabled: loopOptions.enabled, - }).mode - : (configuredLane as PromptMode)); + const taskRoute = routeTaskExecutionForRun({ + task: input.task, + loopEnabled: loopOptions.enabled, + promptModeOverride: + input.promptMode ?? (configuredLane === "auto" ? undefined : (configuredLane as PromptMode)), + }); const hooks = { emit: outputFormat === "jsonl" ? emitJsonl : undefined, @@ -131,7 +129,7 @@ export async function runRunCommand(input: RunCommandInput): Promise { const runtime = await createGlmRuntime({ cwd: input.cwd, ...runtimeConfig, - promptMode, + promptMode: taskRoute.promptMode, }); const startedAt = new Date().toISOString(); @@ -143,12 +141,13 @@ export async function runRunCommand(input: RunCommandInput): Promise { provider: runtimeConfig.provider, model: runtimeConfig.model, loop: loopOptions.enabled, - promptMode, + promptMode: taskRoute.promptMode, + taskRoute, }); const result: SingleTaskExecutionResult | LoopTaskExecutionResult = loopOptions.enabled - ? await runTaskLoop(runtime, input.task, loopOptions, promptMode, hooks) - : await runSingleTask(runtime, input.task, promptMode, hooks); + ? await runTaskLoop(runtime, input.task, loopOptions, taskRoute.promptMode, hooks) + : await runSingleTask(runtime, input.task, taskRoute, hooks); hooks.emit?.({ type: "run.done", @@ -168,7 +167,8 @@ export async function runRunCommand(input: RunCommandInput): Promise { task: input.task, provider: runtimeConfig.provider, model: runtimeConfig.model, - promptMode, + promptMode: taskRoute.promptMode, + taskRoute, loop: loopOptions, result, }); diff --git a/src/loop/profiles/code.ts b/src/loop/profiles/code.ts index 57d5e69..c5ed09c 100644 --- a/src/loop/profiles/code.ts +++ b/src/loop/profiles/code.ts @@ -7,7 +7,11 @@ export function createCodeLoopProfile(promptMode: PromptMode = "intensive"): Loo return { name: "code", buildLoopContract(task: string): string { - return composeTaskPrompt(task, promptMode); + return composeTaskPrompt(task, { + promptMode, + taskIntent: "delivery", + verifierHarness: "loop", + }); }, buildRepairPrompt(result: VerificationResult, nextRound: number): string { return composeRepairPrompt(result, nextRound); diff --git a/src/prompt/task-overlay.ts b/src/prompt/task-overlay.ts index 9a583b5..0c85b30 100644 --- a/src/prompt/task-overlay.ts +++ b/src/prompt/task-overlay.ts @@ -1,15 +1,22 @@ -import type { PromptMode } from "./mode-overlays.js"; +import type { TaskPromptProfile } from "./task-prompt-profile.js"; -export function buildTaskOverlay(task: string, mode: PromptMode): string { +export function buildTaskOverlay(task: string, profile: TaskPromptProfile): string { const trimmedTask = task.trim(); + const { promptMode, taskIntent, verifierHarness } = profile; const instructions = - mode === "direct" + taskIntent === "review" + ? [ + "- Review the requested scope before proposing changes.", + "- Prioritize concrete findings, regressions, risks, and missing tests.", + "- Do not make code changes unless the task explicitly asks for them.", + ] + : promptMode === "direct" ? [ "- Work the bounded task directly.", "- Keep the change minimal and report the concrete result.", ] - : mode === "intensive" + : promptMode === "intensive" ? [ "- Start with a short plan.", "- Make the smallest coherent fix or change.", @@ -17,11 +24,16 @@ export function buildTaskOverlay(task: string, mode: PromptMode): string { ] : [ "- Start with a short plan when needed.", - "- Make the smallest coherent change that completes the task.", - "- Verify before claiming success when the repo offers a practical check.", - ]; + "- Make the smallest coherent change that completes the task.", + "- Verify before claiming success when the repo offers a practical check.", + ]; - return [`Task overlay (${mode}):`, trimmedTask, "", "Round instructions:", ...instructions].join( - "\n", - ); + return [ + `Task overlay (${promptMode}/${taskIntent}):`, + trimmedTask, + "", + `Verifier harness: ${verifierHarness}`, + "Round instructions:", + ...instructions, + ].join("\n"); } diff --git a/src/prompt/task-prompt-profile.ts b/src/prompt/task-prompt-profile.ts new file mode 100644 index 0000000..f5ab4e0 --- /dev/null +++ b/src/prompt/task-prompt-profile.ts @@ -0,0 +1,10 @@ +import type { PromptMode } from "./mode-overlays.js"; + +export type TaskIntent = "delivery" | "review"; +export type VerifierHarnessMode = "disabled" | "loop"; + +export type TaskPromptProfile = { + promptMode: PromptMode; + taskIntent: TaskIntent; + verifierHarness: VerifierHarnessMode; +}; diff --git a/src/runtime/prompt.ts b/src/runtime/prompt.ts index 03e724c..ca940c2 100644 --- a/src/runtime/prompt.ts +++ b/src/runtime/prompt.ts @@ -2,6 +2,7 @@ import { getBaseContractPath, loadBaseContractPrompt } from "../prompt/base-cont import { buildModeOverlay, type PromptMode } from "../prompt/mode-overlays.js"; import { buildRepoOverlay } from "../prompt/repo-overlay.js"; import { buildTaskOverlay } from "../prompt/task-overlay.js"; +import type { TaskPromptProfile } from "../prompt/task-prompt-profile.js"; import { buildVerificationOverlay } from "../prompt/verification-overlay.js"; import type { VerificationResult } from "../loop/types.js"; import { buildRepoContextPack } from "./repo-context.js"; @@ -37,8 +38,8 @@ export async function buildRuntimePromptStack(args: { }; } -export function composeTaskPrompt(task: string, mode: PromptMode): string { - return buildTaskOverlay(task, mode); +export function composeTaskPrompt(task: string, profile: TaskPromptProfile): string { + return buildTaskOverlay(task, profile); } export function composeRepairPrompt(result: VerificationResult, nextRound: number): string { diff --git a/src/runtime/run-runtime.ts b/src/runtime/run-runtime.ts index 3a4228a..1f333f5 100644 --- a/src/runtime/run-runtime.ts +++ b/src/runtime/run-runtime.ts @@ -7,6 +7,7 @@ import { runVerificationCommand } from "../loop/verify-runner.js"; import type { VerificationCommandResolution, VerificationResult } from "../loop/types.js"; import { composeTaskPrompt } from "./prompt.js"; import type { PromptMode } from "../prompt/mode-overlays.js"; +import type { TaskPromptProfile } from "../prompt/task-prompt-profile.js"; import { patchRuntimeLoopStatus } from "../diagnostics/runtime-status.js"; export type RunRuntimeHooks = { @@ -40,10 +41,14 @@ function defaultWriteHuman(text: string): void { export async function runSingleTask( runtime: AgentSessionRuntime, task: string, - promptMode: PromptMode = "standard", + taskPromptProfile: TaskPromptProfile = { + promptMode: "standard", + taskIntent: "delivery", + verifierHarness: "disabled", + }, hooks?: RunRuntimeHooks, ): Promise { - return runPromptSequence(runtime, [composeTaskPrompt(task, promptMode)], hooks); + return runPromptSequence(runtime, [composeTaskPrompt(task, taskPromptProfile)], hooks); } type AgentAssistantMessage = { diff --git a/src/runtime/task-router.ts b/src/runtime/task-router.ts index 442655d..c09ae87 100644 --- a/src/runtime/task-router.ts +++ b/src/runtime/task-router.ts @@ -1,7 +1,10 @@ import type { PromptMode } from "../prompt/mode-overlays.js"; +import type { TaskPromptProfile } from "../prompt/task-prompt-profile.js"; export type TaskRouterDecision = { - mode: PromptMode; + promptMode: PromptMode; + taskIntent: TaskPromptProfile["taskIntent"]; + verifierHarness: TaskPromptProfile["verifierHarness"]; reason: string; }; @@ -57,28 +60,78 @@ function isLikelyTrivialTask(task: string): boolean { return false; } -export function routePromptModeForTask(args: { +function isLikelyReviewTask(task: string): boolean { + return matchesAnyKeyword(task, [ + "review", + "audit", + "code review", + "pr review", + "regression review", + "security review", + "审查", + "评审", + "代码审查", + ]); +} + +export function routeTaskExecutionForRun(args: { task: string; loopEnabled: boolean; + promptModeOverride?: PromptMode; }): TaskRouterDecision { const task = normalizeTaskText(args.task); if (args.loopEnabled) { return { - mode: "intensive", - reason: "loop enabled", + promptMode: args.promptModeOverride ?? "intensive", + taskIntent: "delivery", + verifierHarness: "loop", + reason: + args.promptModeOverride === undefined ? "loop enabled" : "loop enabled; prompt mode override", + }; + } + + if (isLikelyReviewTask(task)) { + return { + promptMode: args.promptModeOverride ?? "standard", + taskIntent: "review", + verifierHarness: "disabled", + reason: + args.promptModeOverride === undefined + ? "review task heuristic" + : "review task heuristic; prompt mode override", }; } if (isLikelyTrivialTask(task)) { return { - mode: "direct", - reason: "trivial task heuristic", + promptMode: args.promptModeOverride ?? "direct", + taskIntent: "delivery", + verifierHarness: "disabled", + reason: + args.promptModeOverride === undefined + ? "trivial task heuristic" + : "trivial task heuristic; prompt mode override", }; } return { - mode: "standard", - reason: "default lane", + promptMode: args.promptModeOverride ?? "standard", + taskIntent: "delivery", + verifierHarness: "disabled", + reason: + args.promptModeOverride === undefined ? "default lane" : "default lane; prompt mode override", + }; +} + +export function routePromptModeForTask(args: { + task: string; + loopEnabled: boolean; + promptModeOverride?: PromptMode; +}): { mode: PromptMode; reason: string } { + const decision = routeTaskExecutionForRun(args); + return { + mode: decision.promptMode, + reason: decision.reason, }; } diff --git a/tests/commands/run-command.test.ts b/tests/commands/run-command.test.ts new file mode 100644 index 0000000..5b37e88 --- /dev/null +++ b/tests/commands/run-command.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getDefaultConfigFile } from "../../src/app/config-store.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("runRunCommand", () => { + test("prints task route metadata in JSON output", async () => { + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + vi.doMock("../../src/app/config-store.js", async () => { + const actual = await vi.importActual( + "../../src/app/config-store.js", + ); + + return { + ...actual, + readConfigFile: vi.fn(async () => getDefaultConfigFile()), + }; + }); + + vi.doMock("../../src/session/create-session.js", async () => { + const actual = await vi.importActual( + "../../src/session/create-session.js", + ); + + return { + ...actual, + createGlmRuntime: vi.fn(async () => ({ cwd: "/tmp/repo" })), + withPreservedProcessCwd: vi.fn(async (fn: () => Promise) => fn()), + withScopedEnvironment: vi.fn( + async (_env: Partial, fn: () => Promise) => fn(), + ), + }; + }); + + vi.doMock("../../src/runtime/run-runtime.js", () => ({ + runSingleTask: vi.fn(async () => ({ + kind: "single", + exitCode: 0, + assistantText: "review complete", + })), + runTaskLoop: vi.fn(), + })); + + const { runRunCommand } = await import("../../src/commands/run.js"); + + const exitCode = await runRunCommand({ + cwd: "/tmp/repo", + task: "review the current diff for regressions", + json: true, + }); + + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(stdout.mock.calls.at(-1)?.[0])); + expect(payload.taskRoute).toMatchObject({ + promptMode: "standard", + taskIntent: "review", + verifierHarness: "disabled", + }); + }); +}); diff --git a/tests/loop/controller.test.ts b/tests/loop/controller.test.ts index 88d9237..a76612d 100644 --- a/tests/loop/controller.test.ts +++ b/tests/loop/controller.test.ts @@ -33,7 +33,7 @@ describe("runLoopController", () => { expect(result.status).toBe("succeeded"); expect(prompts).toHaveLength(2); - expect(prompts[0]).toContain("Task overlay (intensive):"); + expect(prompts[0]).toContain("Task overlay (intensive/delivery):"); expect(prompts[0]).toContain("fix tests"); expect(prompts[1]).toContain("Verification overlay: repair round 2."); expect(prompts[1]).toContain("pnpm test"); @@ -105,6 +105,6 @@ describe("runLoopController", () => { }); expect(result.status).toBe("succeeded"); - expect(prompts[0]).toContain("Task overlay (direct):"); + expect(prompts[0]).toContain("Task overlay (direct/delivery):"); }); }); diff --git a/tests/runtime/prompt.test.ts b/tests/runtime/prompt.test.ts index b772af3..a85be41 100644 --- a/tests/runtime/prompt.test.ts +++ b/tests/runtime/prompt.test.ts @@ -54,8 +54,12 @@ describe("runtime prompt stack", () => { }); test("task and repair overlays stay narrow", () => { - const taskPrompt = composeTaskPrompt("fix failing tests", "intensive"); - expect(taskPrompt).toContain("Task overlay (intensive):"); + const taskPrompt = composeTaskPrompt("fix failing tests", { + promptMode: "intensive", + taskIntent: "delivery", + verifierHarness: "loop", + }); + expect(taskPrompt).toContain("Task overlay (intensive/delivery):"); expect(taskPrompt).toContain("fix failing tests"); expect(taskPrompt).not.toContain("Respect the approval policy"); @@ -74,4 +78,17 @@ describe("runtime prompt stack", () => { expect(repairPrompt).toContain("1 test failed"); expect(repairPrompt).not.toContain("Start with a short explicit plan before editing."); }); + + test("review overlays prioritize findings without enabling verifier harness", () => { + const taskPrompt = composeTaskPrompt("review the current patch", { + promptMode: "standard", + taskIntent: "review", + verifierHarness: "disabled", + }); + + expect(taskPrompt).toContain("Task overlay (standard/review):"); + expect(taskPrompt).toContain("Prioritize concrete findings"); + expect(taskPrompt).toContain("Verifier harness: disabled"); + expect(taskPrompt).not.toContain("Make the smallest coherent fix"); + }); }); diff --git a/tests/runtime/run-single-task.test.ts b/tests/runtime/run-single-task.test.ts index b2c9dcf..64bee11 100644 --- a/tests/runtime/run-single-task.test.ts +++ b/tests/runtime/run-single-task.test.ts @@ -64,12 +64,16 @@ describe("runSingleTask", () => { const runtime = createFakeRuntime(dir); vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const result = await runSingleTask(runtime, "fix tests", "direct"); + const result = await runSingleTask(runtime, "fix tests", { + promptMode: "direct", + taskIntent: "delivery", + verifierHarness: "disabled", + }); expect(result.exitCode).toBe(0); expect(runtime.session.prompt).toHaveBeenCalledTimes(1); expect(runtime.session.prompt).toHaveBeenCalledWith( - expect.stringContaining("Task overlay (direct):"), + expect.stringContaining("Task overlay (direct/delivery):"), expect.anything(), ); expect(runtime.dispose).toHaveBeenCalledTimes(1); diff --git a/tests/runtime/task-router.test.ts b/tests/runtime/task-router.test.ts index 652aa7a..9d34161 100644 --- a/tests/runtime/task-router.test.ts +++ b/tests/runtime/task-router.test.ts @@ -1,40 +1,83 @@ import { describe, expect, test } from "vitest"; -import { routePromptModeForTask } from "../../src/runtime/task-router.js"; +import { routeTaskExecutionForRun } from "../../src/runtime/task-router.js"; describe("task router", () => { test("chooses intensive when loop is enabled", () => { - const decision = routePromptModeForTask({ + const decision = routeTaskExecutionForRun({ task: "fix the tests", loopEnabled: true, }); - expect(decision.mode).toBe("intensive"); + expect(decision).toMatchObject({ + promptMode: "intensive", + taskIntent: "delivery", + verifierHarness: "loop", + }); }); test("chooses direct for documentation tasks", () => { - const decision = routePromptModeForTask({ + const decision = routeTaskExecutionForRun({ task: "update README examples", loopEnabled: false, }); - expect(decision.mode).toBe("direct"); + expect(decision).toMatchObject({ + promptMode: "direct", + taskIntent: "delivery", + verifierHarness: "disabled", + }); }); test("chooses direct for Chinese documentation tasks", () => { - const decision = routePromptModeForTask({ + const decision = routeTaskExecutionForRun({ task: "修复 README 文档中的错别字", loopEnabled: false, }); - expect(decision.mode).toBe("direct"); + expect(decision).toMatchObject({ + promptMode: "direct", + taskIntent: "delivery", + verifierHarness: "disabled", + }); }); test("chooses standard for non-trivial code tasks when loop is disabled", () => { - const decision = routePromptModeForTask({ + const decision = routeTaskExecutionForRun({ task: "fix the failing tests in CI by addressing the flaky assertion", loopEnabled: false, }); - expect(decision.mode).toBe("standard"); + expect(decision).toMatchObject({ + promptMode: "standard", + taskIntent: "delivery", + verifierHarness: "disabled", + }); + }); + + test("routes review tasks into the review lane without enabling verifier harness", () => { + const decision = routeTaskExecutionForRun({ + task: "review the current patch for regressions and missing tests", + loopEnabled: false, + }); + + expect(decision).toMatchObject({ + promptMode: "standard", + taskIntent: "review", + verifierHarness: "disabled", + }); + }); + + test("keeps verifier harness disabled when prompt mode is overridden without loop", () => { + const decision = routeTaskExecutionForRun({ + task: "fix the flaky assertion", + loopEnabled: false, + promptModeOverride: "intensive", + }); + + expect(decision).toMatchObject({ + promptMode: "intensive", + taskIntent: "delivery", + verifierHarness: "disabled", + }); }); });