diff --git a/SCIENT.md b/SCIENT.md index b6dbf8055c..b1566eef6e 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -25,6 +25,7 @@ Everything else is upstream's code and behavior. | Next message | `predict_next_message` gives an RPC host the prediction of the user's next message that OMP's terminal composer shows after a turn: OMP's prompt and parsing, on the session's model and context, without changing its history. It runs outside the command queue; a newer request replaces one in flight, and a turn or compaction that starts cancels it. | | Usage-limit reset | A `prompt_result` for a turn stopped by a usage limit carries `error.resetAt`, the time the provider said the limit resets (epoch ms): from its retry hint, the account's usage report, or an earlier stated block, whichever is latest. It is left out when the provider gave no time; the 30-minute guess for a hintless error is never reported. A host can resume the turn at that time. | | Prompt cache | An assistant reply's `message_end` carries `promptCache` (`tier`, `touchedAt`, `ttlMs`): the prompt-cache entry its response confirmed, while the next request would still read it. It is left out for a cache that never expires and for an entry the agent only inferred. A host can compact an idle conversation just before a one-hour entry expires, so the summary reads a warm cache. The agent's own idle compaction stays a terminal feature and never runs over RPC. | +| Plan mode | Oh My Pi plans only in its terminal and for ACP clients. Over RPC, the `plan` command (`enter`, `exit`, `get`) turns plan mode on: the agent reads and drafts a plan file but changes nothing else. When it proposes the plan, a `plan_proposed` frame carries the plan, and a select request asks the host to approve it while the run waits. Only an explicit approval leaves plan mode, and the same run then carries out the plan. Refine asks the host what to change, and that answer, like any typed answer, reaches the model as feedback; planning goes on. An answer that comes after plan mode ended never approves, and a new, switched or forked conversation starts outside plan mode. Each time plan mode turns on or off, a `plan_updated` frame tells the host. Planning is refused while a goal is set. | | Failure kinds | A `prompt_result` for a failed turn carries `error.kinds`, the error classifier's labels for that failure (`auth-failed`, `context-overflow`, `transient`, `usage-limit`, ...), so a host can report why turns fail. It is left out when the classifier recognized no kind; the labels carry no provider text. | | macOS Accessibility prompt | The agent sends no Accessibility message while macOS doesn't trust it, so a computer call that refuses for lack of a permission never makes macOS ask. Only the host asks for permissions. | | Live voice delegation | `live_connect` with `hostDelegation` sends each request the voice model delegates to the host as a `live_delegation` frame instead of starting a turn, so a host that owns its turns (Scient Desktop) sends it as the user's message. The live session still reads that turn's answer. A host that runs the turn elsewhere, such as in the conversation's own agent while the call runs in a separate one, reports its progress and answer with `live_report`. Without the flag nothing changes. | diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1a45c46654..457e76f221 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -90,6 +90,9 @@ import { RpcNextMessagePredictor } from "../../scient/next-message"; // scient(SA-PROMPT-CACHE): begin - A host compacts an idle conversation before its prompt cache expires. import { withPromptCache } from "../../scient/prompt-cache"; // scient(SA-PROMPT-CACHE): end +// scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. +import { RpcPlanController } from "../../scient/rpc-plan"; +// scient(SA-PLAN-MODE): end import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages"; import { RpcBtwController } from "./rpc-btw"; import { RpcGoalController } from "./rpc-goal"; @@ -1754,6 +1757,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions // A single shared instance routes all responses received on stdin to the // correct waiting promise regardless of which code path created the request. const rpcUiContext = new RpcExtensionUIContext(pendingExtensionRequests, output); + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + const planController = new RpcPlanController(session, rpcUiContext, output); + // scient(SA-PLAN-MODE): end setToolUIContext?.(rpcUiContext, true); const setAskDialog = (command: RpcCommand & { type: "set_ask_dialog" }): RpcResponse => { rpcUiContext.askDialogEnabled = command.enabled === true; @@ -1820,6 +1826,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions // close the prompts it was answering. Branch and navigation leave a live // run streaming to its normal yield. if (detachesRun) promptResults.abortOpen(); + // scient(SA-PLAN-MODE): begin - A plan never carries into another conversation. + if (detachesRun) await planController.handle({ op: "exit" }); + // scient(SA-PLAN-MODE): end void settleWatcher.check(); } } @@ -2200,6 +2209,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions inputGate.commitSessionChange(command); // `branch` leaves a live run streaming to its normal yield; new/switch detach it. if (command.type !== "branch" && command.type !== "fork") promptResults.abortOpen(); + // scient(SA-PLAN-MODE): begin - A plan never carries into another conversation. + if (command.type !== "branch") await planController.handle({ op: "exit" }); + // scient(SA-PLAN-MODE): end // The detached run publishes no terminal agent_end to settle on. void settleWatcher.check(); await emitAvailableCommandsUpdate(); @@ -2976,6 +2988,15 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions case "predict_next_message": return success(id, "predict_next_message", { text: await nextMessagePredictor.predict() }); // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + + case "plan": + try { + return success(id, "plan", await planController.handle(command)); + } catch (planError) { + return error(id, "plan", planError instanceof Error ? planError.message : String(planError)); + } + // scient(SA-PLAN-MODE): end default: { const unknownCommand = command as { type: string }; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index d09bf3e738..659e38cc5d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -33,6 +33,9 @@ import type { LivePhase } from "@oh-my-pi/pi-tui/apps/live-visualizer"; import type { RpcMessagesPage } from "./rpc-messages"; import type { GoalModeState } from "../../goals/state"; import type { RpcGoalOp, RpcGoalResult } from "./rpc-goal"; +// scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. +import type { RpcPlanOp, RpcPlanResult } from "../../scient/rpc-plan"; +// scient(SA-PLAN-MODE): end import type { BtwHistoryRecord } from "../../session/btw-history"; // scient(SA-AUTH): begin - A host sees and signs out stored sign-ins. import type { RpcLoginProvider } from "../../scient/sign-in-providers"; @@ -177,6 +180,9 @@ export type RpcCommand = // scient(SA-NEXT-MESSAGE): begin - A host shows the agent's prediction of the user's next message. | { id?: string; type: "predict_next_message" } // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + | { id?: string; type: "plan"; op: RpcPlanOp } + // scient(SA-PLAN-MODE): end | { id?: string; type: "predict_word_feedback"; @@ -352,6 +358,9 @@ export type RpcLiveFrame = | RpcLiveEndFrame | RpcLiveDelegationFrame; // scient(SA-LIVE-HOST-DELEGATION): end +// scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. +export type { RpcPlanProposedFrame, RpcPlanUpdatedFrame } from "../../scient/rpc-plan"; +// scient(SA-PLAN-MODE): end /** Draft-only dictation frames bypass the session event filter. */ export interface RpcDictationStateFrame { @@ -719,6 +728,9 @@ export type RpcResponse = // scient(SA-NEXT-MESSAGE): begin - A host shows the agent's prediction of the user's next message. | { id?: string; type: "response"; command: "predict_next_message"; success: true; data: { text: string | null } } // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + | { id?: string; type: "response"; command: "plan"; success: true; data: RpcPlanResult } + // scient(SA-PLAN-MODE): end // Side questions (/btw) | { id?: string; type: "response"; command: "btw"; success: true; data: { record: RpcBtwHistoryRecord } } diff --git a/packages/coding-agent/src/modes/rpc/wire/commands.ts b/packages/coding-agent/src/modes/rpc/wire/commands.ts index e67e811d7f..d34288a564 100644 --- a/packages/coding-agent/src/modes/rpc/wire/commands.ts +++ b/packages/coding-agent/src/modes/rpc/wire/commands.ts @@ -452,4 +452,12 @@ export const rpcCommands: readonly RpcCommandSpec[] = [ timeoutMs: 30_000, }, // scient(SA-NEXT-MESSAGE): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + { + name: "plan", + doc: "Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request).", + params: { op: "PlanOp" }, + result: "PlanResult", + }, + // scient(SA-PLAN-MODE): end ]; diff --git a/packages/coding-agent/src/modes/rpc/wire/frames.ts b/packages/coding-agent/src/modes/rpc/wire/frames.ts index a9e158517b..67484451d5 100644 --- a/packages/coding-agent/src/modes/rpc/wire/frames.ts +++ b/packages/coding-agent/src/modes/rpc/wire/frames.ts @@ -132,6 +132,16 @@ export const frameDefs = { "A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`.", ), // scient(SA-LIVE-HOST-DELEGATION): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + PlanProposedEvent: doc( + { type: "'plan_proposed'", title: "string", planFilePath: "string", markdown: "string" }, + "The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.", + ), + PlanUpdatedEvent: doc( + { type: "'plan_updated'", enabled: "boolean", "planFilePath?": "string" }, + "Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation.", + ), + // scient(SA-PLAN-MODE): end DictationStateEvent: doc( { type: "'dictation_state'", captureId: "string", state: "'starting' | 'recording' | 'transcribing'" }, "State of one draft-only microphone capture.", diff --git a/packages/coding-agent/src/modes/rpc/wire/index.ts b/packages/coding-agent/src/modes/rpc/wire/index.ts index 1dbf7d8955..b2457e7d6a 100644 --- a/packages/coding-agent/src/modes/rpc/wire/index.ts +++ b/packages/coding-agent/src/modes/rpc/wire/index.ts @@ -78,6 +78,10 @@ const notificationDefs = { // scient(SA-LIVE-HOST-DELEGATION): begin - A host that owns its turns sends spoken requests itself. "LiveDelegationEvent", // scient(SA-LIVE-HOST-DELEGATION): end + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + "PlanProposedEvent", + "PlanUpdatedEvent", + // scient(SA-PLAN-MODE): end "DictationStateEvent", "DictationTranscriptEvent", "DictationEndEvent", diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts index 3b91dd8665..7fc9b822d8 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts @@ -392,6 +392,15 @@ export type MessageUpdates = "full" | "delta"; export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked"; +export type PlanOp = "get" | "enter" | "exit"; + +export interface PlanResult { + /** Plan mode is on: the agent plans and changes nothing until a plan is approved. */ + enabled: boolean; + /** The plan file the agent drafts, while plan mode is on. */ + planFilePath?: string; +} + export type GoalOp = "get" | "create" | "resume" | "pause" | "drop"; export type GoalStatus = "active" | "paused" | "budget-limited" | "complete" | "dropped"; @@ -1223,6 +1232,21 @@ export interface LiveDelegationEvent { text: string; } +/** The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. */ +export interface PlanProposedEvent { + type: "plan_proposed"; + title: string; + planFilePath: string; + markdown: string; +} + +/** Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. */ +export interface PlanUpdatedEvent { + type: "plan_updated"; + enabled: boolean; + planFilePath?: string; +} + /** State of one draft-only microphone capture. */ export interface DictationStateEvent { type: "dictation_state"; @@ -1580,7 +1604,7 @@ export interface HostUriSchemeDefinition { } /** Unsolicited outbound frame (everything except responses and host tool/URI requests), discriminated by `type`. */ -export type RpcNotification = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent; +export type RpcNotification = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | PlanUpdatedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent; /** Any frame the server writes to stdout (after reassembling `rpc_chunk` sequences), discriminated by `type`. */ export type RpcServerFrame = RpcResponse | RpcHostRequest | RpcNotification; @@ -1980,6 +2004,10 @@ export interface PredictNextMessageResult { text: string | null; } +export interface PlanParams { + op: PlanOp; +} + /** Every RPC command's parameters and successful response `data`. */ export interface RpcWireCommands { negotiate_protocol: { params: NegotiateProtocolParams; result: NegotiateProtocolResult }; @@ -2059,4 +2087,5 @@ export interface RpcWireCommands { btw_cancel: { params: BtwCancelParams; result: BtwCancelResult }; get_btw_history: { params: undefined; result: GetBtwHistoryResult }; predict_next_message: { params: undefined; result: PredictNextMessageResult }; + plan: { params: PlanParams; result: PlanResult }; } diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json index 8a5b353de9..cd07cfd8ba 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json @@ -857,6 +857,17 @@ "timeoutMs": 30000, "completion": null, "clientOmit": [] + }, + { + "name": "plan", + "doc": "Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request).", + "params": "PlanParams", + "result": "PlanResult", + "nullable": false, + "unwrap": null, + "timeoutMs": null, + "completion": null, + "clientOmit": [] } ] }, @@ -2334,6 +2345,30 @@ ], "type": "string" }, + "PlanOp": { + "enum": [ + "get", + "enter", + "exit" + ], + "type": "string" + }, + "PlanResult": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Plan mode is on: the agent plans and changes nothing until a plan is approved." + }, + "planFilePath": { + "type": "string", + "description": "The plan file the agent drafts, while plan mode is on." + } + }, + "required": [ + "enabled" + ] + }, "GoalOp": { "enum": [ "get", @@ -5047,6 +5082,49 @@ ], "description": "A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`." }, + "PlanProposedEvent": { + "type": "object", + "properties": { + "type": { + "const": "plan_proposed" + }, + "title": { + "type": "string" + }, + "planFilePath": { + "type": "string" + }, + "markdown": { + "type": "string" + } + }, + "required": [ + "type", + "title", + "planFilePath", + "markdown" + ], + "description": "The plan the agent proposed in plan mode, sent just before the select request that asks to approve it." + }, + "PlanUpdatedEvent": { + "type": "object", + "properties": { + "type": { + "const": "plan_updated" + }, + "enabled": { + "type": "boolean" + }, + "planFilePath": { + "type": "string" + } + }, + "required": [ + "type", + "enabled" + ], + "description": "Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation." + }, "DictationStateEvent": { "type": "object", "properties": { @@ -6258,6 +6336,12 @@ { "$ref": "#/$defs/LiveDelegationEvent" }, + { + "$ref": "#/$defs/PlanProposedEvent" + }, + { + "$ref": "#/$defs/PlanUpdatedEvent" + }, { "$ref": "#/$defs/DictationStateEvent" }, @@ -7469,6 +7553,17 @@ "required": [ "text" ] + }, + "PlanParams": { + "type": "object", + "properties": { + "op": { + "$ref": "#/$defs/PlanOp" + } + }, + "required": [ + "op" + ] } } } diff --git a/packages/coding-agent/src/modes/rpc/wire/state.ts b/packages/coding-agent/src/modes/rpc/wire/state.ts index d42fecc8b3..b4f934d64d 100644 --- a/packages/coding-agent/src/modes/rpc/wire/state.ts +++ b/packages/coding-agent/src/modes/rpc/wire/state.ts @@ -20,6 +20,13 @@ export const stateDefs = { "`set_event_filter` projection: `delta` replaces the accumulated snapshots with the block a start/end event touches.", ), TodoStatus: "'pending' | 'in_progress' | 'completed' | 'abandoned' | 'blocked'", + // scient(SA-PLAN-MODE): begin - A host plans with the agent before it acts, and approves the plan. + PlanOp: "'get' | 'enter' | 'exit'", + PlanResult: { + enabled: doc("boolean", "Plan mode is on: the agent plans and changes nothing until a plan is approved."), + "planFilePath?": doc("string", "The plan file the agent drafts, while plan mode is on."), + }, + // scient(SA-PLAN-MODE): end GoalOp: "'get' | 'create' | 'resume' | 'pause' | 'drop'", GoalStatus: "'active' | 'paused' | 'budget-limited' | 'complete' | 'dropped'", SlashCommandSource: "'builtin' | 'skill' | 'extension' | 'custom' | 'mcp_prompt' | 'file'", diff --git a/packages/coding-agent/src/scient/rpc-plan.ts b/packages/coding-agent/src/scient/rpc-plan.ts new file mode 100644 index 0000000000..98de1406af --- /dev/null +++ b/packages/coding-agent/src/scient/rpc-plan.ts @@ -0,0 +1,209 @@ +/** + * Plan mode for RPC hosts. Oh My Pi offers plan mode only in its terminal and to + * ACP clients; this controller gives an RPC host the same flow: + * + * - `plan enter` turns plan mode on: the agent may read and draft a plan file, + * but changes nothing else, until it proposes the plan by writing its title to + * `xd://propose`. + * - The proposal sends the plan to the host (`plan_proposed`), then asks Approve + * or Refine on the host's select card while the tool call waits. Approve leaves + * plan mode and the same run goes on to carry out the plan; Refine asks the + * host what to change and gives the answer to the model as feedback, and any + * other answer is read as feedback too. Both keep planning. A cancelled or + * abandoned question never approves: only an explicit Approve does. + * - `plan exit` leaves plan mode without a plan. + * - Every time plan mode turns on or off, for any reason, the host gets `plan_updated`. + */ +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; +import { logger } from "@oh-my-pi/pi-utils"; +import type { ExtensionUIContext } from "../extensibility/extensions"; +import type { LocalProtocolOptions } from "../internal-urls"; +import { normalizePlanTitle, type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan"; +import { autosaveApprovedPlan } from "../plan-mode/plan-autosave"; +import { listPlanFiles, readPlanFile } from "../plan-mode/plan-files"; +import { cfgPlanEnabled } from "../plan-mode/settings"; +import type { AgentSession } from "../session/agent-session"; + +export const PLAN_APPROVE_OPTION = "Approve and carry out"; +export const PLAN_REFINE_OPTION = "Refine plan"; +const DEFAULT_PLAN_FILE_URL = "local://PLAN.md"; + +export type RpcPlanOp = "get" | "enter" | "exit"; + +export interface RpcPlanCommand { + op: RpcPlanOp; +} + +export interface RpcPlanResult { + /** Plan mode is on: the agent plans and changes nothing until a plan is approved. */ + enabled: boolean; + /** The plan file the agent drafts, while plan mode is on. */ + planFilePath?: string; +} + +/** Plan mode turned on or off: by the host, by an approval, or by a change of conversation. */ +export interface RpcPlanUpdatedFrame extends RpcPlanResult { + type: "plan_updated"; +} + +/** The plan the agent proposed, sent before the host is asked to approve it. */ +export interface RpcPlanProposedFrame { + type: "plan_proposed"; + title: string; + planFilePath: string; + markdown: string; +} + +export type RpcPlanSession = Pick< + AgentSession, + | "settings" + | "sessionManager" + | "getPlanModeState" + | "setPlanModeState" + | "setPlanProposalHandler" + | "setPlanReferencePath" + | "sendPlanModeContext" + | "getGoalModeState" + | "isStreaming" +>; + +export class RpcPlanController { + readonly #session: RpcPlanSession; + readonly #ui: Pick; + readonly #output: (frame: RpcPlanProposedFrame | RpcPlanUpdatedFrame) => void; + /** Bumped when plan mode is left, so an approval answered afterwards changes nothing. */ + #generation = 0; + + constructor( + session: RpcPlanSession, + ui: Pick, + output: (frame: RpcPlanProposedFrame | RpcPlanUpdatedFrame) => void, + ) { + this.#session = session; + this.#ui = ui; + this.#output = output; + } + + get #state(): RpcPlanResult { + const state = this.#session.getPlanModeState(); + return state?.enabled ? { enabled: true, planFilePath: state.planFilePath } : { enabled: false }; + } + + async handle(command: RpcPlanCommand): Promise { + switch (command.op) { + case "get": + return this.#state; + case "enter": + await this.#enter(); + return this.#state; + case "exit": + this.#exit(); + return this.#state; + default: { + const op: never = command.op; + throw new Error(`Unknown plan op: ${String(op)}`); + } + } + } + + async #enter(): Promise { + const session = this.#session; + if (session.getPlanModeState()?.enabled) return; + if (!cfgPlanEnabled.get(session.settings)) throw new Error("Plan mode is disabled (plan.enabled)."); + if (session.getGoalModeState()) throw new Error("Drop or finish the goal before planning."); + const previous = session.getPlanModeState(); + session.setPlanModeState({ + enabled: true, + planFilePath: previous?.planFilePath ?? DEFAULT_PLAN_FILE_URL, + workflow: previous?.workflow ?? "parallel", + reentry: previous !== undefined, + }); + const generation = this.#generation; + session.setPlanProposalHandler(title => this.#propose(title, generation)); + this.#output({ type: "plan_updated", ...this.#state }); + if (session.isStreaming) await session.sendPlanModeContext({ deliverAs: "steer" }); + } + + #exit(): void { + this.#generation++; + const wasEnabled = this.#session.getPlanModeState()?.enabled === true; + this.#session.setPlanProposalHandler(null); + this.#session.setPlanModeState(undefined); + if (wasEnabled) this.#output({ type: "plan_updated", enabled: false }); + } + + #localProtocolOptions(): LocalProtocolOptions { + const manager = this.#session.sessionManager; + return { getArtifactsDir: () => manager.getArtifactsDir(), getSessionId: () => manager.getSessionId() }; + } + + async #propose(title: string, generation: number): Promise> { + const session = this.#session; + const state = session.getPlanModeState(); + if (!state?.enabled || generation !== this.#generation) throw new ToolError("Plan mode is not active."); + const localProtocolOptions = this.#localProtocolOptions(); + const cwd = session.sessionManager.getCwd(); + const plan = await resolveApprovedPlan({ + suppliedTitle: title, + statePlanFilePath: state.planFilePath, + readPlan: url => readPlanFile(url, { localProtocolOptions, cwd }), + listPlanFiles: () => listPlanFiles({ localProtocolOptions }), + }); + const details: PlanApprovalDetails = { planFilePath: plan.planFilePath, title: plan.title, planExists: true }; + this.#output({ + type: "plan_proposed", + title: plan.title, + planFilePath: plan.planFilePath, + markdown: plan.planContent, + }); + let answer = ( + await this.#ui.select(`Carry out the plan "${plan.title}"?`, [PLAN_APPROVE_OPTION, PLAN_REFINE_OPTION]) + )?.trim(); + if (generation !== this.#generation) throw new ToolError("Plan mode ended before the plan was approved."); + // Refine asks what to change, so the model revises in the same run instead of proposing again. + if (answer === PLAN_REFINE_OPTION) { + answer = (await this.#ui.input("What should change in the plan?", "Describe the change"))?.trim() || answer; + if (generation !== this.#generation) throw new ToolError("Plan mode ended before the plan was approved."); + } + if (answer !== PLAN_APPROVE_OPTION) { + // The next planning turn targets the plan just reviewed. + if (state.planFilePath !== plan.planFilePath) + session.setPlanModeState({ ...state, planFilePath: plan.planFilePath }); + const title = normalizePlanTitle(plan.title).title; + // Without feedback, the user says what to change in their next message. + const text = + answer && answer !== PLAN_REFINE_OPTION + ? `The plan was not approved. The user asked: ${answer} Update the plan file, then write ${title} to xd://propose again when it is ready.` + : `The plan was not approved. Stop here and ask the user what to change; plan mode stays on. When they answer, update the plan file and write ${title} to xd://propose again.`; + return { + content: [{ type: "text", text }], + details, + }; + } + session.setPlanReferencePath(plan.planFilePath); + this.#exit(); + session.sessionManager.appendModeChange("none", { approvedPlanFilePath: plan.planFilePath }); + const autosaved = await autosaveApprovedPlan({ + settings: session.settings, + cwd, + title: plan.title, + planContent: plan.planContent, + }).then( + () => true, + error => { + logger.warn("Failed to autosave approved plan", { error: String(error) }); + return false; + }, + ); + return { + content: [ + { + type: "text", + text: `Plan approved at ${plan.planFilePath}. Plan mode exited; proceed with the implementation.${autosaved ? "" : " (Plan autosave failed; continuing.)"}`, + }, + ], + details, + }; + } +} diff --git a/packages/coding-agent/test/scient/rpc-plan.test.ts b/packages/coding-agent/test/scient/rpc-plan.test.ts new file mode 100644 index 0000000000..276fb0f751 --- /dev/null +++ b/packages/coding-agent/test/scient/rpc-plan.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resolvePlanFilePath } from "@oh-my-pi/pi-coding-agent/plan-mode/plan-files"; +import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; +import { + PLAN_APPROVE_OPTION, + PLAN_REFINE_OPTION, + RpcPlanController, + type RpcPlanProposedFrame, + type RpcPlanUpdatedFrame, + type RpcPlanSession, +} from "@oh-my-pi/pi-coding-agent/scient/rpc-plan"; +import type { PlanProposalHandler } from "@oh-my-pi/pi-coding-agent/tools/resolve"; +import { readLines, removeWithRetries, TempDir } from "@oh-my-pi/pi-utils"; + +describe("RPC plan mode", () => { + let tempDir: TempDir; + let planState: PlanModeState | undefined; + let handler: PlanProposalHandler | undefined; + let referencePath: string | undefined; + let modeChanges: Array<{ mode: string; data?: Record }>; + let goalActive: boolean; + let frames: Array; + let answers: Array>; + let sessionId: string; + + async function controller(): Promise { + const settings = await Settings.loadIsolated({ agentDir: tempDir.path(), cwd: tempDir.path() }); + const session = { + settings, + sessionManager: { + getCwd: () => tempDir.path(), + getArtifactsDir: () => path.join(tempDir.path(), "artifacts"), + getSessionId: () => sessionId, + appendModeChange: (mode: string, data?: Record) => { + modeChanges.push({ mode, data }); + }, + }, + getPlanModeState: () => planState, + setPlanModeState: (state: PlanModeState | undefined) => { + planState = state; + }, + setPlanProposalHandler: (next: PlanProposalHandler | null) => { + handler = next ?? undefined; + }, + setPlanReferencePath: (next: string) => { + referencePath = next; + }, + sendPlanModeContext: async () => {}, + getGoalModeState: () => (goalActive ? { enabled: true } : undefined), + isStreaming: false, + } as unknown as RpcPlanSession; + return new RpcPlanController( + session, + { + select: () => { + const answer = Promise.withResolvers(); + answers.push(answer); + return answer.promise; + }, + input: () => { + const answer = Promise.withResolvers(); + answers.push(answer); + return answer.promise; + }, + }, + frame => frames.push(frame), + ); + } + + /** Enters plan mode and drafts the plan file the agent would write. */ + async function draft(plans: RpcPlanController, markdown = "# Fit the model\n\n1. Load data\n"): Promise { + expect(await plans.handle({ op: "enter" })).toEqual({ enabled: true, planFilePath: "local://PLAN.md" }); + const localProtocolOptions = { + getArtifactsDir: () => path.join(tempDir.path(), "artifacts"), + getSessionId: () => sessionId, + }; + await Bun.write(resolvePlanFilePath("local://PLAN.md", { localProtocolOptions, cwd: tempDir.path() }), markdown); + } + + /** The agent proposes; resolves once the host has been asked, with the pending tool result. */ + async function propose(): Promise<{ result: Promise }> { + if (!handler) throw new Error("plan mode installed no proposal handler"); + const asked = answers.length; + const result = handler("PLAN"); + while (answers.length === asked) await Promise.race([result, Bun.sleep(1)]); + return { result }; + } + + const resultText = (result: unknown) => (result as { content: Array<{ text: string }> }).content[0]!.text; + + beforeEach(() => { + tempDir = TempDir.createSync("@scient-rpc-plan-"); + planState = undefined; + handler = undefined; + referencePath = undefined; + modeChanges = []; + goalActive = false; + frames = []; + answers = []; + sessionId = `scient-rpc-plan-${crypto.randomUUID()}`; + }); + + afterEach(async () => { + try { + await tempDir.remove(); + } catch {} + }); + + it("shows the proposed plan, and an approval leaves plan mode so the run carries it out", async () => { + const plans = await controller(); + await draft(plans); + const { result } = await propose(); + expect(frames).toMatchObject([ + { type: "plan_updated", enabled: true, planFilePath: "local://PLAN.md" }, + { type: "plan_proposed", markdown: "# Fit the model\n\n1. Load data\n" }, + ]); + const planFilePath = frames[1]!.planFilePath; + answers[0]!.resolve(PLAN_APPROVE_OPTION); + + expect(resultText(await result)).toStartWith(`Plan approved at ${planFilePath}.`); + // The host learns plan mode ended without asking. + expect(frames.at(-1)).toEqual({ type: "plan_updated", enabled: false }); + expect(await plans.handle({ op: "get" })).toEqual({ enabled: false }); + expect(handler).toBeUndefined(); + expect(referencePath).toBe(planFilePath); + expect(modeChanges).toEqual([{ mode: "none", data: { approvedPlanFilePath: planFilePath } }]); + }); + + it("keeps planning on Refine, a typed answer, or a cancelled question", async () => { + const plans = await controller(); + await draft(plans); + for (const [answer, expected] of [ + ["Use a mixed model instead", "The user asked: Use a mixed model instead"], + [undefined, "Stop here and ask the user what to change"], + ] as const) { + const { result } = await propose(); + answers.at(-1)!.resolve(answer); + expect(resultText(await result)).toContain(expected); + expect((await plans.handle({ op: "get" })).enabled).toBe(true); + } + expect(referencePath).toBeUndefined(); + }); + + it("asks what to change on Refine and gives the answer to the model", async () => { + const plans = await controller(); + await draft(plans); + for (const [change, expected] of [ + ["Add a sensitivity analysis", "The user asked: Add a sensitivity analysis"], + [undefined, "Stop here and ask the user what to change"], + ] as const) { + const { result } = await propose(); + const asked = answers.length; + answers.at(-1)!.resolve(PLAN_REFINE_OPTION); + while (answers.length === asked) await Promise.race([result, Bun.sleep(1)]); + answers.at(-1)!.resolve(change); + expect(resultText(await result)).toContain(expected); + expect((await plans.handle({ op: "get" })).enabled).toBe(true); + } + }); + + it("never approves an answer that arrives after plan mode was left", async () => { + const plans = await controller(); + await draft(plans); + const { result } = await propose(); + await plans.handle({ op: "exit" }); + answers[0]!.resolve(PLAN_APPROVE_OPTION); + + await expect(result).rejects.toThrow("Plan mode ended before the plan was approved."); + expect(referencePath).toBeUndefined(); + expect(modeChanges).toEqual([]); + }); + + it("refuses to plan while a goal is set", async () => { + goalActive = true; + const plans = await controller(); + await expect(plans.handle({ op: "enter" })).rejects.toThrow("Drop or finish the goal before planning."); + expect(planState).toBeUndefined(); + }); +}); + +describe("RPC plan mode across sessions", () => { + it("leaves plan mode when the host starts another conversation, so an old approval cannot touch it", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "scient-rpc-plan-")); + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "..", "fixtures", "live-rpc-agent.ts")], { + cwd: dir, + env: { ...process.env, SCIENT_AGENT_DIR: dir, PI_NO_TITLE: "1" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stderr = new Response(child.stderr).text(); + const lines = readLines(child.stdout, AbortSignal.timeout(30_000)); + const decoder = new TextDecoder(); + const receive = async () => { + const line = await lines.next(); + if (line.done) throw new Error(await stderr); + return JSON.parse(decoder.decode(line.value)) as Record; + }; + const command = async (id: string, frame: object) => { + child.stdin.write(`${JSON.stringify({ ...frame, id })}\n`); + await child.stdin.flush(); + for (;;) { + const reply = await receive(); + if (reply.type === "response" && reply.id === id) return reply; + } + }; + try { + while ((await receive()).type !== "ready") {} + expect(await command("enter", { type: "plan", op: "enter" })).toMatchObject({ data: { enabled: true } }); + expect(await command("new", { type: "new_session" })).toMatchObject({ success: true }); + expect(await command("get", { type: "plan", op: "get" })).toMatchObject({ data: { enabled: false } }); + } finally { + child.kill(); + await child.exited; + await removeWithRetries(dir); + } + }, 60_000); +}); diff --git a/scripts/scient/divergences.json b/scripts/scient/divergences.json index c265c3691f..b8c72a980e 100644 --- a/scripts/scient/divergences.json +++ b/scripts/scient/divergences.json @@ -389,6 +389,20 @@ "packages/coding-agent/src/modes/rpc/wire/events.ts" ], "verification": ["bun --cwd=packages/coding-agent test test/scient/prompt-cache.test.ts"] + }, + "SA-PLAN-MODE": { + "status": "active", + "contract": "An RPC host can plan with the agent: the plan command (op get, enter or exit) turns plan mode on or off. In plan mode the agent changes nothing but its plan file until it proposes the plan through xd://propose. The proposal sends a plan_proposed frame (title, planFilePath, markdown), then asks the host to approve it with a select request while the tool call waits. Only an explicit approve leaves plan mode, and the same run goes on to carry out the plan; Refine asks the host what to change (an input request), and that answer, like any typed answer, reaches the model as feedback; planning goes on. An answer that arrives after plan mode was left never approves, and a new, switched or forked conversation leaves plan mode. Each time plan mode turns on or off, a plan_updated frame ({ enabled, planFilePath? }) tells the host. Planning is refused while a goal is set. Hosts that do not send plan are unaffected.", + "owned": ["packages/coding-agent/src/scient/rpc-plan.ts", "packages/coding-agent/test/scient/rpc-plan.test.ts"], + "upstream": [ + "packages/coding-agent/src/modes/rpc/rpc-mode.ts", + "packages/coding-agent/src/modes/rpc/rpc-types.ts", + "packages/coding-agent/src/modes/rpc/wire/commands.ts", + "packages/coding-agent/src/modes/rpc/wire/frames.ts", + "packages/coding-agent/src/modes/rpc/wire/index.ts", + "packages/coding-agent/src/modes/rpc/wire/state.ts" + ], + "verification": ["bun --cwd=packages/coding-agent test test/scient/rpc-plan.test.ts"] } } } diff --git a/sdk/go/omp-rpc/wire.go b/sdk/go/omp-rpc/wire.go index bbfe90d937..c3c70484ea 100644 --- a/sdk/go/omp-rpc/wire.go +++ b/sdk/go/omp-rpc/wire.go @@ -2031,6 +2031,50 @@ func (v *TodoStatus) UnmarshalJSON(data []byte) error { return unknownValue("TodoStatus", s) } +type PlanOp string + +const ( + PlanOpGet PlanOp = "get" + PlanOpEnter PlanOp = "enter" + PlanOpExit PlanOp = "exit" +) + +func (v *PlanOp) UnmarshalJSON(data []byte) error { + s, err := decodeString(data, "PlanOp") + if err != nil { + return err + } + switch value := PlanOp(s); value { + case PlanOpGet, PlanOpEnter, PlanOpExit: + *v = value + return nil + } + return unknownValue("PlanOp", s) +} + +type PlanResult struct { + // Plan mode is on: the agent plans and changes nothing until a plan is approved. + Enabled bool `json:"enabled"` + // The plan file the agent drafts, while plan mode is on. + PlanFilePath *string `json:"planFilePath,omitempty"` +} + +func (v *PlanResult) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PlanResult", v.decodeFrom) +} + +func (v *PlanResult) decodeFrom(raw map[string]json.RawMessage) error { + var out PlanResult + d := fieldDecoder{raw: raw, owner: "PlanResult"} + d.required("enabled", &out.Enabled) + d.optional("planFilePath", &out.PlanFilePath) + if d.err != nil { + return d.err + } + *v = out + return nil +} + type GoalOp string const ( @@ -5404,6 +5448,64 @@ func (v LiveDelegationEvent) MarshalJSON() ([]byte, error) { return encodeObject(plain(v), `"type":"live_delegation"`, nil) } +// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. +type PlanProposedEvent struct { + Title string `json:"title"` + PlanFilePath string `json:"planFilePath"` + Markdown string `json:"markdown"` +} + +func (v *PlanProposedEvent) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PlanProposedEvent", v.decodeFrom) +} + +func (v *PlanProposedEvent) decodeFrom(raw map[string]json.RawMessage) error { + var out PlanProposedEvent + d := fieldDecoder{raw: raw, owner: "PlanProposedEvent"} + d.constant("type", "plan_proposed") + d.required("title", &out.Title) + d.required("planFilePath", &out.PlanFilePath) + d.required("markdown", &out.Markdown) + if d.err != nil { + return d.err + } + *v = out + return nil +} + +func (v PlanProposedEvent) MarshalJSON() ([]byte, error) { + type plain PlanProposedEvent + return encodeObject(plain(v), `"type":"plan_proposed"`, nil) +} + +// Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. +type PlanUpdatedEvent struct { + Enabled bool `json:"enabled"` + PlanFilePath *string `json:"planFilePath,omitempty"` +} + +func (v *PlanUpdatedEvent) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PlanUpdatedEvent", v.decodeFrom) +} + +func (v *PlanUpdatedEvent) decodeFrom(raw map[string]json.RawMessage) error { + var out PlanUpdatedEvent + d := fieldDecoder{raw: raw, owner: "PlanUpdatedEvent"} + d.constant("type", "plan_updated") + d.required("enabled", &out.Enabled) + d.optional("planFilePath", &out.PlanFilePath) + if d.err != nil { + return d.err + } + *v = out + return nil +} + +func (v PlanUpdatedEvent) MarshalJSON() ([]byte, error) { + type plain PlanUpdatedEvent + return encodeObject(plain(v), `"type":"plan_updated"`, nil) +} + // State of one draft-only microphone capture. type DictationStateEvent struct { CaptureID string `json:"captureId"` @@ -6980,6 +7082,8 @@ func (LiveLevelsEvent) isRpcNotification() {} func (LiveTranscriptEvent) isRpcNotification() {} func (LiveEndEvent) isRpcNotification() {} func (LiveDelegationEvent) isRpcNotification() {} +func (PlanProposedEvent) isRpcNotification() {} +func (PlanUpdatedEvent) isRpcNotification() {} func (DictationStateEvent) isRpcNotification() {} func (DictationTranscriptEvent) isRpcNotification() {} func (DictationEndEvent) isRpcNotification() {} @@ -7064,6 +7168,10 @@ func (v *RpcNotification) UnmarshalJSON(data []byte) error { value, err = decodeVariant[LiveEndEvent](raw) case "live_delegation": value, err = decodeVariant[LiveDelegationEvent](raw) + case "plan_proposed": + value, err = decodeVariant[PlanProposedEvent](raw) + case "plan_updated": + value, err = decodeVariant[PlanUpdatedEvent](raw) case "dictation_state": value, err = decodeVariant[DictationStateEvent](raw) case "dictation_transcript": @@ -7188,6 +7296,8 @@ func (LiveLevelsEvent) isRpcServerFrame() {} func (LiveTranscriptEvent) isRpcServerFrame() {} func (LiveEndEvent) isRpcServerFrame() {} func (LiveDelegationEvent) isRpcServerFrame() {} +func (PlanProposedEvent) isRpcServerFrame() {} +func (PlanUpdatedEvent) isRpcServerFrame() {} func (DictationStateEvent) isRpcServerFrame() {} func (DictationTranscriptEvent) isRpcServerFrame() {} func (DictationEndEvent) isRpcServerFrame() {} @@ -7282,6 +7392,10 @@ func (v *RpcServerFrame) UnmarshalJSON(data []byte) error { value, err = decodeVariant[LiveEndEvent](raw) case "live_delegation": value, err = decodeVariant[LiveDelegationEvent](raw) + case "plan_proposed": + value, err = decodeVariant[PlanProposedEvent](raw) + case "plan_updated": + value, err = decodeVariant[PlanUpdatedEvent](raw) case "dictation_state": value, err = decodeVariant[DictationStateEvent](raw) case "dictation_transcript": @@ -8824,3 +8938,15 @@ func (c Commands) PredictNextMessage(ctx context.Context) (*string, error) { err := c.call(ctx, "predict_next_message", nil, 30*time.Second, &out) return out.Text, err } + +// PlanCommand holds the parameters of "plan". +type PlanCommand struct { + Op PlanOp `json:"op"` +} + +// Plan sends "plan": Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request). +func (c Commands) Plan(ctx context.Context, p PlanCommand) (PlanResult, error) { + var out PlanResult + err := c.call(ctx, "plan", p, 0, &out) + return out, err +} diff --git a/sdk/python/omp-rpc/src/omp_rpc/_wire.py b/sdk/python/omp-rpc/src/omp_rpc/_wire.py index f671cfa940..377d918f0f 100644 --- a/sdk/python/omp-rpc/src/omp_rpc/_wire.py +++ b/sdk/python/omp-rpc/src/omp_rpc/_wire.py @@ -98,6 +98,11 @@ _decode_todo_status = cast("Decoder[TodoStatus]", literal(_TODO_STATUS_VALUES)) +PlanOp: TypeAlias = Literal["get", "enter", "exit"] +_PLAN_OP_VALUES: Final[frozenset[str]] = frozenset({"get", "enter", "exit"}) +_decode_plan_op = cast("Decoder[PlanOp]", literal(_PLAN_OP_VALUES)) + + GoalOp: TypeAlias = Literal["get", "create", "resume", "pause", "drop"] _GOAL_OP_VALUES: Final[frozenset[str]] = frozenset({"get", "create", "resume", "pause", "drop"}) _decode_goal_op = cast("Decoder[GoalOp]", literal(_GOAL_OP_VALUES)) @@ -553,6 +558,14 @@ class ModelInfo: compat: JsonObject | None = None +@dataclass(slots=True, frozen=True, kw_only=True) +class PlanResult: + enabled: bool + """Plan mode is on: the agent plans and changes nothing until a plan is approved.""" + plan_file_path: str | None = None + """The plan file the agent drafts, while plan mode is on.""" + + @dataclass(slots=True, frozen=True, kw_only=True) class TodoItem: content: str @@ -1435,6 +1448,23 @@ class LiveDelegationEvent: text: str +@dataclass(slots=True, frozen=True, kw_only=True) +class PlanProposedEvent: + """The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.""" + type: Literal["plan_proposed"] = "plan_proposed" + title: str + plan_file_path: str + markdown: str + + +@dataclass(slots=True, frozen=True, kw_only=True) +class PlanUpdatedEvent: + """Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation.""" + type: Literal["plan_updated"] = "plan_updated" + enabled: bool + plan_file_path: str | None = None + + @dataclass(slots=True, frozen=True, kw_only=True) class DictationStateEvent: """State of one draft-only microphone capture.""" @@ -1754,7 +1784,7 @@ class LogoutResult: """A session event, discriminated by `type`; `set_event_filter` selects which are sent.""" -RpcNotification: TypeAlias = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent | UnknownNotification +RpcNotification: TypeAlias = ReadyEvent | PromptResultEvent | SessionSettledEvent | ExtensionError | ExtensionUiRequest | AvailableCommandsUpdateEvent | SubagentLifecycleEvent | SubagentProgressEvent | SubagentEvent | LivePhaseEvent | LiveLevelsEvent | LiveTranscriptEvent | LiveEndEvent | LiveDelegationEvent | PlanProposedEvent | PlanUpdatedEvent | DictationStateEvent | DictationTranscriptEvent | DictationEndEvent | BtwDeltaEvent | BtwRecordEvent | CommandOutputEvent | SessionInfoUpdateEvent | ConfigUpdateEvent | RpcFrameErrorEvent | RpcAgentEvent | UnknownNotification """Unsolicited outbound frame (everything except responses and host tool/URI requests), discriminated by `type`.""" @@ -1971,6 +2001,14 @@ def parse_model_info(value: object, path: str = "ModelInfo") -> ModelInfo: ) +def parse_plan_result(value: object, path: str = "PlanResult") -> PlanResult: + payload = expect_object(value, path) + return PlanResult( + enabled=required(payload, "enabled", decode_bool, path), + plan_file_path=optional(payload, "planFilePath", decode_str, path), + ) + + def parse_todo_item(value: object, path: str = "TodoItem") -> TodoItem: payload = expect_object(value, path) return TodoItem( @@ -2939,6 +2977,25 @@ def parse_live_delegation_event(value: object, path: str = "LiveDelegationEvent" ) +def parse_plan_proposed_event(value: object, path: str = "PlanProposedEvent") -> PlanProposedEvent: + payload = expect_object(value, path) + required(payload, "type", cast('Decoder[Literal["plan_proposed"]]', literal(frozenset({"plan_proposed"}))), path) + return PlanProposedEvent( + title=required(payload, "title", decode_str, path), + plan_file_path=required(payload, "planFilePath", decode_str, path), + markdown=required(payload, "markdown", decode_str, path), + ) + + +def parse_plan_updated_event(value: object, path: str = "PlanUpdatedEvent") -> PlanUpdatedEvent: + payload = expect_object(value, path) + required(payload, "type", cast('Decoder[Literal["plan_updated"]]', literal(frozenset({"plan_updated"}))), path) + return PlanUpdatedEvent( + enabled=required(payload, "enabled", decode_bool, path), + plan_file_path=optional(payload, "planFilePath", decode_str, path), + ) + + def parse_dictation_state_event(value: object, path: str = "DictationStateEvent") -> DictationStateEvent: payload = expect_object(value, path) required(payload, "type", cast('Decoder[Literal["dictation_state"]]', literal(frozenset({"dictation_state"}))), path) @@ -3357,6 +3414,8 @@ def parse_notification(value: object, path: str = "notification") -> RpcNotifica "live_transcript": parse_live_transcript_event, "live_end": parse_live_end_event, "live_delegation": parse_live_delegation_event, + "plan_proposed": parse_plan_proposed_event, + "plan_updated": parse_plan_updated_event, "dictation_state": parse_dictation_state_event, "dictation_transcript": parse_dictation_transcript_event, "dictation_end": parse_dictation_end_event, @@ -3877,6 +3936,12 @@ def predict_next_message(self) -> str | None: params: dict[str, object] = {} return required(expect_object(self._command("predict_next_message", params, timeout=30), "predict_next_message"), "text", nullable(decode_str), "predict_next_message") + def plan(self, op: PlanOp) -> PlanResult: + """Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request).""" + params: dict[str, object] = {} + params["op"] = op + return parse_plan_result(self._command("plan", params), "plan") + def on_ready(self, listener: Callable[[ReadyEvent], None]) -> Callable[[], None]: """Subscribe to `ready`: First frame after startup; transport fields are absent on servers without protocol v2.""" return self._listen("ready", listener) @@ -3933,6 +3998,14 @@ def on_live_delegation(self, listener: Callable[[LiveDelegationEvent], None]) -> """Subscribe to `live_delegation`: A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`.""" return self._listen("live_delegation", listener) + def on_plan_proposed(self, listener: Callable[[PlanProposedEvent], None]) -> Callable[[], None]: + """Subscribe to `plan_proposed`: The plan the agent proposed in plan mode, sent just before the select request that asks to approve it.""" + return self._listen("plan_proposed", listener) + + def on_plan_updated(self, listener: Callable[[PlanUpdatedEvent], None]) -> Callable[[], None]: + """Subscribe to `plan_updated`: Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation.""" + return self._listen("plan_updated", listener) + def on_dictation_state(self, listener: Callable[[DictationStateEvent], None]) -> Callable[[], None]: """Subscribe to `dictation_state`: State of one draft-only microphone capture.""" return self._listen("dictation_state", listener) @@ -4227,6 +4300,10 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "OpenSessionResult", "OpenUrlUiRequest", "ParentIrc", + "PlanOp", + "PlanProposedEvent", + "PlanResult", + "PlanUpdatedEvent", "PromoteQueuedMessageResult", "PromptAck", "PromptCacheEntry", @@ -4414,6 +4491,9 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "parse_open_session_result", "parse_open_url_ui_request", "parse_parent_irc", + "parse_plan_proposed_event", + "parse_plan_result", + "parse_plan_updated_event", "parse_promote_queued_message_result", "parse_prompt_ack", "parse_prompt_cache_entry", diff --git a/sdk/rust/omp-rpc/src/wire.rs b/sdk/rust/omp-rpc/src/wire.rs index 14df3f071f..c53e23c541 100644 --- a/sdk/rust/omp-rpc/src/wire.rs +++ b/sdk/rust/omp-rpc/src/wire.rs @@ -2778,6 +2778,36 @@ impl TodoStatus { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PlanOp { + #[serde(rename = "get")] + Get, + #[serde(rename = "enter")] + Enter, + #[serde(rename = "exit")] + Exit, +} + +impl PlanOp { + /// Wire value. + pub fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + Self::Enter => "enter", + Self::Exit => "exit", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanResult { + /// Plan mode is on: the agent plans and changes nothing until a plan is approved. + pub enabled: bool, + /// The plan file the agent drafts, while plan mode is on. + #[serde(rename = "planFilePath", default, skip_serializing_if = "Option::is_none")] + pub plan_file_path: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum GoalOp { #[serde(rename = "get")] @@ -4410,6 +4440,23 @@ pub struct LiveDelegationEvent { pub text: String, } +/// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanProposedEvent { + pub title: String, + #[serde(rename = "planFilePath")] + pub plan_file_path: String, + pub markdown: String, +} + +/// Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanUpdatedEvent { + pub enabled: bool, + #[serde(rename = "planFilePath", default, skip_serializing_if = "Option::is_none")] + pub plan_file_path: Option, +} + /// State of one draft-only microphone capture. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DictationStateEvent { @@ -5137,6 +5184,10 @@ pub enum RpcNotification { LiveEnd(LiveEndEvent), /// A spoken request for the host to send as the user's message; sent only to a `live_connect` with `hostDelegation`. LiveDelegation(LiveDelegationEvent), + /// The plan the agent proposed in plan mode, sent just before the select request that asks to approve it. + PlanProposed(PlanProposedEvent), + /// Plan mode turned on or off: by the `plan` command, by approving a plan, or by a change of conversation. + PlanUpdated(PlanUpdatedEvent), /// State of one draft-only microphone capture. DictationState(DictationStateEvent), /// Full accumulated draft preview; replaces the previous preview for this capture. @@ -5179,6 +5230,8 @@ impl RpcNotification { Some("live_transcript") => |value| serde_json::from_value(value).map(Self::LiveTranscript), Some("live_end") => |value| serde_json::from_value(value).map(Self::LiveEnd), Some("live_delegation") => |value| serde_json::from_value(value).map(Self::LiveDelegation), + Some("plan_proposed") => |value| serde_json::from_value(value).map(Self::PlanProposed), + Some("plan_updated") => |value| serde_json::from_value(value).map(Self::PlanUpdated), Some("dictation_state") => |value| serde_json::from_value(value).map(Self::DictationState), Some("dictation_transcript") => |value| serde_json::from_value(value).map(Self::DictationTranscript), Some("dictation_end") => |value| serde_json::from_value(value).map(Self::DictationEnd), @@ -5212,6 +5265,8 @@ impl Serialize for RpcNotification { Self::LiveTranscript(member) => serialize_tagged(member, &[("type", "live_transcript")], serializer), Self::LiveEnd(member) => serialize_tagged(member, &[("type", "live_end")], serializer), Self::LiveDelegation(member) => serialize_tagged(member, &[("type", "live_delegation")], serializer), + Self::PlanProposed(member) => serialize_tagged(member, &[("type", "plan_proposed")], serializer), + Self::PlanUpdated(member) => serialize_tagged(member, &[("type", "plan_updated")], serializer), Self::DictationState(member) => serialize_tagged(member, &[("type", "dictation_state")], serializer), Self::DictationTranscript(member) => serialize_tagged(member, &[("type", "dictation_transcript")], serializer), Self::DictationEnd(member) => serialize_tagged(member, &[("type", "dictation_end")], serializer), @@ -5252,7 +5307,7 @@ impl RpcServerFrame { let decode: fn(Value) -> Result = match value.get("type").and_then(Value::as_str) { Some("response") => |value| serde_json::from_value(value).map(Self::Response), Some("host_tool_call" | "host_tool_cancel" | "host_uri_request" | "host_uri_cancel") => |value| serde_json::from_value(value).map(Self::RpcHostRequest), - Some("ready" | "prompt_result" | "session_settled" | "extension_error" | "extension_ui_request" | "available_commands_update" | "subagent_lifecycle" | "subagent_progress" | "subagent_event" | "live_phase" | "live_levels" | "live_transcript" | "live_end" | "live_delegation" | "dictation_state" | "dictation_transcript" | "dictation_end" | "btw_delta" | "btw_record" | "command_output" | "session_info_update" | "config_update" | "rpc_frame_error" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "message_start" | "message_update" | "message_end" | "tool_execution_start" | "tool_execution_update" | "tool_stream_update" | "tool_execution_end" | "auto_compaction_start" | "auto_compaction_end" | "manual_compaction_start" | "manual_compaction_end" | "auto_retry_start" | "auto_retry_end" | "cache_warming_start" | "cache_warming_end" | "retry_fallback_applied" | "retry_fallback_succeeded" | "model_changed" | "config_warnings_changed" | "advisor_cost_changed" | "advisor_yielded" | "ttsr_triggered" | "todo_reminder" | "todo_auto_clear" | "irc_message" | "notice" | "thinking_level_changed" | "goal_updated" | "queue_update") => |value| serde_json::from_value(value).map(Self::RpcNotification), + Some("ready" | "prompt_result" | "session_settled" | "extension_error" | "extension_ui_request" | "available_commands_update" | "subagent_lifecycle" | "subagent_progress" | "subagent_event" | "live_phase" | "live_levels" | "live_transcript" | "live_end" | "live_delegation" | "plan_proposed" | "plan_updated" | "dictation_state" | "dictation_transcript" | "dictation_end" | "btw_delta" | "btw_record" | "command_output" | "session_info_update" | "config_update" | "rpc_frame_error" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "message_start" | "message_update" | "message_end" | "tool_execution_start" | "tool_execution_update" | "tool_stream_update" | "tool_execution_end" | "auto_compaction_start" | "auto_compaction_end" | "manual_compaction_start" | "manual_compaction_end" | "auto_retry_start" | "auto_retry_end" | "cache_warming_start" | "cache_warming_end" | "retry_fallback_applied" | "retry_fallback_succeeded" | "model_changed" | "config_warnings_changed" | "advisor_cost_changed" | "advisor_yielded" | "ttsr_triggered" | "todo_reminder" | "todo_auto_clear" | "irc_message" | "notice" | "thinking_level_changed" | "goal_updated" | "queue_update") => |value| serde_json::from_value(value).map(Self::RpcNotification), _ => |value| Ok(Self::Unknown(value)), }; decode(value) @@ -5824,6 +5879,11 @@ pub struct PredictNextMessageResult { pub text: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanParams { + pub op: PlanOp, +} + /// The constant `"image"`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct LitImage; @@ -7608,3 +7668,19 @@ impl Command for PredictNextMessageCommand { serde_json::from_value::(data.unwrap_or_else(|| Value::Object(Map::new()))).map(|result| result.text) } } + +/// Read, enter or leave plan mode: the agent plans without changing anything until the host approves the plan it proposes (`plan_proposed`, then a select request). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanCommand { + pub op: PlanOp, +} + +impl Command for PlanCommand { + const NAME: &'static str = "plan"; + const TIMEOUT_MS: Option = None; + type Output = PlanResult; + + fn decode(data: Option) -> Result { + serde_json::from_value::(data.unwrap_or_else(|| Value::Object(Map::new()))) + } +}