Plan Mode¶
Role¶
Plan Mode lets the agent “figure out and write down intent” before executing. While active, the agent is in a read-only phase:
Only read-only tools plus 4 whitelisted tools work:
plan_enter/plan_write/plan_exit/todo_write(the shell can be opted in — see below).Any other tool call is rejected immediately (the agent sees a “plan-mode denied” note).
Exiting Plan Mode requires HITL confirmation (reusing the permission system’s ASK), so the model can’t unilaterally jump into execution.
This pipeline encodes “design → plan → human review → execute” — combined with todo_write and subagents, it noticeably reduces “improvise-then-break-things” outcomes on long tasks.
Opt-in¶
HarnessAgent agent = HarnessAgent.builder()
.name("planner")
.model(model)
.workspace(workspace)
.enablePlanMode() // installs the Plan Mode trio
.planFileDirectory("plans") // optional; default "plans"
.build();
Builder options:
Method |
Default |
Notes |
|---|---|---|
|
|
enable Plan Mode |
|
|
plan-file root (workspace-relative) |
|
|
opt in to running the shell ( |
You can also call enableTaskList() so that todos created during the plan phase show up as a small reminder before each reasoning step.
The three tools¶
Tool |
Purpose |
Params |
|---|---|---|
|
Enter Plan Mode |
none |
|
Write content to the current plan file (default |
|
|
Exit Plan Mode → execution phase; HITL confirmation |
|
plan_write is a dedicated write entry for Plan Mode — avoids the security risk of whitelisting the generic write_file (which would let the model write anywhere during plan).
Workflow¶
sequenceDiagram
autonumber
participant U as User
participant A as Agent
participant H as Human (HITL)
participant FS as workspace
U->>A: "Refactor module X for me"
A->>A: plan_enter
A->>A: think → call read_file / grep_files (read-only)
A->>FS: plan_write to plans/PLAN.md
A->>H: plan_exit → HITL confirmation
H-->>A: ConfirmResult(true)
A->>A: enter execution phase; all tools allowed
Any non-whitelisted tool call (e.g. write_file, or execute unless you opt in) during the plan phase is rejected immediately with something like:
[Tool denied — plan mode is active]
Only read-only tools and plan_enter / plan_write / plan_exit / todo_write are allowed.
Seeing the denial, the model naturally switches back to “write the plan first”.
Reading the outcome¶
Plan-mode entry is autonomous, so a run can end in four states. isPlanModeActive() == false alone is ambiguous — don’t treat it as success without checking whether planning actually happened:
Terminal state |
Meaning |
|---|---|
Never entered plan mode |
Model chose to work directly in build mode — a valid decision, often because the task doesn’t match the workspace. |
Entered → |
Success: planned, got approval, now in build mode. |
Still in plan mode + |
Drafted a plan but didn’t exit; resume the session to approve. |
Still in plan mode + no |
“Narrate but don’t act”: the final message may read like a plan but none was written — give more specific input or a matching codebase. |
To tell these apart programmatically, track whether plan_enter / plan_write were called (e.g. from ToolCallStartEvent) alongside the final isPlanModeActive() and the plan file’s existence.
Allowing the shell during the plan phase (opt-in)¶
By default the shell tool (execute) is denied during the plan phase. The shell is dual-use: a single tool call can read (cat / ls / grep / git log) or mutate (rm / > / git commit / npm install), and Plan Mode decides what to permit purely by tool name — so it cannot tell a read invocation from a write one. Denying the shell keeps the read-only guarantee intact.
But shell access is often the most flexible way to investigate a codebase and produce a realistic plan. When you accept that trade-off, opt in:
HarnessAgent agent = HarnessAgent.builder()
.name("planner")
.model(model)
.workspace(workspace)
.enablePlanMode()
.allowShellInPlanMode() // let the model run the shell read-only during plan
.build();
With the opt-in enabled:
executeis added to the plan-phase allow-list, so the model can investigate via the shell.The plan banner gains an extra instruction telling the model to keep shell usage read-only (
cat/ls/grep/git log/diff/show/status) and not to run mutating commands until the plan is approved.The dedicated file-editing tools (
write_file/edit_file) remain denied — they are the primary mutation path, so the read-only intent is still enforced for file writes.
This mirrors how OpenCode handles its plan agent: it allows the shell for investigation, hard-blocks the edit/write tools, and relies on the prompt to keep the shell read-only. The guarantee is therefore softer than the default (the model could still mutate via the shell), so prefer enabling this together with a sandboxed filesystem to contain the blast radius.
Runtime permission switching (the “bypass” escape hatch)¶
Plan Mode is one specific phase switch. Underneath it, every session carries a PermissionMode that the permission engine evaluates against. You can flip that mode at runtime — for example to grant a deliberate, user-initiated “skip all permission prompts” toggle (similar to a YOLO / dangerous-skip switch in other coding tools):
RuntimeContext ctx = RuntimeContext.builder().sessionId("my-session").build();
agent.setPermissionMode(ctx, PermissionMode.BYPASS); // allow everything, no prompts
// ... run the operations that need full access ...
agent.setPermissionMode(ctx, PermissionMode.DEFAULT); // restore normal enforcement
PermissionMode current = agent.getPermissionMode(userId, sessionId);
setPermissionMode(...) preserves the session’s configured allow/deny/ask rules and working directories — only the mode changes — and rebuilds that session’s cached permission engine so the switch takes effect on the next call. An in-flight call keeps the engine it started with.
⚠ BYPASS disables all rule evaluation, so treat it as an explicit, per-session, opt-in action and prefer pairing it with a sandbox. To run unattended without prompts but with enforcement, use PermissionMode.DONT_ASK instead (ASK decisions become DENY rather than being auto-allowed).
Plan state is persisted¶
Plan Mode is runtime state and is auto-persisted along with AgentState — process restarts, node failovers, and cross-replica restores all bring back the plan phase. The plan file itself is written to plans/ in the workspace and goes through whichever filesystem mode you’ve configured (local / sandbox / remote KV), so it’s distributed-safe.
Programmatic enter/exit¶
When app code drives Plan Mode (e.g. an admin console button):
RuntimeContext ctx = RuntimeContext.builder().sessionId("my-session").build();
agent.enterPlanMode(ctx); // equivalent to the LLM calling plan_enter
agent.exitPlanMode(ctx); // equivalent to plan_exit; programmatic entry does NOT trigger HITL
agent.isPlanModeActive(ctx);
If you use agentscope-admin-spring-boot-starter, the admin HTTP API also exposes Plan Mode controls (POST /v1/admin/sessions/{id}:enter-plan-mode / :exit-plan-mode / GET /v1/admin/sessions/{id}/plan).
Interaction with subagents¶
⚠ Current known gap: subagents spawned via agent_spawn during Plan Mode do not automatically inherit the read-only restriction. To restrict the child:
Narrow
toolsin the child’s declaration to a read-only set, orAlso
enablePlanMode()on the child’s own builder and enter it explicitly
A future release will propagate plan-mode restrictions parent → child automatically.
Interaction with todo_write¶
Plan Mode and todo_write (provided by core) are independent but commonly used together:
Plan Mode — phase switch + plan file + HITL exit
todo_write— maintain a structured “what to do now” list during execution (whole-list replace; exactly onein_progress)
Typical workflow: write PLAN.md during the plan phase → plan_exit → in execution use todo_write to slice the PLAN into 5–8 todos → progress one at a time. Each reasoning step shows the agent a todos reminder to stay focused.
⚠ Don’t confuse with subagent background tasks (task_output / task_cancel / task_list) — that’s a different concept; see Subagent.
Viewing the task list¶
The task list lives in AgentState.tasksContext and is persisted automatically with every call(). To read it from application code:
List<Task> tasks = agent.getAgentState(userId, sessionId)
.getTasksContext()
.getTasks();
for (Task t : tasks) {
System.out.printf("[%s] %s%n", t.getState(), t.getSubject());
// state: PENDING / IN_PROGRESS / COMPLETED
}
If you use agentscope-admin-spring-boot-starter, the admin REST API provides a ready-made endpoint:
GET /v1/admin/sessions/{sessionId}/tasks
It returns each task’s subject, state, owner, and dependency info (blocks / blockedBy).
To observe task changes in real time through the event stream, listen for todo_write tool calls in streamEvents():
agent.streamEvents(message)
.filter(e -> e.getType() == AgentEventType.TOOL_RESULT_END)
.filter(e -> "todo_write".equals(((ToolResultEndEvent) e).getToolCallName()))
.doOnNext(e -> {
// Re-read the latest task list from state
var tasks = agent.getAgentState(userId, sessionId)
.getTasksContext().getTasks();
updateUI(tasks);
})
.subscribe();