Skip to content
Closed
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,43 @@
# 分支说明:feature/plan-completion-gate-only

**日期**: 2026-07-25
**基线分支**: `feature/observability` (commit `e7ac54a`)
**PR 入口**: https://github.com/D-Robotics/moss/pull/new/feature/plan-completion-gate-only

只含**计划完成门**(及其前提 per-session store 重构)。不含规划质量 critic、不含 A/B 协议——那些在独立分支 `feature/plan-completion-gate`,default off / 实验性,本分支不带入。

## 起点

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

## 改了什么(5 个 commit)

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

### 2. 计划完成门本体(`99507a8`,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 条路径(无 session/无 plan/状态不对/查 plan 抛错/用户中止)。类型干净(required→optional 子类型,无适配)。

### 3. MOSS_PLAN_GATE flag(`48de1f0`,feat)
default **on**(完成门是已上线功能)。`MOSS_PLAN_GATE=off` 可关,留作 A/B baseline。端到端验证:default ON 时未完成 plan 被拦 + 注入 correction;OFF 时彻底 no-op(不注入)。

### 4. 集成测试(`0a2e0b2`,test)
真起 MossAgent loop,mock provider 走 create→approve+start→提前 end_turn→被否决→skip 两步→放行。证明完成门端到端生效(否决→注入 correction→循环继续→放行)。

### 5. off vs on mock 对比(`5947bc0`,test)
同一"偷懒"mock(建 5 步 plan、做完 1 步就 end_turn)跑 off/on:off 放行无 correction、on 拦住注入 correction。证明机制方向对(注:非真实收益,因 mock 是固定偷懒非真模型)。

## 测试

6 个相关 spec 全过:plan-controller-store / plan-completion-gate(9 case)/ plan-completion-gate-integ / plan-completion-gate-ab / plan-tools-nudge / interaction-mode-exit。build + typecheck 干净。

## 已知限制(诚实)

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-controller plan 不受 gate 覆盖**——`ctx.sessionKey` 为空时走 shared 兜底,`setActivePlanId` 不记,gate 查不到(无 sessionKey → fail-open 放行)。CLI 总有 sessionKey,影响主要在无 sessionKey 的嵌入 host。
4. tech-debt(文档级):CLI 路径 gate 在 chain 和 wrapper 各跑一次(幂等纯函数,无副作用,仅冗余)。

## 与完整分支的区别

完整分支 `feature/plan-completion-gate` 还含:critic 实验框架(`3859849`,default off,runner 待接)+ A/B 协议 doc(`960b865`)+ final-review 修正(`c29dbbe`)。本 `*-only` 分支**不含**这些,纯完成门,适合先单独 review/合。
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