How are different modes implemented in MastraCode?
in mastracode, how does the different modes are implemented? plan, build, ... ?
Now I have a complete picture. Here's how the different modes are implemented in Mastra Code:
##How Modes Work in Mastra Code
Mastra Code implements a multi-mode agent architecture where a single agent (code-agent) behaves differently depending on which mode is active. The three built-in modes are Build (default), Plan, and Fast. Here's how it all fits together:
###1. Mode Definition — HarnessMode type
Modes are defined as configuration objects conforming to the HarnessMode interface:
interface HarnessMode<TState> {
id: string; // e.g., "plan", "build", "fast"
name?: string; // display name
default?: boolean; // which mode starts as active
defaultModelId?: string; // each mode can have its own default LLM
color?: string; // hex color for UI indicator
agent: Agent | ((state: TState) => Agent); // the agent to use
}
###2. Mode Registration — in createMastraCode()
In mastracode/src/index.ts (lines ~148-173), the three default modes are defined:
const defaultModes: HarnessMode[] = [
{ id: 'build', name: 'Build', default: true, defaultModelId: 'anthropic/claude-opus-4-6', color: mastra.purple, agent: codeAgent },
{ id: 'plan', name: 'Plan', defaultModelId: 'openai/gpt-5.2-codex', color: mastra.blue, agent: codeAgent },
{ id: 'fast', name: 'Fast', defaultModelId: 'cerebras/zai-glm-4.7', color: mastra.green, agent: codeAgent },
];
Key insight: All three modes use the same codeAgent — the behavioral difference comes entirely from dynamic system prompts and tool filtering, not different agents.
###3. The Harness — Mode Orchestrator
The Harness class is the core orchestrator. It:
- Stores the current mode in
this.currentModeId(line ~75) - Provides
listModes(),getCurrentMode(),switchMode()methods (lines ~301-370) - Injects
modeIdinto the request context viabuildRequestContext()(line ~2690-2718), so the agent's instructions and tools know which mode is active - Persists mode per-thread — when switching modes, it saves/restores the model associated with each mode per thread
###4. Dynamic System Prompts — The Heart of Mode Differentiation
When a message is sent, the agent's instructions are generated dynamically via getDynamicInstructions(), which calls buildFullPrompt():
Full Prompt = Base Prompt + Task Section + Agent Instructions + Mode-Specific Prompt
The base prompt (mastracode/src/agents/prompts/base.ts) is shared across all modes and includes the "Current mode: {mode}" in the environment section.
The mode-specific prompts are selected from a map:
const modePrompts: Record<string, string | ((ctx) => string)> = {
build: buildModePromptFn, // function (to inject approved plans)
plan: planModePrompt, // static string
fast: fastModePrompt, // static string
};
####Plan Mode (mastracode/src/agents/prompts/plan.ts)
- Read-only — explicitly tells the agent NOT to modify files, create files, or run side-effect commands
- Focuses on exploration (view, search, find) and analysis
- Instructs the agent to produce a structured plan (Overview → Complexity → Steps → Verification)
- Requires calling
submit_plantool to present the plan for user approval
####Build Mode (mastracode/src/agents/prompts/build.ts)
- Full tool access — read, write, edit, execute
- If an approved plan exists in state (
ctx.activePlan), it prepends the plan to the build prompt so the agent implements it step-by-step - Follows a "understand → implement → verify → clean up" loop
- Requires verification before considering any task done
####Fast Mode (mastracode/src/agents/prompts/fast.ts)
- Speed-optimized — short responses, skip planning, minimize tool round-trips
- Only explore the codebase if the question is about the specific project
- Use a faster/cheaper default model (Cerebras)
###5. Tool Filtering Per Mode
buildToolGuidance() generates mode-specific tool documentation:
- All modes:
view,search_content,find_files,execute_command,task_write,task_check,ask_user,subagent - Build & Fast only (i.e.,
modeId !== 'plan'):string_replace_lsp,write_file— the write/edit tools are omitted from guidance in Plan mode (line ~81) - Plan mode only:
submit_plantool guidance is included only whenmodeId === 'plan'(line ~153)
###6. Plan → Build Transition Flow
This is the most interesting cross-mode interaction:
- User sends message in Plan mode → agent explores code, calls
submit_plantool submit_plantool (packages/core/src/harness/tools.ts) emits aplan_approval_requiredevent and blocks (via Promise)- TUI renders the
PlanApprovalInlineComponentwith Approve / Reject / Request Changes options - On Approve →
harness.respondToPlanApproval():- Automatically calls
switchMode({ modeId: 'build' })(the default mode) - The approved plan is stored in harness state as
activePlan(mastracode/src/tui/handlers/prompts.ts) - On the next message,
buildModePromptFn()seesctx.activePlanand prepends the full plan to the Build mode prompt
- Automatically calls
- On Reject → stays in Plan mode, tool returns feedback for the agent to revise
###7. Mode Switching via TUI
Users can switch modes via:
/mode <id>slash command — handled byhandleModeCommand()- Shift+Tab — cycles through modes (
mastracode/src/tui/setup.tsline ~138) - Plan approval — automatically switches to Build mode
switchMode() in the Harness (line ~321):
- Aborts any in-progress generation
- Saves the current model to the outgoing mode
- Updates
currentModeIdand persists it per-thread - Loads/restores the incoming mode's previously-selected model
- Emits a
mode_changedevent for the UI
###Summary Architecture
User Input
↓
Harness.sendMessage()
↓
buildRequestContext() ← injects { modeId, state } into request context
↓
Agent.stream() ← single codeAgent shared by all modes
↓
getDynamicInstructions() ← reads modeId from request context
↓
buildFullPrompt() = basePrompt + toolGuidance(modeId) + modePrompt[modeId]
↓
Different behavior based on prompt:
• Plan: read-only, explore, submit_plan → approval → auto-switch to Build
• Build: full access, implement, verify (may include approved plan)
• Fast: minimal overhead, quick answers/edits
The elegance of this design is that modes are purely a prompt + tool-guidance layer on top of a single agent, orchestrated by the Harness class which manages state, mode persistence, and the plan approval workflow.
can i see more code? how's the Agent defined? what's buildRequestContext()
That completes the full picture. Here's the breakdown:
##The Agent Class
The Agent class (packages/core/src/agent/agent.ts) is a large class (~5300 lines), but the core structure relevant to modes is straightforward:
###Constructor & Key Fields
export class Agent<TAgentId, TTools, TOutput, TRequestContext> extends MastraBase {
#instructions: DynamicArgument<AgentInstructions, TRequestContext>;
model: DynamicArgument<MastraModelConfig | ModelWithRetries[]> | ModelFallbacks;
#tools: DynamicArgument<TTools, TRequestContext>;
// ...
}
Both instructions and model accept DynamicArgument<T>, which is defined in packages/core/src/types/dynamic-argument.ts:
type DynamicArgument<T, TRequestContext> =
| T // static value
| (({ requestContext, mastra }) => Promise<T> | T); // function resolved per-request
This is the lynchpin — it allows the same agent to produce different instructions, models, and tools on every request, driven by what's in the RequestContext.
###How Instructions Are Resolved
In getInstructions() (line ~1056):
public getInstructions({ requestContext }) {
if (typeof this.#instructions === 'function') {
// Call the function, passing requestContext
return this.#instructions({ requestContext, mastra: this.#mastra });
}
return this.#instructions; // static string
}
This is called during #execute() (line ~4173):
const instructions = options.instructions || (await this.getInstructions({ requestContext }));
###The stream() → #execute() Flow
stream(messages, streamOptions)(line ~4768) — entry point called by Harness- Gets the LLM via
getLLM({ requestContext }) - Calls
#execute({ methodType: 'stream', messages, ... }) #execute()(line ~4098):- Resolves
requestContext(already populated by Harness) - Resolves
instructions— callsgetInstructions({ requestContext })which triggers the dynamic function - Resolves
memory,workspace - Creates a
PrepareStreamWorkflowand runs it
- Resolves
##buildRequestContext() — The Bridge Between Harness and Agent
This is in packages/core/src/harness/harness.ts line 2690:
private async buildRequestContext(requestContext?: RequestContext): Promise<RequestContext> {
requestContext ??= new RequestContext();
const harnessContext: HarnessRequestContext = {
harnessId: this.id,
state: this.getState(), // full state snapshot (model, mode, yolo, activePlan, etc.)
getState: () => this.getState(), // live getter
setState: updates => this.setState(updates), // mutator
threadId: this.currentThreadId,
resourceId: this.resourceId,
modeId: this.currentModeId, // ← THIS is how the mode reaches the agent
abortSignal: this.abortController?.signal,
workspace: this.workspace,
emitEvent: event => this.emit(event),
registerQuestion: params => this.registerQuestion(params),
registerPlanApproval: params => this.registerPlanApproval(params),
getSubagentModelId: params => this.getSubagentModelId(params),
};
requestContext.set('harness', harnessContext); // ← stored under 'harness' key
// Resolve dynamic workspace if needed
if (this.workspaceFn) {
const resolved = await Promise.resolve(this.workspaceFn({ requestContext }));
harnessContext.workspace = resolved;
this.workspace = resolved;
}
return requestContext;
}
RequestContext (packages/core/src/request-context/index.ts) is essentially a typed Map<string, unknown> — a simple key-value bag that flows through the entire request lifecycle.
###The Full Data Flow
Here's how a single message flows through the mode system:
Harness.sendMessage("fix the bug")
│
├─ 1. getCurrentAgent() → looks up this.currentModeId in config.modes[]
│ returns mode.agent (the shared codeAgent)
│
├─ 2. buildRequestContext() → creates RequestContext, sets 'harness' key with:
│ { modeId: "plan", state: { currentModelId: "openai/gpt-5.2-codex",
│ activePlan: null, ... }, ... }
│
├─ 3. buildToolsets(requestContext) → assembles built-in tools (ask_user, submit_plan, etc.)
│
└─ 4. agent.stream(message, { requestContext, toolsets, ... })
│
└─ Agent.#execute()
│
├─ getInstructions({ requestContext })
│ └─ calls getDynamicInstructions({ requestContext }) ← the function passed at agent creation
│ │
│ ├─ reads requestContext.get('harness').modeId → "plan"
│ ├─ reads requestContext.get('harness').state → { activePlan, ... }
│ │
│ └─ buildFullPrompt(ctx)
│ ├─ buildBasePrompt() → shared core personality
│ ├─ buildToolGuidance("plan") → no write tools, has submit_plan
│ └─ modePrompts["plan"] → "You are in PLAN mode. Read-only..."
│
├─ getLLM({ requestContext })
│ └─ calls getDynamicModel({ requestContext })
│ └─ reads state.currentModelId → "openai/gpt-5.2-codex"
│ calls resolveModel("openai/gpt-5.2-codex") → OpenAI Codex provider
│
└─ runs the LLM loop with the mode-specific instructions + tools
###Key Design Insight
The RequestContext is the dependency injection mechanism that connects the Harness (which knows about modes) to the Agent (which is generic). The Agent doesn't know about modes at all — it just has dynamic functions for instructions and model that read from whatever's in the RequestContext. The Harness is what populates that context with the current modeId, state, and helper functions before every agent call.
so coming back to the plan/build/fast modes: it's all driven by the requestContext, right? can the LLM itself decide to mutate this context? is this context persisted when a client dies and then re-connects?
Now I have the complete picture. Here's the answer:
##Can the LLM itself mutate the RequestContext?
No, the LLM cannot directly mutate the RequestContext. But it can indirectly mutate harness state through tool calls, which affects future requests. Here's the precise boundary:
###What the RequestContext exposes to tools
From HarnessRequestContext, tools receive:
| Capability | What it does |
|---|---|
state (read-only snapshot) | Snapshot at request start — stale by design |
getState() | Live getter for current harness state |
setState(updates) | Can mutate harness state |
modeId | Read-only string — no setter |
emitEvent(event) | Can emit events to the TUI |
registerQuestion(...) | Can block execution waiting for user input |
registerPlanApproval(...) | Can block execution waiting for plan approval |
###What tools can change via setState:
tasks— thetask_writetool callsharnessCtx.setState({ tasks })(tools.ts line 197)sandboxAllowedPaths— therequest_accesstool adds paths (request-sandbox-access.ts line 83)
###What tools cannot change:
modeId/currentModeId—switchMode()is a Harness method that is not exposed inHarnessRequestContext. No tool can switch modes.currentModelId—switchModel()is also not exposed to tools.- The
modeIdfield in the context is a plainstring, not a setter.
###The one special case: submit_plan
The submit_plan tool doesn't switch modes itself. It:
- Calls
registerPlanApproval()and blocks (via Promise) - The TUI (not the LLM) handles the user's approve/reject
- On approval, the TUI handler calls
harness.setState({ activePlan: {...} })andharness.respondToPlanApproval(), which internally callsswitchMode()(harness.ts line 2148)
So the mode switch is always a user-initiated action that flows through the Harness — the LLM can only request it indirectly by submitting a plan.
###The setState call is in-memory only
Looking at setState():
async setState(updates) {
const newState = { ...this.state, ...updates };
// validate against schema...
this.state = result.value;
this.emit({ type: 'state_changed', state: this.state, changedKeys });
// ← NO storage write. Just in-memory + event.
}
It merges into this.state (in-memory), validates, emits an event. No persistence to storage. This means anything a tool writes via setState (like tasks, activePlan, sandboxAllowedPaths) lives only in process memory.
##Is context persisted when a client dies and reconnects?
Partially. There's a two-tier persistence story:
###Tier 1: Thread metadata (persisted to storage) ✅
Specific fields are explicitly saved to thread metadata via setThreadSetting(), which writes to thread.metadata in the storage backend (LibSQL/Postgres):
| Field | When saved | Where |
|---|---|---|
currentModeId | On switchMode() | line 338 |
modeModelId_{modeId} | On switchMode() / switchModel() | lines 332, 420 |
observerModelId | On switchObserverModel() | line 1148 |
reflectorModelId | On switchReflectorModel() | line 1157 |
subagentModelId | On setSubagentModelId() | line 1178 |
tokenUsage | After each message | line 2757 |
On reconnect, selectOrCreateThread() → loadThreadMetadata() restores these fields from thread metadata back into harness state.
###Tier 2: In-memory harness state (NOT persisted) ❌
Everything else in setState() — including things tools write — is ephemeral:
| Field | Persisted? | What happens on reconnect |
|---|---|---|
activePlan | ❌ (only saved to disk as .md file, not in thread metadata) | Lost. Reverts to null. The plan file on disk is for human reference only. |
tasks | ❌ | Lost. Task list resets to []. |
sandboxAllowedPaths | ❌ | Lost. Resets to []. |
yolo | ❌ (per-thread) | Resets to the global default from initialState / settings. |
permissionRules | ❌ | Resets to defaults. |
thinkingLevel | ❌ (per-thread) | Resets to the global default. |
###Tier 3: Messages (persisted via Memory) ✅
The conversation history itself is persisted — the Agent's memory system stores messages to the storage backend, keyed by thread ID. So on reconnect, the LLM gets the full conversation history back. It just doesn't get the ephemeral state like active plans or task lists.
###The net effect
When a client dies and reconnects:
- Thread is restored —
selectOrCreateThread()picks up the most recent thread - Mode is restored —
currentModeIdis in thread metadata ✅ - Model per mode is restored —
modeModelId_{modeId}is in thread metadata ✅ - Conversation history is restored — messages are in storage ✅
- Active plan is lost — only in memory, not in thread metadata ❌
- Task list is lost — only in memory ❌
- Sandbox paths are lost — only in memory ❌
So the answer is: the mode and model survive reconnection, but in-flight state like plans, tasks, and sandbox permissions do not. The conversation history does survive, so the agent can see what happened before, but it won't have the structured activePlan injected into its system prompt — it would need to re-derive context from the message history.
END OF POST