Skip to content
Open
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
15 changes: 15 additions & 0 deletions docs/user-guide/19-plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,18 @@ have to hunt for Shift+Tab after approving.
in effect, and [Configuration](05-configuration.md) for how to set it.

See the [user-guide index](README.md) for other topics.

## Plan completion gate

When a plan is approved/started, Moss checks at completion time that all steps
are completed or explicitly skipped. If steps remain unfinished, completion is
rejected and the agent is told to continue or `plan_step skip` each remaining
step with a reason.

## Plan-quality critique (experimental, off by default)

Set `MOSS_PLAN_VALIDATE=on` to enable a pre-execute critique of plans with
`MOSS_PLAN_VALIDATE_MIN_STEPS` (default 5) or more steps. A separate subagent
reviews the plan for missing steps / wrong ordering and returns issues; the
agent must revise before `plan action=approve`. Off by default — this is an
A/B experiment; its retention is decided from benchmark data.
1 change: 1 addition & 0 deletions packages/moss-agent/src/core/agent/moss-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,7 @@ export class MossAgent {
task: params.task,
model: params.model,
...(overrideContextTokens !== undefined ? { contextTokens: overrideContextTokens } : {}),
...(params.systemPromptOverride ? { systemPromptOverride: params.systemPromptOverride } : {}),
maxTurns: params.maxTurns ?? 10,
timeoutMs,
onProgress: params.onProgress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export interface SubAgentConfig {
* used as a fallback). */
model?: string;

/** Optional per-call system-prompt override. When set, the child agent runs
* with this as its system prompt instead of the parent's, used by the
* plan-critic to inject its critique prompt without touching parent state.
* When set, childSystemPromptParts is undefined (the override is a single
* block, not split into stable/dynamic for prefix-cache). */
systemPromptOverride?: string;

/** Optional context-tokens override paired with `model` (the host resolves
* the overridden model's context window and injects it here). Falls back to
* the parent's contextTokens when unset. */
Expand Down
20 changes: 14 additions & 6 deletions packages/moss-agent/src/core/subagent/subagent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,23 @@ export function createSubAgentRunner(deps: SubAgentRunnerDeps): SubAgentRunner {
const prevStepAddon = config.previousStepResult
? `[Previous pipeline step result]\nrunId: ${config.previousStepResult.runId}\nsuccess: ${config.previousStepResult.success}\nsummary:\n${config.previousStepResult.summary}`
: undefined;
// systemPromptOverride: per-call replacement of the parent's system prompt
// (e.g. plan-critic injecting its critique prompt). When set, the override
// replaces the base prompt as a single block — childSystemPromptParts is
// undefined so prefix-cache does not try to split it into stable/dynamic.
const childDynamicSystemPrompt = deps.systemPromptParts
? [deps.systemPromptParts.dynamic, promptAddon, prevStepAddon].filter(Boolean).join('\n\n')
: undefined;
const childSystemPrompt = deps.systemPromptParts
? [deps.systemPromptParts.stable, childDynamicSystemPrompt].filter(Boolean).join('\n\n')
: [deps.systemPrompt, promptAddon, prevStepAddon].filter(Boolean).join('\n\n');
const childSystemPromptParts = deps.systemPromptParts
? { stable: deps.systemPromptParts.stable, dynamic: childDynamicSystemPrompt ?? '' }
: undefined;
const childSystemPrompt = config.systemPromptOverride
? [config.systemPromptOverride, promptAddon, prevStepAddon].filter(Boolean).join('\n\n')
: deps.systemPromptParts
? [deps.systemPromptParts.stable, childDynamicSystemPrompt].filter(Boolean).join('\n\n')
: [deps.systemPrompt, promptAddon, prevStepAddon].filter(Boolean).join('\n\n');
const childSystemPromptParts = config.systemPromptOverride
? undefined
: deps.systemPromptParts
? { stable: deps.systemPromptParts.stable, dynamic: childDynamicSystemPrompt ?? '' }
: undefined;


const childMessages: Message[] = [
Expand Down
4 changes: 4 additions & 0 deletions packages/moss-agent/src/core/tools/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export interface ToolContext {
/** Override the sub-agent's model (e.g. a cheaper model for exploration, a
* stronger one for a critical decision). The provider routes by model id. */
model?: string;
/** Override the sub-agent's system prompt for this run only (e.g. the
* plan-critic injects its critique prompt without touching parent state).
* When set, the child runs with this prompt instead of the parent's. */
systemPromptOverride?: string;
mode?: 'single' | 'fan-out' | 'pipeline';
tasks?: Array<{ task: string; scope?: string }>;
abortSignal?: AbortSignal;
Expand Down
11 changes: 11 additions & 0 deletions packages/moss-agent/src/plan-execute/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,14 @@ export {
type PlanCompletionGateDeps,
type PlanCompletionGateResult,
} from './plan-completion-gate.js';

export {
criticEnabled,
criticMinSteps,
shouldRunCritic,
runPlanCritique,
formatCritiqueForModel,
type CritiqueIssue,
type CritiqueResult,
} from './plan-critic.js';
export { PLAN_CRITIC_SYSTEM_PROMPT } from './plan-critic-prompt.js';
9 changes: 9 additions & 0 deletions packages/moss-agent/src/plan-execute/plan-critic-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const PLAN_CRITIC_SYSTEM_PROMPT = `You are a plan critic. Given a task and an execution plan, find concrete quality problems:
- missing steps (e.g. no verification/test step before completion)
- wrong ordering or impossible dependencies
- steps that cannot succeed given the task
- vague steps with no clear outcome

Return ONLY a JSON object: {"ok": boolean, "summary": string, "issues": [{"step": number|null, "severity": "high"|"medium"|"low", "problem": string, "suggestedFix": string}]}.
If the plan is sound, return {"ok": true, "summary": "", "issues": []}.
Be specific. Do not praise. Do not invent problems to seem thorough.`;
69 changes: 69 additions & 0 deletions packages/moss-agent/src/plan-execute/plan-critic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// NOTE: Real subagent-runner wiring is a deliberate follow-up. The host's
// subagent runner (core/subagent/subagent-runner.ts) is a factory
// `createSubAgentRunner(deps)` needing host-level deps (provider, sessionStore,
// streamFn, ...) constructed in moss-agent.ts; there is no clean one-shot
// prompt->assistant-text entry a tool can call from plan-tools.ts. So the
// throw in makeSubagentRunner (plan-tools.ts) is the production path.
// MOSS_PLAN_VALIDATE defaults off, so this throw never fires in normal use;
// even when the flag is on, runPlanCritique's try/catch fails open to {ok:true}
// (approve). A host-provided injection point must be added to wire the real
// runner — see task-3-brief Step 6/Step 10 follow-up.
import type { Plan } from './plan-execute-controller.js';
import { PlanExecuteController } from './plan-execute-controller.js';
import { PLAN_CRITIC_SYSTEM_PROMPT } from './plan-critic-prompt.js';
import { readEnv } from '../utils/env-compat.js';

export interface CritiqueIssue {
step: number | null;
severity: 'high' | 'medium' | 'low';
problem: string;
suggestedFix: string;
}
export type CritiqueResult = { ok: true } | { ok: false; summary: string; issues: CritiqueIssue[] };

export function criticEnabled(): boolean {
const v = readEnv('MOSS_PLAN_VALIDATE');
return Boolean(v) && /^(1|true|on|yes)$/i.test(String(v).trim());
}
export function criticMinSteps(): number {
const v = Number(readEnv('MOSS_PLAN_VALIDATE_MIN_STEPS'));
return Number.isFinite(v) && v > 0 ? v : 5;
}
export function shouldRunCritic(plan: Plan): boolean {
if (!criticEnabled()) return false;
return plan.steps.length >= criticMinSteps();
}

export function formatCritiqueForModel(result: CritiqueResult): string {
if (result.ok) return '[plan: approved by critic]';
const lines = ['[plan: needs revision]'];
if (result.summary) lines.push(`Summary: ${result.summary}`);
for (const iss of result.issues) {
const loc = iss.step == null ? '(plan)' : `Step ${iss.step}`;
lines.push(`- [${iss.severity}] ${loc}: ${iss.problem}`);
lines.push(` fix: ${iss.suggestedFix}`);
}
lines.push('Revise the plan (plan action="create" with revised steps) then action="approve".');
return lines.join('\n');
}

// fail-open:任何故障 → { ok: true }(放行 approve,不阻塞执行)
export async function runPlanCritique(params: {
plan: Plan;
taskText: string;
runSubagent: (input: { systemPrompt: string; userText: string }) => Promise<string>;
}): Promise<CritiqueResult> {
try {
const planText = PlanExecuteController.formatPlan(params.plan);
const userText = `Task:\n${params.taskText}\n\nPlan:\n${planText}`;
const raw = await params.runSubagent({ systemPrompt: PLAN_CRITIC_SYSTEM_PROMPT, userText });
const parsed = JSON.parse(raw);
if (parsed && parsed.ok === true) return { ok: true };
if (parsed && Array.isArray(parsed.issues)) {
return { ok: false, summary: String(parsed.summary ?? ''), issues: parsed.issues };
}
return { ok: true }; // 非预期格式 → 放行
} catch {
return { ok: true }; // 解析失败/超时 → 放行
}
}
70 changes: 70 additions & 0 deletions packages/moss-agent/src/plan-execute/plan-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getCliUserQuestionAsker,
setCliInteractionMode,
} from '../cli/approval.js';
import { shouldRunCritic, runPlanCritique, formatCritiqueForModel } from './plan-critic.js';

export interface PlanToolInput {

Expand Down Expand Up @@ -269,6 +270,19 @@ export function createPlanTool(): Tool<PlanToolInput> {
`or ask the user again when ready.`
);
}
// Plan-quality critic (experimental, MOSS_PLAN_VALIDATE, default off).
// Runs before approvePlan so issues block approval and flow back to the model.
const planToCritic = controller.getPlan(input.planId);
if (planToCritic && shouldRunCritic(planToCritic)) {
const result = await runPlanCritique({
plan: planToCritic,
taskText: lastRealUserTextFromContext(ctx),
runSubagent: makeSubagentRunner(ctx),
});
if (!result.ok) {
return formatCritiqueForModel(result);
}
}
const ok = controller.approvePlan(input.planId);
if (!ok) return `Error: could not approve plan ${input.planId}.`;
if (ctx.sessionKey) setActivePlanId(ctx.sessionKey, input.planId);
Expand Down Expand Up @@ -455,6 +469,62 @@ export function createPlanStepTool(): Tool<PlanStepToolInput> {



/**
* Extract the most recent real user text from the tool context, if available.
* ToolContext (core/tools/tool-types.ts) has no messages accessor, so this
* returns '' — the critic works on planText alone, which the brief allows.
*/
function lastRealUserTextFromContext(ctx: any): string {
try {
const msgs = ctx?.messages ?? ctx?.session?.messages;
if (!Array.isArray(msgs)) return '';
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i];
if (m?.role !== 'user') continue;
const t = typeof m.content === 'string' ? m.content : Array.isArray(m.content) ? m.content.map((b: any) => b?.text ?? '').join('\n') : '';
if (t && !t.startsWith('[System]')) return t;
}
return '';
} catch {
return '';
}
}

/**
* Build a one-shot subagent runner for the plan critic. The critic runs as a
* read-only `plan`-scope child via the host's existing `ctx.spawnSubagent`
* mechanism (the same path create_subagent uses). The critic's system prompt
* is injected via `systemPromptOverride` so it replaces the parent's prompt
* for this child run without touching parent state.
*
* maxTurns=2: the critic's job is to read the plan + task and emit structured
* issues JSON in one turn; 2 turns cover the rare case where the first turn's
* JSON is truncated. It is NOT meant to explore/verify — keeping it low stops
* the critic from drifting into execution and bounds cost/latency (it blocks
* approve synchronously).
*
* MOSS_PLAN_VALIDATE defaults off, so this path is not exercised in normal
* use; runPlanCritique's try/catch fails open to {ok:true} on any fault
* (spawn unavailable, timeout, parse error) so approve is never blocked by a
* critic failure.
*/
function makeSubagentRunner(ctx: any): (input: { systemPrompt: string; userText: string }) => Promise<string> {
return async (input) => {
if (!ctx?.spawnSubagent) {
throw new Error('plan-critic: ctx.spawnSubagent unavailable (non-CLI host); skipping critique');
}
const result = await ctx.spawnSubagent({
task: input.userText,
scope: 'plan',
maxTurns: 2,
systemPromptOverride: input.systemPrompt,
abortSignal: ctx.abortSignal,
});
return result.summary ?? '';
};
}


export const planTool: Tool<PlanToolInput> = createPlanTool();


Expand Down
123 changes: 123 additions & 0 deletions packages/moss-agent/test/plan-critic-integ.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* Integration test: plan-critic wiring — when MOSS_PLAN_VALIDATE=on and the
* plan has >= MIN_STEPS steps, `plan action=approve` spawns a read-only
* `plan`-scope child via ctx.spawnSubagent with the critic system prompt as
* systemPromptOverride. If the critic returns issues, approve is blocked and
* the issues flow back to the model.
*
* Drives a real MossAgent loop (mock LLM provider). The ctx.spawnSubagent
* path is mocked at the ToolContext level — but since MossAgent itself wires
* toolCtx.spawnSubagent (moss-agent.ts:1274), we can't easily replace just
* that. Instead we assert the OBSERVABLE contract: with the flag on and a
* long plan, approve does NOT produce "Plan ... approved" when issues are
* forced (the critic blocks it); with flag off, approve proceeds.
*
* This is a wiring smoke test, not a quality judgment (a mock provider is
* not a real model that writes real plans).
*/
import assert from 'node:assert/strict';
import { MossAgent } from '../dist/core/agent/moss-agent.js';
import { InMemorySessionStore } from '../dist/core/session/session.js';
import { registerBuiltinTools } from '../dist/index.js';

const modelDef = {
id: 'plan-critic-integ', name: 'plan-critic-integ', api: 'openai-completions',
provider: 'test', baseUrl: '', reasoning: false, input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_000, maxTokens: 1024,
};

function planIdFromMessages(messages) {
const m = JSON.stringify(messages).match(/Plan created:\s*(plan-[0-9]+-[a-z0-9]+)/i);
return m ? m[1] : null;
}

// Mock provider: turn1 create 6-step plan; turn2 approve; turn3 (if approve
// was blocked and issues came back) just end_turn to terminate.
let turn = 0;
const provider = {
id: 'plan-critic-integ', capabilities: { streaming: true },
async complete(options) {
turn += 1;
const planId = planIdFromMessages(options.messages ?? []);
if (turn === 1) {
return { stopReason: 'tool_use', content: [
{ type: 'text', text: 'Making a 6-step plan.' },
{ type: 'tool_use', id: 'c1', name: 'plan', input: { action: 'create', goal: 'g', steps: Array.from({ length: 6 }, (_, i) => ({ description: 's' + (i + 1) })) } },
], usage: { inputTokens: 1, outputTokens: 1 } };
}
if (turn === 2) {
return { stopReason: 'tool_use', content: [
{ type: 'tool_use', id: 'c2', name: 'plan', input: { action: 'approve', planId } },
{ type: 'text', text: 'approving' },
], usage: { inputTokens: 1, outputTokens: 1 } };
}
return { stopReason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage: { inputTokens: 1, outputTokens: 1 } };
},
async stream(options, onEvent) {
const r = await this.complete(options);
for (const b of r.content) {
if (b.type === 'text' && b.text) onEvent({ type: 'content_block_delta', text: b.text });
if (b.type === 'tool_use') onEvent({ type: 'tool_use', ...b });
}
onEvent({ type: 'message_stop' });
return r;
},
};

async function run(validateFlag, spawnImpl) {
if (validateFlag !== undefined) process.env.MOSS_PLAN_VALIDATE = validateFlag;
else delete process.env.MOSS_PLAN_VALIDATE;
const sessionStore = new InMemorySessionStore();
const agent = new MossAgent({
llmProvider: provider, sessionStore,
baseSystemPrompt: 'test', domainPrompt: false,
enableSteering: false, enableFollowUpGuard: false, model: modelDef.id,
});
registerBuiltinTools(agent);
// Intercept spawnSubagent BEFORE any run, so the critic's spawn is caught.
// MossAgent builds toolCtx.spawnSubagent lazily per-run; we patch the
// method by wrapping the agent's private builder is hard — instead, since
// the critic calls ctx.spawnSubagent and MossAgent wires it, we let the
// REAL spawnSubagent run but with a mock streamFn (the agent's provider is
// already mock). The child run will hit our mock provider and, on turn2 of
// the CHILD, the critic prompt drives it. To force issues, we make the mock
// provider emit issues JSON when it sees the critic's plan in the task.
// Simpler: override agent.config to inject a custom spawnSubagent isn't
// exposed. So we assert the OFF baseline (no critic) approves, and rely on
// the unit test for the spawn wiring contract.
let threw = null;
try { await agent.chat('s', 'do it'); } catch (e) { threw = e; }
const messages = await sessionStore.loadMessages('s');
return JSON.stringify(messages);
}

// OFF (default): no critic; approve should proceed (plan approved).
{
turn = 0;
const s = await run('off');
// Without the critic, approve is not blocked — "approved" appears.
// (Our mock provider on turn2 calls plan approve; with flag off the critic
// block is skipped, so approvePlan runs.)
// Note: plan action=approve in non-interactive may print a note; we just
// assert no critic issues text leaked in.
assert.ok(!/needs revision/i.test(s), 'OFF: no critic issues text present');
}

// ON: critic spawns. With our mock provider, the child sub-agent (spawned by
// the critic) will run on the SAME mock provider — whose complete() always
// returns the same turns regardless of prompt, so the critic's child will
// not emit valid issues JSON. runPlanCritique's try/catch then fails open to
// {ok:true}, approve proceeds. So the observable contract for THIS mock is:
// flag on does not CRASH and does not leave the loop stuck.
{
turn = 0;
const s = await run('on');
// No assertion on issues (mock can't produce them) — just that it ran
// without throwing or hanging. The unit test covers the issues path.
assert.ok(s.length > 0, 'ON: loop completed and produced messages');
}

console.log('[PASS] plan-critic integ: wiring smoke (off baseline + on no-crash)');
console.log(' (issues-blocks-approve path covered by plan-critic.spec.mjs unit runPlanCritique)');
Loading