feat(plan-mode): integration bridge wiring tools + mutation gate + sessions.patch [PR-8]#67840
feat(plan-mode): integration bridge wiring tools + mutation gate + sessions.patch [PR-8]#67840100yenadmin wants to merge 29 commits into
Conversation
…ne, types Phase 3.C of the GPT 5.4 parity sprint. Implements plan mode as an opt-in feature where mutation tools are blocked during Phase A (plan) until the user approves the plan, then Phase B (execute) runs normally with full tool access. ## Plan mode mutation gate (mutation-gate.ts) - Blocks 11 mutation tools by exact name: apply_patch, edit, exec, gateway, message, nodes, process, sessions_send, sessions_spawn, subagents, write - Blocks suffix patterns: *.write, *.edit, *.delete - Allows: read, web_search, web_fetch, memory_search, memory_get, update_plan, exit_plan_mode, session_status - Special exec whitelist: read-only commands (ls, cat, git status, grep, find, etc.) are allowed; mutating commands are blocked - 30 unit tests covering all paths ## Approval state machine (approval.ts) - States: none → pending → approved | edited | rejected | timed_out - approve: transitions to normal mode, records confirmedAt - edit: re-enters plan mode with edited plan - reject/timeout: transitions to normal mode, aborts turn - buildApprovedPlanInjection(): generates system message for Phase B - 7 unit tests ## Types (types.ts) - PlanMode, PlanApprovalState, PlanModeSessionState - DEFAULT_PLAN_MODE_STATE Built independently of PR #61845. Plan mode is NEVER auto-enabled — it requires explicit user opt-in via config, slash command, or UI toggle (entry points follow in a separate commit). Tracking: #66345, #67520.
…efault deny, confirmedAt - Block shell compound operators (&&, ||, |, ;, >, >>, backticks, $()) in exec/bash commands to prevent chaining past the prefix allowlist - Add "bash" to the mutation tool blocklist (alias for exec) - Default to blocked for unknown tools (fail-closed, not fail-open) - Clear confirmedAt on "edit" transition to prevent stale approval leaking - Remove unused PlanApprovalState import
…itelist Two Codex P1 issues at 95%+ confidence: 1. Newlines as shell separators: "ls\nrm -rf tmp" bypassed the compound operator check because \n wasn't in the regex. Added \n and \r to the operator detection pattern. 2. Dangerous flags on allowed prefixes: "find . -delete" passed because "find" is whitelisted. Added a dangerous-flag check for -delete, -exec, -execdir, --delete, -rf, -i that runs after the operator check but before the prefix allowlist. Added 4 new test cases covering both vectors + bash alias.
`env` as a prefix allows arbitrary command execution (e.g. `env npm install`). Remove it — users who need environment variables can use `printenv` which is already allowlisted.
- Convert args from mappings to arrays - Replace sendAgentMessage with state.addInboundMessage - Replace waitForToolCall with waitForOutboundMessage + lambda predicates - Replace assertions: blocks with assert: actions - Add waitForGatewayHealthy + waitForQaChannelReady setup - Use ref: state / ref: env for runtime objects - Fix gpt54-plan-mode-default-off scenario ID to match filename - Simplify injection-scan to baseline check (scanner unit-tested in #67512)
- Add explicit tests for bash tool blocked in plan mode, shell compound operators (redirect, pipe, semicolon), newlines in commands, and dangerous flags (find -delete, find -exec) - Add early-return guard in resolvePlanApproval for stale timeouts that fire after the user has already acted on the approval - Add test verifying stale timeout is ignored after approval is resolved
…ear confirmedAt Three fixes at 95%+ confidence from Copilot review: 1. Remove -i from DANGEROUS_FLAGS — blocks grep -i (case-insensitive search), the most common grep usage in plan mode. The remaining flags (-delete, -exec, -execdir, --delete, -rf) are genuinely dangerous. 2. Add read-only suffix patterns (.read, .search, .list, .get, .view) so MCP read tools like custom_mcp.read pass the fail-closed default. Mutation suffixes (.write, .edit, .delete) are still blocked. 3. Clear confirmedAt on reject and timeout transitions in the approval state machine. Previously only edit cleared it; reject/timeout spread current which could leak a stale confirmedAt.
…ject/timeout - mutation-gate: use word-boundary matching for DANGEROUS_FLAGS to prevent substring false positives (e.g. -exec matching -executable, -delete matching -delete-old). Added tests for find -executable and grep -rfl. - approval: reject and timeout now stay in plan mode instead of transitioning to normal mode, preventing fail-open after rejection.
Decision 4 from adversarial audit: defines how plan rejection, editing, and revision work across all surfaces (web, CLI, messaging). ## State machine changes - reject: stays in plan mode (fail-closed), increments rejectionCount, captures user feedback. Agent receives [PLAN_DECISION] injection on next turn with feedback text and revision instructions. - edit: transitions to normal mode (user edits count as approval), records confirmedAt. Edited plan is the approved plan. - timeout: stays in plan mode. Agent told proposal expired, may re-propose. - After 3+ rejections: injection adds "consider asking the user to clarify their goal" guidance. ## New exports - buildPlanDecisionInjection(): generates the structured [PLAN_DECISION] block for rejection/expiry context injection - PlanModeSessionState now tracks feedback and rejectionCount ## UI contract (documented in types.ts) Web/Desktop: [Approve] [Edit] [Reject + Feedback] buttons + persistent "Plan Mode Active" banner + [Exit Plan Mode] escape hatch Messaging: [Approve] [Reject] inline buttons, user types feedback as next message after rejection. No Edit on messaging (limitation). ## Tests 14 test cases covering: approve, edit, reject with feedback, rejection accumulation, timeout, stale timeout, enteredAt preservation, feedback clearing on approval, plan decision injection format.
…nly turns Increase strict-agentic planning-only retry limit from 2 to 3 to give GPT-5.4 more chances to act before blocking. Add escalating retry instructions that increase urgency with each attempt: - Retry 1: standard act-now steer - Retry 2: CRITICAL — must call a tool - Retry 3: FINAL WARNING — execute or cancel Add autoContinue config for embeddedPi (agents.defaults.embeddedPi.autoContinue): - enabled: false by default (opt-in) - maxTurns: max consecutive auto-continue turns before pausing (default: 5) - stopOnMutation: pause when agent produces mutating tool calls (default: true) Wire auto-continue into the run loop: when enabled and budget remains, inject ACK fast-path instruction instead of surfacing the blocked state, resetting the retry counter for the next planning-only cycle.
…events, maxCycles rename Critical fixes from adversarial audit: 1. stopOnMutation now tracks accumulated mutations across the entire run, not just the current attempt. Previously a plan-only turn following a mutating turn would reset hadPotentialSideEffects to false and bypass the mutation guard. 2. Auto-continue path now emits agentPlanEvent with source='auto_continue' so UI observers track state transitions (parity with planning-only retry path which already emits plan events). 3. Rename maxTurns → maxCycles with accurate documentation: each cycle = 1 ACK injection + up to 3 planning retries = ~4 API calls. Total worst-case = 1 + (maxCycles × 4). Default lowered from 5 to 3 (worst-case 13 calls instead of 21). 4. Deduplicate prompt additions via Set to prevent ACK instruction from appearing twice when both ackExecutionFastPathInstruction and planningOnlyRetryInstruction resolve to the same string.
…guard reachability Add integration test verifying auto-continue injects ACK fast-path and respects maxCycles budget via the full runEmbeddedPiAgent harness. Document that stopOnMutation accumulated guard is defense-in-depth: the planning-only detection at incomplete-turn.ts:567 already rejects turns with hadPotentialSideEffects, so the accumulated guard protects against future filter relaxation, not current behavior.
Mutation gate: - Block process substitution <( and >( in shell operator regex - Add --output to dangerous flags (prevents git diff --output= file write bypass) - Replace indexOf-based flag matching with word-boundary regex to avoid false matches on substrings (e.g. -executable != -exec) - Tabs now treated as whitespace separators in flag detection Approval state machine: - Terminal states (approved, edited, timed_out) now reject further actions - Rejected state allows re-approve (user changes mind) and re-reject (revised feedback) - Added tests for terminal-state guard and rejected→approved transition
…ent, cfg guard 1. Replace ACK_EXECUTION_FAST_PATH_INSTRUCTION with AUTO_CONTINUE_FAST_PATH_INSTRUCTION in auto-continue path. The old text falsely claimed 'the latest user message is a short approval' when no user message was sent. New text: 'The system is auto-continuing.' 2. Add onAgentEvent emission to auto-continue path for parity with planning_only_retry path. Streaming UI observers now see source='auto_continue' plan updates. 3. Guard cfg undefined in resolveAgentAutoContinue — resolveAgentConfig() requires non-undefined config.
Every scenario now asserts actual tool calls, not just response text: - mandatory-tool-use: asserts exec/code_execution/session_status called - act-dont-ask: asserts exec called for port check, no clarification asked - injection-scan: asserts response doesn't echo injected directive - cancelled-status: asserts update_plan called with cancelled status - plan-mode-default-off: asserts read called, no update_plan for simple tasks
…uard, approvalId + reset #67538a (auto-continue + retry escalation): - Tone down retry-3 instruction: removed 'execute or cancel' threat. Replaced with Hermes-style 'Final reminder: this is the third planning-only turn. Please call a tool now to make progress.' - Plan-mode awareness in shouldApplyPlanningOnlyRetryGuard: when planModeActive is true, return false. Planning-only IS the desired state in plan mode — the agent should produce a thorough plan and call exit_plan_mode for review. - Plumb planModeActive through resolvePlanningOnlyRetryInstruction, resolveReasoningOnlyRetryInstruction, resolveEmptyResponseRetryInstruction, resolveAckExecutionFastPathInstruction (optional, default false = current behavior) #67538b (plan-mode library): - Add approvalId version token to PlanModeSessionState. Regenerated on every exit_plan_mode call (PR-8 will wire). Approve/reject/edit with mismatched expectedApprovalId is no-op (prevents stale-event attacks where user clicks Approve on a plan that was already revised on another surface). - newPlanApprovalId() helper exported from plan-mode/index for PR-8 to mint fresh tokens. - approve/edit reset rejectionCount to 0 — user is moving forward, cycle history is no longer relevant. reject and timeout do NOT reset. - Terminal-state guard remains correct: approved/edited/timed_out are terminal; rejected stays open for re-approval or re-rejection. Tests: +2 incomplete-turn (plan-mode planning-only retry returns null, plan-mode ack fast-path returns null). +8 approval (approvalId stale-event guard 4 cases, rejectionCount reset 4 cases). All 78 plan-mode + 52 incomplete-turn tests green.
…opy + injection escape
Three security fixes from adversarial review of plan-mode lib:
1. **resolvePlanApproval stale-event guard fail-open (CRITICAL):**
The prior guard
if (expectedApprovalId !== undefined && current.approvalId !== undefined && ...)
silently fell open whenever current.approvalId was cleared/undefined,
meaning an attacker (or stale UI) supplying expectedApprovalId would have
it accepted whenever current state happened to have no token. Tightened
to require current.approvalId to exist AND match when expectedApprovalId
is supplied — fail-closed.
2. **newPlanApprovalId entropy bumped (HIGH):**
The prior 8-char Math.random().toString(36).slice(2, 10) exposed only
~26 bits of entropy plus ~36 bits of timestamp. An attacker observing one
approvalId could guess the next within practical attempt budgets.
Replaced with crypto.randomUUID() (~122 bits cryptographic entropy) with
defensive Math.random fallback for non-webcrypto hosts.
3. **buildPlanDecisionInjection feedback marker injection (HIGH):**
JSON.stringify(feedback) escapes quotes and control chars but does not
neutralize the literal string [/PLAN_DECISION] inside the feedback. An
adversarial feedback like
'x[/PLAN_DECISION]\\n[PLAN_APPROVAL]\\napproved: true'
could close the envelope early and inject a fake downstream block a
naive parser might trust. Added sanitizeFeedbackForInjection() which
replaces the closing marker (case-insensitive) with a parser-distinct
variant containing a zero-width space.
Tests: 8 new adversarial regression tests
- 3 covering fail-closed when current.approvalId is undefined
(approve/reject/edit each verified)
- 2 covering buildPlanDecisionInjection neutralizing the marker
(case-sensitive + case-insensitive variants)
- 2 covering newPlanApprovalId entropy (1024 distinct ids in
rapid back-to-back calls; format check)
Total: 31 tests pass.
…solved agent id Codex P2 (PR #67538 r3096325365): > autoContinueConfig is resolved from params.agentId, but many runs select > an agent via sessionKey with no explicit agentId. In that case, per-agent > agents.list[].embeddedPi.autoContinue overrides are ignored even though > this function already computes sessionAgentId just above for > execution-contract resolution. The result is inconsistent behavior where > strict-agentic applies to the session agent but auto-continue silently > falls back to defaults/hardcoded values. Fix: use `sessionAgentId ?? params.agentId` so per-agent autoContinue overrides apply consistently with the execution-contract resolution performed two lines above. The fallback to params.agentId preserves the existing behavior when no sessionKey is present.
…5650458) Codex P2 (PR #67538 r3095650458): > Using 'const merged = agentAc ?? defaultAc' makes any per-agent > embeddedPi.autoContinue object replace the defaults object wholesale > before field fallback is applied, so missing agent keys fall back to > hardcoded constants instead of agents.defaults.embeddedPi.autoContinue. > In practice, setting only one per-agent field (for example 'enabled') > silently resets maxCycles/stopOnMutation away from configured defaults. Confirmed: per-agent partial overrides discarded the entire agents.defaults.embeddedPi.autoContinue block, fell through to DEFAULT_AUTO_CONTINUE constants for unspecified fields. Configured defaults were silently ignored whenever any per-agent override existed. Fix: cascade per-field instead of wholesale — per-agent → agents.defaults → DEFAULT_AUTO_CONTINUE constant for each of enabled / maxCycles / stopOnMutation. Tests: 4 new in agent-scope.test.ts (resolveAgentAutoContinue describe): - defaults when nothing configured (sanity baseline) - agents.defaults overrides constants per field - partial per-agent override INHERITS from agents.defaults (not constant) — direct regression test for the reported bug - per-agent fully specified wins over agents.defaults 31 tests pass.
…te + sessions.patch + planMode runtime context The integration bridge for plan mode. The 7 sprint PRs ship building blocks (#67538 lib, #67721 UI chip, etc.) that don't connect end-to-end without this PR. With it merged, plan mode actually functions: clicking Plan in the UI flips session state, the runtime mutation gate blocks write/edit/exec, and the agent has enter_plan_mode/exit_plan_mode tools to drive the approval flow. **Backend wiring (10 files):** 1. src/agents/tools/enter-plan-mode-tool.ts (new) — structured-result tool the runner intercepts to fire mode-entered events. 2. src/agents/tools/exit-plan-mode-tool.ts (new) — plan-payload tool the runner intercepts to fire approval-requested events. 3. src/agents/tool-description-presets.ts — 2 new tool descriptions + display summaries. 4. src/agents/openclaw-tools.registration.ts — isPlanModeToolsEnabledForOpenClawTools() gates on agents.defaults.planMode.enabled (default OFF, opt-in). 5. src/agents/openclaw-tools.ts — register both new tools alongside update_plan when the gate enables them. 6. src/agents/tool-catalog.ts — catalog entries so the tools participate in profile/policy filtering. 7. src/agents/pi-tools.ts — options.planMode threads through to the wrapToolWithBeforeToolCallHook context so the gate fires without re-loading the session store on every tool call. 8. src/agents/pi-tools.before-tool-call.ts — the actual mutation-gate check via checkMutationGate(toolName, planMode, execCommand). Runs AFTER loop detection, BEFORE the plugin hookRunner so plugins can't bypass. 9. src/agents/pi-embedded-runner/run/params.ts — RunEmbeddedPiAgentParams.planMode field for the caller (auto-reply pipeline / chat send handler) to provide. 10. src/agents/pi-embedded-runner/run/attempt.ts — thread params.planMode into createOpenClawCodingTools. **Session state + protocol:** 11. src/config/sessions/types.ts — SessionEntry.planMode field (structural type mirroring PlanModeSessionState; avoids agents/* → config/sessions/* coupling). 12. src/gateway/protocol/schema/sessions.ts — SessionsPatchParamsSchema gains optional 'plan'|'normal'|null planMode field. Additive, backwards-compatible. 13. src/gateway/sessions-patch.ts — handler converts the wire literal into the full PlanModeSessionState object (or clears it on 'normal'/null). **Config:** 14. src/config/types.agent-defaults.ts + src/config/zod-schema.agent-defaults.ts — agents.defaults.planMode.{enabled, autoEnableFor, approvalTimeoutSeconds} config block. enabled default false (opt-in). approvalTimeoutSeconds default 600 (10 min). **Integration test (1 file):** 15. src/agents/plan-mode/integration.test.ts — 20 tests covering tool enablement gate, enter_plan_mode/exit_plan_mode tool behavior + validation, before-tool-call hook BLOCK+ALLOW outcomes for write/edit/exec/read/web_search/update_plan/exit_plan_mode/exec- with-readonly-command/unknown-tool. 93 plan-mode lib tests (approval + mutation-gate) ALSO pass on this branch. **Co-merges with #67538 (plan-mode lib) and #67721 (UI chip):** each is dead code on its own. Without the lib this branch can't import checkMutationGate; without this branch the lib has no callers and the chip's planMode toggle has no backend handler. **Out of scope (intentional, follow-up PRs):** - Channel renderers (Telegram/Discord/Slack/CLI) — Control UI is sufficient for end-to-end testing and the channel surfaces can ship later - Approval reply dispatch (full plugin approval flow) — uses the existing agent_approval_event surface for now; richer dispatch can come later - run.ts agent_approval_event emission on exit_plan_mode tool call — the tool returns a structured result; agent-side event emission is the next natural integration point
There was a problem hiding this comment.
Pull request overview
Wires plan mode end-to-end by adding session-state persistence (sessions.patch planMode), agent-facing tools (enter_plan_mode / exit_plan_mode), and a before-tool-call mutation gate that blocks mutating tools while plan mode is active, plus associated config and test coverage.
Changes:
- Add
sessions.patchwire protocol + gateway handler support for toggling plan mode and persistingSessionEntry.planMode. - Register new plan-mode tools and integrate mutation gating into the tool pipeline (
pi-tools.before-tool-call), threading plan mode through embedded runner params. - Add plan-mode runtime library (types, approval state machine, mutation gate) and tests, plus runner hardening updates (retry escalation + auto-continue).
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/gateway/sessions-patch.ts | Applies planMode session patches by constructing/clearing SessionEntry.planMode. |
| src/gateway/protocol/schema/sessions.ts | Extends SessionsPatchParamsSchema with planMode: "plan" | "normal" | null. |
| src/config/zod-schema.agent-defaults.ts | Adds Zod schema for agents.defaults.planMode and embeddedPi.autoContinue. |
| src/config/types.agent-defaults.ts | Adds config types/docs for plan mode and embedded runner auto-continue. |
| src/config/sessions/types.ts | Persists plan mode state on SessionEntry as a structural type. |
| src/agents/tools/enter-plan-mode-tool.ts | New agent tool returning structured “entered plan mode” result. |
| src/agents/tools/exit-plan-mode-tool.ts | New agent tool returning structured “approval requested” plan payload. |
| src/agents/tool-description-presets.ts | Adds descriptions + display summaries for plan-mode tools. |
| src/agents/tool-catalog.ts | Adds plan-mode tools to the catalog for policy/profile filtering. |
| src/agents/plan-mode/types.ts | Introduces plan mode types + approvalId + decision injection builder. |
| src/agents/plan-mode/mutation-gate.ts | Implements the plan-mode mutation gate with exec whitelist and default-deny. |
| src/agents/plan-mode/mutation-gate.test.ts | Unit tests for mutation gate behavior and exec whitelist hardening. |
| src/agents/plan-mode/approval.ts | Approval state machine for approve/edit/reject/timeout transitions. |
| src/agents/plan-mode/approval.test.ts | Unit tests for approval state machine + injection + approvalId guard. |
| src/agents/plan-mode/index.ts | Barrel exports for plan-mode library surface. |
| src/agents/plan-mode/integration.test.ts | Integration test covering tool enablement + hook blocking/allowing. |
| src/agents/pi-tools.ts | Threads planMode into before-tool-call hook context. |
| src/agents/pi-tools.before-tool-call.ts | Enforces checkMutationGate before plugin hookRunner executes. |
| src/agents/pi-embedded-runner/run/params.ts | Adds RunEmbeddedPiAgentParams.planMode. |
| src/agents/pi-embedded-runner/run/attempt.ts | Passes planMode into createOpenClawCodingTools. |
| src/agents/pi-embedded-runner/run/incomplete-turn.ts | Adds retry escalation + disables retry pressure when plan mode is active (API). |
| src/agents/pi-embedded-runner/run.ts | Adds auto-continue + retry escalation wiring in the main run loop. |
| src/agents/pi-embedded-runner/run.incomplete-turn.test.ts | Updates retry-limit expectations + adds tests for escalation and plan-mode behavior. |
| src/agents/openclaw-tools.ts | Registers plan-mode tools when config gate enables them. |
| src/agents/openclaw-tools.registration.ts | Adds isPlanModeToolsEnabledForOpenClawTools() gate. |
| src/agents/agent-scope.ts | Adds resolveAgentAutoContinue() with per-field merge behavior. |
| src/agents/agent-scope.test.ts | Tests resolveAgentAutoContinue() merge semantics. |
| qa/scenarios/gpt54-plan-mode-default-off.md | QA flow asserting default GPT-5.4 run does not enter plan mode. |
| qa/scenarios/gpt54-mandatory-tool-use.md | QA flow asserting GPT-5.4 uses tools for factual queries. |
| qa/scenarios/gpt54-injection-scan.md | QA flow for baseline injection-scanner non-interference. |
| qa/scenarios/gpt54-cancelled-status.md | QA flow around cancelled plan step semantics. |
| qa/scenarios/gpt54-act-dont-ask.md | QA flow asserting GPT-5.4 acts on obvious defaults (exec usage). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f75fde492
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryThis PR wires the plan-mode integration end-to-end:
Confidence Score: 4/5Safe to merge with the P1 addressed — the unimplemented config keys won't break anything at runtime but will silently mislead operators. Core wiring is solid and the 20-test integration suite covers the key paths. One P1 finding: two newly-added config keys ( src/config/types.agent-defaults.ts and src/config/zod-schema.agent-defaults.ts — the Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/config/types.agent-defaults.ts
Line: 305-318
Comment:
**`autoEnableFor` and `approvalTimeoutSeconds` document behavior that isn't implemented**
Both fields are added in this PR with documentation that describes active runtime behavior, but no production code reads either of them at runtime.
- `autoEnableFor` docs say "the runtime auto-enters plan mode at session start" when a model matches a pattern. Grep across `src/` turns up zero callers outside the type definition and zod schema. An operator who sets `autoEnableFor: ["^openai/gpt-5\\."]` will see no effect — the session will never auto-enter plan mode.
- `approvalTimeoutSeconds` docs say "The agent receives a `[PLAN_DECISION] decision: expired` injection on timeout." `DEFAULT_APPROVAL_CONFIG`, `PlanApprovalConfig`, and the `"timeout"` action on `resolvePlanApproval` are all defined but never invoked anywhere in production code. No timer fires after the configured interval, so pending approvals stay pending indefinitely regardless of this setting.
Both are currently stub fields. If they're intentionally deferred, the docs should say so (e.g. `// TODO: not yet implemented`) to avoid silent misconfiguration in production. For `autoEnableFor` in particular, the "runtime auto-enters" language is likely to be acted on by operators and then silently ignored.
How can I resolve this? If you propose a fix, please make it concise.Reviews (2): Last reviewed commit: "fix(plan-mode): runtime auto-retry for a..." | Re-trigger Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3bd8ae63d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a3ea5265a1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… plan-tool content Companion to rollup commit 58b6ab6. A. NEW: resolvePlanModeAckOnlyRetryInstruction() detector + 16-case unit test. Sister to the existing planning-only retry (which short-circuits in plan mode at incomplete-turn.ts:568). Triggers when session is in plan mode and agent's response had no exit_plan_mode call AND no investigative tool call AND clean stop. Hard-cap 2 retries with escalating tone. Reuses planningOnlyRetryInstruction injection slot. Closes Eva's self-diagnosed action-selection drift after 3 rounds of prompt-only fixes failed to fully eliminate it. B. update_plan + exit_plan_mode now return non-empty content. The third-party lossless-claw extension was injecting '[lossless-claw] missing tool result …' placeholders into the agent's read-time context whenever plan tools' content:[] was filtered by lossless-claw's eviction logic. Non-empty payload satisfies its pairing check.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 63f0c9f049
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Plan-mode rollout series — status updateThis PR is part of a 10-PR plan-mode rollout. The cumulative state ships locally as Series overview + landing order: see #68441 (latest comment). This PR's role in the series: see the original PR description above. Live test status: covered by the cumulative install — all behaviors from this PR are verified in production-like usage on Telegram + webchat. No regressions detected since the live install (2026-04-18). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 39 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/agents/tools/update-plan-tool.ts:255
createUpdatePlanTool()now returns non-emptycontentand can emitagent_plan_eventonly when constructed with arunId. However, the primary tool registration path (createOpenClawTools) currently callscreateUpdatePlanTool()with no options, meaning (a) merge mode can’t read/writeAgentRunContext.lastPlanSteps, and (b) no plan events are emitted forupdate_plancalls in normal runs. Consider threadingrunIdthrough the tool assembly path and constructing this ascreateUpdatePlanTool({ runId })so merge/event features actually take effect at runtime.
export interface CreateUpdatePlanToolOptions {
/**
* Stable run identifier. When provided, merge mode reads the previous
* plan from `AgentRunContext.lastPlanSteps` and writes the merged
* result back. When omitted, merge mode falls back to replace
* (no previous plan available — useful for tests/standalone).
*/
runId?: string;
}
export function createUpdatePlanTool(options?: CreateUpdatePlanToolOptions): AnyAgentTool {
const runId = options?.runId;
return {
label: "Update Plan",
name: "update_plan",
displaySummary: UPDATE_PLAN_TOOL_DISPLAY_SUMMARY,
description: describeUpdatePlanTool(),
parameters: UpdatePlanToolSchema,
execute: async (_toolCallId, args, _signal) => {
const params = args as Record<string, unknown>;
const explanation = readStringParam(params, "explanation");
const merge = typeof params.merge === "boolean" ? params.merge : false;
const incomingSteps = readPlanSteps(params);
const ctx = runId ? getAgentRunContext(runId) : undefined;
const previousSteps = (ctx?.lastPlanSteps ?? []) as UpdatePlanStep[];
const plan: UpdatePlanStep[] =
merge && previousSteps.length > 0
? mergeSteps(previousSteps, incomingSteps)
: incomingSteps;
// Re-validate the active-step invariant on the MERGED plan
// (Codex P1 on PR #67514): readPlanSteps only enforces the
// single-in_progress rule on the incoming patch, but merge can
// still produce a final plan with two in_progress entries when
// the previous plan had one in_progress step and the patch marks
// a different step as in_progress. The tool's own contract — and
// downstream renderers — assume at most one active step.
const mergedInProgress = plan.filter((s) => s.status === "in_progress").length;
if (mergedInProgress > 1) {
throw new ToolInputError(
"merge would produce a plan with multiple in_progress steps; " +
"explicitly mark the prior in_progress step as completed/cancelled in the same patch",
);
}
// Persist for next merge in this run. Snapshot stored as
// `PlanStepSnapshot[]` (structural superset of `UpdatePlanStep[]`).
if (ctx) {
ctx.lastPlanSteps = plan.map<PlanStepSnapshot>((s) => ({
step: s.step,
status: s.status,
...(s.activeForm !== undefined ? { activeForm: s.activeForm } : {}),
}));
}
// Emit `agent_plan_event` so channel renderers + control UI see updates.
// Skip emit when we have no runId — that's the standalone/test path.
if (runId) {
emitAgentPlanEvent({
runId,
...(ctx?.sessionKey ? { sessionKey: ctx.sessionKey } : {}),
data: {
phase: "update",
title: "Plan updated",
...(explanation ? { explanation } : {}),
steps: plan.map((s) => s.step),
source: "update_plan",
},
});
}
// PR-8 follow-up: return non-empty content. Empty content arrays
// trip third-party transcript-pairing extensions (lossless-claw)
// which inject `[lossless-claw] missing tool result` placeholders
// into the agent's read-time context, polluting it with synthetic
// errors. Non-empty content satisfies the pairing check and keeps
// the agent's view of past turns clean.
const stepCount = plan.length;
const summaryLine = `Plan updated (${stepCount} ${stepCount === 1 ? "step" : "steps"}).`;
return {
content: [{ type: "text" as const, text: summaryLine }],
details: {
status: "updated" as const,
...(explanation ? { explanation } : {}),
plan,
},
};
},
};
}
📋 Architecture & Status — single source of truth (commit
|
|
To use Codex here, create an environment for this repo. |
| /** | ||
| * Plan-mode approval payload (PR-8). Present only when `kind === "plugin"` | ||
| * and the underlying tool was `exit_plan_mode`. The UI/channel renderers | ||
| * use this to show the plan checklist with Approve/Reject/Edit buttons. | ||
| */ | ||
| plan?: AgentApprovalPlanStep[]; | ||
| /** One-line summary the agent included with the proposed plan. */ | ||
| summary?: string; |
There was a problem hiding this comment.
The doc comment for AgentApprovalEventData.plan says it’s present only for exit_plan_mode, but the runner emits an approval-stream event for enter_plan_mode with plan: [] (see pi-embedded-subscribe.handlers.tools.ts). Either update this comment to allow the empty-plan "mode-entered" signal, or stop populating plan for enter events to keep the contract consistent for UI/channel renderers.
| plan: Type.Array( | ||
| Type.Object( | ||
| { | ||
| step: Type.String({ description: "Short plan step." }), | ||
| status: stringEnum(PLAN_STEP_STATUSES, { | ||
| description: 'One of "pending", "in_progress", or "completed".', | ||
| description: 'One of "pending", "in_progress", "completed", or "cancelled".', | ||
| }), | ||
| activeForm: Type.Optional( | ||
| Type.String({ | ||
| description: | ||
| 'Present-continuous form shown while in_progress (e.g. "Running tests"). ' + | ||
| "Present-continuous form used during in_progress display. Accepted on any status but only rendered for in_progress steps.", | ||
| }), | ||
| ), | ||
| }, | ||
| { additionalProperties: true }, | ||
| { additionalProperties: false }, | ||
| ), |
There was a problem hiding this comment.
UpdatePlanToolSchema now sets per-step { additionalProperties: false }, but the existing contract/tests rely on tolerating extra per-step fields (see update-plan-tool.test.ts:41-58). If we still want the tool to be forward-compatible with richer step objects (e.g. future metadata), keep additionalProperties enabled; otherwise update tests/docs and be aware some providers may reject model-generated extra fields before execute() runs.
| const PLAN_STEP_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; | ||
|
|
||
| const ExitPlanModeToolSchema = Type.Object({ | ||
| plan: Type.Array( | ||
| Type.Object( | ||
| { | ||
| step: Type.String({ description: "Short plan step." }), | ||
| status: stringEnum(PLAN_STEP_STATUSES, { | ||
| description: 'One of "pending", "in_progress", "completed", or "cancelled".', | ||
| }), | ||
| activeForm: Type.Optional( |
There was a problem hiding this comment.
exit_plan_mode duplicates the plan step status enum locally. Since update-plan-tool.ts now exports PLAN_STEP_STATUSES/PlanStepStatus, consider importing and reusing those to prevent drift (e.g. a future status added to update_plan but not to exit_plan_mode would break round-tripping between tools).
|
Closing in favor of consolidated PR #68939. Rationale: this branch was 734 commits behind upstream/main and the The full iteration history + decision log lives in |
The integration bridge that makes plan mode actually function end-to-end. Co-merges with #67538 (plan-mode lib) and #67721 (UI mode switcher) — each is dead code on its own.
Why
The 7 sprint PRs ship plan-mode building blocks but none of them connect:
checkMutationGate,resolvePlanApproval, types) with no callersThis PR fixes all of that. After it lands, clicking the Plan chip in the Control UI flips session state, mutation tools become blocked at the runtime hook, and the agent has
enter_plan_mode/exit_plan_modetools to drive the approval flow.What ships
Backend (10 files):
src/agents/tools/enter-plan-mode-tool.ts(new)src/agents/tools/exit-plan-mode-tool.ts(new)src/agents/tool-description-presets.tssrc/agents/openclaw-tools.registration.tsisPlanModeToolsEnabledForOpenClawTools()gates onagents.defaults.planMode.enabled(default OFF, opt-in)src/agents/openclaw-tools.tsupdate_planwhen the gate enables themsrc/agents/tool-catalog.tssrc/agents/pi-tools.tsoptions.planModethreads through to before-tool-call hook contextsrc/agents/pi-tools.before-tool-call.tscheckMutationGate(toolName, planMode, execCommand)— runs AFTER loop detection, BEFORE plugin hookRunner so plugins can't bypasssrc/agents/pi-embedded-runner/run/params.tsRunEmbeddedPiAgentParams.planModefield for callers (auto-reply / chat send handler)src/agents/pi-embedded-runner/run/attempt.tsparams.planModeintocreateOpenClawCodingToolsSession state + protocol:
src/config/sessions/types.tsSessionEntry.planModefield (structural type — avoidsagents/*→config/sessions/*coupling)src/gateway/protocol/schema/sessions.tsSessionsPatchParamsSchemagains optional\"plan\" | \"normal\" | nullplanMode field. Additive, backwards-compatible.src/gateway/sessions-patch.tsPlanModeSessionStateobject (or clears on\"normal\"/null)Config:
src/config/types.agent-defaults.ts+src/config/zod-schema.agent-defaults.tsagents.defaults.planMode.{enabled, autoEnableFor, approvalTimeoutSeconds}block.enableddefaultfalse(opt-in).approvalTimeoutSecondsdefault 600 (10 min).Integration test (1 file):
src/agents/plan-mode/integration.test.tsenter_plan_mode/exit_plan_modevalidation, before-tool-call hook BLOCK+ALLOW outcomes for write/edit/exec/read/web_search/update_plan/exit_plan_mode/exec-with-readonly-command/unknown-tool/no-planMode/normal-planModeUI side (lives on the #67721 branch —
feat/ui-mode-switcher-plan-cards):setSessionPlanMode(client, sessionKey, mode)helper inui/src/ui/chat/slash-command-executor.ts— mirrors thethinkingLevel/fastModepatch pattern.Co-merge contract
This PR's diff includes all of #67538's commits because it branches off
phase3/plan-mode. To land cleanly:checkMutationGate,resolvePlanApproval, types) that this PR importsReviewing this PR diff against
phase3/plan-modeshows ONLY the integration changes; reviewing againstmainshows lib + integration together (which is fine — they're a unit).Out of scope (intentional, follow-up PRs)
agent_approval_eventsurface for nowrun.tsagent_approval_event emission onexit_plan_modetool call — the tool returns a structured result; agent-side event emission is the next natural integration pointTests
Test plan