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
3 changes: 3 additions & 0 deletions docs/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ All environment variables use the `MOSS_` prefix. Model settings (provider, mode
| `MOSS_PROMPT_PREFIX_DEBUG` | — | Alias for `MOSS_PROMPT_CACHE_DEBUG`. |
| `MOSS_LLM_FIRST_CHUNK_TIMEOUT_MS` | `0` | Timeout for first LLM stream chunk (0 = disabled, max 3,600,000). |
| `MOSS_SELF_LEARNING` | — | Set to `true` to extract user-correction feedback as memory. |
| `MOSS_PLAN_GATE` | `on` | Plan completion gate. Default ON (rejects premature `end_turn` when an approved/executing plan has unfinished steps). Set `off` to disable — used to take an A/B baseline against the same task set. |
| `MOSS_PLAN_VALIDATE` | — | Experimental plan-quality critic (default off). When `on`, critiques plans with `MOSS_PLAN_VALIDATE_MIN_STEPS` (default 5) steps at `plan action=approve`. |
| `MOSS_PLAN_VALIDATE_MIN_STEPS` | `5` | Minimum step count to trigger the `MOSS_PLAN_VALIDATE` critic. |

## MCP (Model Context Protocol)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 分支说明:feature/plan-completion-gate-main

**日期**: 2026-07-25
**基线**: `main` (commit `4e624a1`)
**PR 入口**: 从 main 开,base = `main`

只含**计划完成门**(及前提 per-session store 重构)。不含规划质量 critic、不含 A/B 协议、不含 spec/plan doc —— 那些在独立分支。本分支从 `main` 干净开,完成门单独进 main,不夹带 observability 分支上的其他工作(skill-eval/discovery 等)。

## 起点

「moss 主循环是否需要独立 Planner 规划校验层」——分析后确定性收益在「完成时强制 plan 执行完整」,不在「执行前校验质量」。本分支只做前者。

## 改了什么(5 个 commit)

### 1. per-session PlanExecuteController store(`8006fe6`,refactor)
把 `plan-tools.ts` 进程级单例换成按 `sessionKey` 路由的 store(`plan-controller-store.ts`),维护 `sessionKey → activePlanId` 映射。多 session 嵌入式 host 不再串读对方 active plan。完成门的前提。`PlanExecuteController` 业务逻辑不动。

### 2. 计划完成门(`f59f62b`,feat)
`evaluatePlanCompletionGate(request, deps)`:模型 `end_turn` 时若该 session 有 approved/executing 状态 plan 且 steps 未全做完(completed+skipped)→ 否决完成、注入 correction;逃生口 `plan_step skip` 带理由。接进 CLI `createCliCompletionGate` 链(排 `evaluatePlanEvalCompletionGate` 后)+ MossAgent `completionGate` 包装(无 structured-pending 分支)。fail-open 5 路径。类型干净(required→optional 子类型)。

### 3. MOSS_PLAN_GATE flag(`f758f0c`,feat)
default on。`=off` 可关做 A/B baseline。端到端验证:ON 拦+注入 correction,OFF 彻底 no-op。

### 4. 集成测试(`2bacf21`,test)
真起 MossAgent loop:create→approve+start→提前 end_turn→被否决→skip 两步→放行。

### 5. off vs on mock 对比(`6b2fcea`,test)
同一偷懒 mock:off 放行无 correction、on 拦住注入 correction。证明机制方向(非真实收益)。

## 测试

6 个相关 spec 全过(plan-controller-store / plan-completion-gate 9 case / -integ / -ab / plan-tools-nudge / interaction-mode-exit)。build + typecheck 干净。完成门不依赖 observability 任何东西,从 main 独立成立。

## 已知限制(诚实)

1. **完成门真实收益未经验证** — 机制 + 集成测试证明方向对,真实任务增益(真模型 + N≥3)未测;真 provider 试过 N=1 全噪声且烧 quota。`MOSS_PLAN_GATE` flag 留作后续真 A/B。
2. **retry 超限崩 run** — 模型死活不完成也不 skip,retry 用尽 `throw Completion rejected` 崩 run(平台既有 exhaustion 行为,非 force-complete)。
3. **shared/no-session plan 不受 gate 覆盖** — `ctx.sessionKey` 空时走 shared 兜底,gate 查不到 → fail-open 放行。CLI 总有 sessionKey,影响主要在无 sessionKey 嵌入 host。
4. tech-debt(文档级):CLI 路径 gate 在 chain 和 wrapper 各跑一次(幂等纯函数,仅冗余)。
3 changes: 3 additions & 0 deletions packages/moss-agent/src/cli/coding-completion-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* Soft: low retryLimit, clear coding / multi-step intents only.
*/
import type { Message } from '../core/session/session-jsonl.js';
import { evaluatePlanCompletionGate } from '../plan-execute/plan-completion-gate.js';
import { getActivePlanForSession } from '../plan-execute/plan-controller-store.js';
import { listBackgroundProcessSnapshots } from '../tools/background-exec.js';
import {
extractLatestTodosFromMessages,
Expand Down Expand Up @@ -2746,6 +2748,7 @@ export function createCliCompletionGate(
evaluateMemoryCompletionGate(request),
evaluateAskUserCompletionGate(request),
evaluatePlanEvalCompletionGate(request),
evaluatePlanCompletionGate(request, { getActivePlanForSession }),
evaluateBrowserVisionCompletionGate(request),
evaluateDeviceCompletionGate(request),
evaluateWebToolsCompletionGate(request),
Expand Down
9 changes: 9 additions & 0 deletions packages/moss-agent/src/core/agent/moss-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ import {
bumpStructuredValidationAttempt,
clearPendingStructuredValidation,
} from '../../structured-output/structured-output-tool.js';
import { evaluatePlanCompletionGate } from '../../plan-execute/plan-completion-gate.js';
import { getActivePlanForSession } from '../../plan-execute/plan-controller-store.js';
import {
createSpawnProfileRegistryFromDefaults,
SpawnProfileRegistry,
Expand Down Expand Up @@ -1494,6 +1496,13 @@ export class MossAgent {
if (!pending) {
// No structured validation pending — delegate to the user-provided
// completion gate (if any).
if (request.sessionKey) {
const planGate = evaluatePlanCompletionGate(
{ sessionKey: request.sessionKey, stopReason: request.stopReason },
{ getActivePlanForSession },
);
if (!planGate.ok) return planGate;
}
if (this.config.completionGate) return this.config.completionGate(request);
return { ok: true };
}
Expand Down
17 changes: 17 additions & 0 deletions packages/moss-agent/src/plan-execute/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,24 @@ export {
type PlanStepToolInput,
} from './plan-tools.js';

export {
getPlanController,
getSharedPlanController,
setActivePlanId,
getActivePlanId,
getActivePlanForSession,
resetPlanControllerStoreForTests,
} from './plan-controller-store.js';

export {
buildPlanExecuteSystemPrompt,
type PlanExecutePromptOptions,
} from './plan-execute-prompt.js';

export {
evaluatePlanCompletionGate,
planGateEnabled,
type PlanCompletionGateRequest,
type PlanCompletionGateDeps,
type PlanCompletionGateResult,
} from './plan-completion-gate.js';
74 changes: 74 additions & 0 deletions packages/moss-agent/src/plan-execute/plan-completion-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readEnv } from '../utils/env-compat.js';
import type { Plan } from './plan-execute-controller.js';

export interface PlanCompletionGateRequest {
sessionKey?: string;
stopReason?: string;
}

export interface PlanCompletionGateDeps {
getActivePlanForSession: (sessionKey: string) => Plan | null;
}

export type PlanCompletionGateResult =
| { ok: true }
| { ok: false; reason: string; correction: string; retryLimit: number };

const RETRY_LIMIT = 2;

/**
* Whether the plan completion gate is active. Default ON (the gate is a
* shipped feature, not an experiment). Set `MOSS_PLAN_GATE=off` to disable —
* the gate becomes a complete no-op, so an A/B baseline can be taken against
* the same task set with the gate off. This mirrors `criticEnabled()` in
* plan-critic.ts but defaults the opposite way (critic defaults off).
*/
export function planGateEnabled(): boolean {
const v = readEnv('MOSS_PLAN_GATE');
// Explicitly off only; anything else (unset, on, yes, 1) means enabled.
return !(v && /^(off|0|false|no)$/i.test(String(v).trim()));
}

export function evaluatePlanCompletionGate(
request: PlanCompletionGateRequest,
deps: PlanCompletionGateDeps,
): PlanCompletionGateResult {
// A/B switch: when explicitly disabled, the gate is a no-op (baseline mode).
if (!planGateEnabled()) return { ok: true };

// 用户中止 → 放行(与 evaluatePlanEvalCompletionGate 一致)
if (request.stopReason === 'aborted_by_user') return { ok: true };
const sessionKey = request.sessionKey;
if (!sessionKey) return { ok: true }; // 无 session: fail-open

let plan: Plan | null;
try {
plan = deps.getActivePlanForSession(sessionKey);
} catch {
return { ok: true }; // 故障 fail-open
}
if (!plan) return { ok: true };
if (plan.status !== 'approved' && plan.status !== 'executing') return { ok: true };

const total = plan.steps.length;
const done = plan.steps.filter(
(s) => s.status === 'completed' || s.status === 'skipped',
).length;
if (done >= total) return { ok: true };

const unfinished = plan.steps
.filter((s) => s.status !== 'completed' && s.status !== 'skipped')
.map((s) => `Step ${s.step}: ${s.description}`)
.join('\n');

return {
ok: false,
reason: 'plan has unfinished steps',
retryLimit: RETRY_LIMIT,
correction:
`[System] Plan ${plan.id} is ${plan.status} but ${total - done} step(s) remain unfinished:\n` +
`${unfinished}\n` +
`Continue executing the plan, or for each remaining step call plan_step action="skip" with a reason. ` +
`Do not claim the task complete while the plan has unfinished steps.`,
};
}
42 changes: 42 additions & 0 deletions packages/moss-agent/src/plan-execute/plan-controller-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { PlanExecuteController } from './plan-execute-controller.js';
import type { Plan } from './plan-execute-controller.js';

const DEFAULT_CONFIG = { maxReplans: 3, requireApproval: true, autoApproveSimple: true };

const sessionControllers = new Map<string, PlanExecuteController>();
let sharedController: PlanExecuteController | null = null;
const activePlanIdBySession = new Map<string, string>();

export function getPlanController(sessionKey: string): PlanExecuteController {
let c = sessionControllers.get(sessionKey);
if (!c) {
c = new PlanExecuteController({ ...DEFAULT_CONFIG });
sessionControllers.set(sessionKey, c);
}
return c;
}

export function getSharedPlanController(): PlanExecuteController {
if (!sharedController) sharedController = new PlanExecuteController({ ...DEFAULT_CONFIG });
return sharedController;
}

export function setActivePlanId(sessionKey: string, planId: string): void {
activePlanIdBySession.set(sessionKey, planId);
}

export function getActivePlanId(sessionKey: string): string | undefined {
return activePlanIdBySession.get(sessionKey);
}

export function getActivePlanForSession(sessionKey: string): Plan | null {
const id = activePlanIdBySession.get(sessionKey);
if (!id) return null;
return getPlanController(sessionKey).getPlan(id);
}

export function resetPlanControllerStoreForTests(): void {
sessionControllers.clear();
activePlanIdBySession.clear();
sharedController = null;
}
38 changes: 19 additions & 19 deletions packages/moss-agent/src/plan-execute/plan-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

import type { Tool } from '../core/tools/tool-types.js';
import { PlanExecuteController } from './plan-execute-controller.js';
import {
getPlanController,
getSharedPlanController,
setActivePlanId,
resetPlanControllerStoreForTests,
} from './plan-controller-store.js';
import { errorMessage } from '../errors.js';
import {
getCliInteractionMode,
Expand Down Expand Up @@ -56,20 +62,6 @@ function toolError(prefix: string, err: unknown): Error {
return new Error(`${prefix}: ${errorMessage(err)}`);
}


let controllerInstance: PlanExecuteController | null = null;

function getController(): PlanExecuteController {
if (!controllerInstance) {
controllerInstance = new PlanExecuteController({
maxReplans: 3,
requireApproval: true,
autoApproveSimple: true,
});
}
return controllerInstance;
}

/** When a plan is approved/started, leave interactionMode=plan so coding tools can run. */
function leavePlanModeForExecution(): boolean {
if (getCliInteractionMode() !== 'plan') return false;
Expand All @@ -91,13 +83,14 @@ function isAffirmativePlanApproval(answer: string): boolean {
* drops the plan-mode mutation gate. Non-interactive runs keep previous behavior.
*/
async function confirmPlanApprovalIfNeeded(
controller: PlanExecuteController,
planId: string,
abortSignal?: AbortSignal,
): Promise<'approved' | 'declined' | 'unavailable' | 'skipped'> {
if (getCliInteractionMode() !== 'plan') return 'skipped';
const asker = getCliUserQuestionAsker();
if (!asker) return 'unavailable';
const plan = getController().getPlan(planId);
const plan = controller.getPlan(planId);
const goal = plan?.goal ? plan.goal.slice(0, 160) : planId;
const steps = plan?.steps?.length ?? 0;
const prompt = [
Expand All @@ -120,7 +113,7 @@ async function confirmPlanApprovalIfNeeded(


export function resetPlanControllerForTests(): void {
controllerInstance = null;
resetPlanControllerStoreForTests();
}


Expand Down Expand Up @@ -201,7 +194,9 @@ export function createPlanTool(): Tool<PlanToolInput> {
},
async execute(input, ctx) {
try {
const controller = getController();
const controller = ctx.sessionKey
? getPlanController(ctx.sessionKey)
: getSharedPlanController();

switch (input.action) {
case 'create': {
Expand Down Expand Up @@ -264,6 +259,7 @@ export function createPlanTool(): Tool<PlanToolInput> {
case 'approve': {
if (!input.planId) return 'Error: planId is required for approval.';
const confirmation = await confirmPlanApprovalIfNeeded(
controller,
input.planId,
ctx.abortSignal,
);
Expand All @@ -275,6 +271,7 @@ export function createPlanTool(): Tool<PlanToolInput> {
}
const ok = controller.approvePlan(input.planId);
if (!ok) return `Error: could not approve plan ${input.planId}.`;
if (ctx.sessionKey) setActivePlanId(ctx.sessionKey, input.planId);
// Claude ExitPlanMode parity (light): approving a plan is the user's
// go-ahead to leave read-only planning and begin execution. If the
// session is still in interactionMode=plan, drop to default so
Expand All @@ -295,6 +292,7 @@ export function createPlanTool(): Tool<PlanToolInput> {
if (!input.planId) return 'Error: planId is required to start execution.';
const ok = controller.startExecution(input.planId);
if (!ok) return `Error: could not start plan ${input.planId}. Ensure it is approved.`;
if (ctx.sessionKey) setActivePlanId(ctx.sessionKey, input.planId);

const leftPlanMode = leavePlanModeForExecution();
const plan = controller.getPlan(input.planId);
Expand Down Expand Up @@ -396,9 +394,11 @@ export function createPlanStepTool(): Tool<PlanStepToolInput> {
},
required: ['planId', 'stepNumber', 'action'],
},
async execute(input, _ctx) {
async execute(input, ctx) {
try {
const controller = getController();
const controller = ctx.sessionKey
? getPlanController(ctx.sessionKey)
: getSharedPlanController();

switch (input.action) {
case 'complete': {
Expand Down
Loading