How are different modes implemented in MastraCode?

HEAD
e563911test(core): add regression tests for typeless tool schema items stripping (#14078)
This post might have stale content, as HEAD is 72 commits ahead.

in mastracode, how does the different modes are implemented? plan, build, ... ?

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

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 modeId into the request context via buildRequestContext() (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_plan tool 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_plan tool guidance is included only when modeId === 'plan' (line ~153)

###6. Plan → Build Transition Flow

This is the most interesting cross-mode interaction:

  1. User sends message in Plan mode → agent explores code, calls submit_plan tool
  2. submit_plan tool (packages/core/src/harness/tools.ts) emits a plan_approval_required event and blocks (via Promise)
  3. TUI renders the PlanApprovalInlineComponent with Approve / Reject / Request Changes options
  4. On Approveharness.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() sees ctx.activePlan and prepends the full plan to the Build mode prompt
  5. On Reject → stays in Plan mode, tool returns feedback for the agent to revise

###7. Mode Switching via TUI

Users can switch modes via:

switchMode() in the Harness (line ~321):

  1. Aborts any in-progress generation
  2. Saves the current model to the outgoing mode
  3. Updates currentModeId and persists it per-thread
  4. Loads/restores the incoming mode's previously-selected model
  5. Emits a mode_changed event 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.

Avatar of julianbenegas
Julian Benegas
commented

can i see more code? how's the Agent defined? what's buildRequestContext()

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

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

  1. stream(messages, streamOptions) (line ~4768) — entry point called by Harness
  2. Gets the LLM via getLLM({ requestContext })
  3. Calls #execute({ methodType: 'stream', messages, ... })
  4. #execute() (line ~4098):
    • Resolves requestContext (already populated by Harness)
    • Resolves instructions — calls getInstructions({ requestContext }) which triggers the dynamic function
    • Resolves memory, workspace
    • Creates a PrepareStreamWorkflow and runs it

##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.

Avatar of julianbenegas
Julian Benegas
commented

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?

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

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:

CapabilityWhat 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
modeIdRead-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:

###What tools cannot change:

  • modeId / currentModeIdswitchMode() is a Harness method that is not exposed in HarnessRequestContext. No tool can switch modes.
  • currentModelIdswitchModel() is also not exposed to tools.
  • The modeId field in the context is a plain string, not a setter.

###The one special case: submit_plan

The submit_plan tool doesn't switch modes itself. It:

  1. Calls registerPlanApproval() and blocks (via Promise)
  2. The TUI (not the LLM) handles the user's approve/reject
  3. On approval, the TUI handler calls harness.setState({ activePlan: {...} }) and harness.respondToPlanApproval(), which internally calls switchMode() (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):

FieldWhen savedWhere
currentModeIdOn switchMode()line 338
modeModelId_{modeId}On switchMode() / switchModel()lines 332, 420
observerModelIdOn switchObserverModel()line 1148
reflectorModelIdOn switchReflectorModel()line 1157
subagentModelIdOn setSubagentModelId()line 1178
tokenUsageAfter each messageline 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:

FieldPersisted?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.
tasksLost. Task list resets to [].
sandboxAllowedPathsLost. Resets to [].
yolo❌ (per-thread)Resets to the global default from initialState / settings.
permissionRulesResets 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:

  1. Thread is restoredselectOrCreateThread() picks up the most recent thread
  2. Mode is restoredcurrentModeId is in thread metadata ✅
  3. Model per mode is restoredmodeModelId_{modeId} is in thread metadata ✅
  4. Conversation history is restored — messages are in storage ✅
  5. Active plan is lost — only in memory, not in thread metadata ❌
  6. Task list is lost — only in memory ❌
  7. 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

How are different modes implemented in MastraCode? — mastra-ai/mastra