Skip to content

Agent Source Code Analysis

Module Overview

In the TypeScript version, Agent is no longer an execution engine, but a pure configuration definition. It describes a “role’s” behavioral parameters — name, permission rules, system prompt, model preference, temperature setting — but contains no execution logic.

The actual execution loop is driven by the Session module. Session launches the main loop via SessionPrompt.loop(), which in each iteration calls SessionProcessor.process() -> LLM.stream() -> Vercel AI SDK streamText() to complete the LLM call, then SessionProcessor handles streaming events, tool calls, context compaction, and so on.

This “separation of configuration and execution” design means:

  • Adding or modifying an Agent only requires declaring configuration, with no execution logic to write
  • All Agents share the same Session execution loop; tool calling, error handling, and context management are implemented only once
  • Agent permissions, tool sets, and system prompts are all controlled through declarative configuration

Key Files

File PathLinesResponsibility
src/agent/agent.ts~230Agent configuration definition: Agent.Info schema, built-in Agent list, state() factory, generate() dynamic creation
src/session/index.ts~400Session management: CRUD, message persistence, cost calculation, paginated queries
src/session/prompt.ts~580Core entry point: loop() main loop, user message construction, tool resolution, subtask dispatch
src/session/llm.ts~200LLM invocation: LLM.stream() wraps Vercel AI SDK streamText(), handles system prompt assembly and parameter merging
src/session/processor.ts~220Message processor: streaming event dispatch, tool lifecycle, Doom Loop detection, snapshot tracking
src/session/compaction.ts~220Context compaction: overflow detection, message pruning, summary generation
src/session/overflow.ts~20Overflow check: pure function that checks whether token usage exceeds the model’s context window
src/session/retry.ts~100Retry strategy: error classification, exponential backoff, retry-after header parsing
src/session/revert.ts~160Message revert: snapshot restoration, message deletion, diff calculation
src/session/status.ts~80State management: idle/busy/retry tri-state switching, broadcast via Bus
src/session/message-v2.ts~600Message types: Zod schemas defining all message and Part types, persistence, streaming queries

Type System

Agent.Info — Agent Configuration Schema

export const Info = z
  .object({
    name: z.string(),
    description: z.string().optional(),
    mode: z.enum(["subagent", "primary", "all"]),
    native: z.boolean().optional(),
    hidden: z.boolean().optional(),
    topP: z.number().optional(),
    temperature: z.number().optional(),
    color: z.string().optional(),
    permission: PermissionNext.Ruleset,
    model: z
      .object({
        modelID: z.string(),
        providerID: z.string(),
      })
      .optional(),
    variant: z.string().optional(),
    prompt: z.string().optional(),
    options: z.record(z.string(), z.any()),
    steps: z.number().int().positive().optional(),
  })
  .meta({
    ref: "Agent",
  })

Key field descriptions:

  • mode: "primary" indicates a primary Agent (user converses directly), "subagent" indicates a sub-Agent (invoked by the Task tool), "all" indicates a custom Agent
  • permission: PermissionNext.Ruleset, defines the tool permission rule chain for this Agent
  • prompt: Custom system prompt that overrides the Provider’s default prompt
  • model: Optional fixed model binding; if not specified, the user’s currently selected model is used

Built-in Agent List

Agent NamemodehiddenPurpose
buildprimaryNoDefault Agent with full tool permissions, supports question and plan_enter
planprimaryNoPlanning mode, disables all editing tools, only allows writing to the plans directory
exploresubagentNoQuick code exploration with read-only tools only (grep, glob, read, bash, webfetch, etc.)
generalsubagentNoGeneral-purpose sub-Agent for parallel multi-step task execution, disables the todo tool
compactionprimaryYesDedicated to context compaction, disables all tools, generates conversation summaries
titleprimaryYesGenerates titles for sessions, disables all tools, temperature fixed at 0.5
summaryprimaryYesGenerates summaries for sessions, disables all tools

Session.Info — Session Schema

export const Info = z
  .object({
    id: Identifier.schema("session"),
    slug: z.string(),
    projectID: z.string(),
    directory: z.string(),
    parentID: Identifier.schema("session").optional(),
    summary: z
      .object({
        additions: z.number(),
        deletions: z.number(),
        files: z.number(),
        diffs: Snapshot.FileDiff.array().optional(),
      })
      .optional(),
    share: z.object({ url: z.string() }).optional(),
    title: z.string(),
    version: z.string(),
    time: z.object({
      created: z.number(),
      updated: z.number(),
      compacting: z.number().optional(),
      archived: z.number().optional(),
    }),
    permission: PermissionNext.Ruleset.optional(),
    revert: z
      .object({
        messageID: z.string(),
        partID: z.string().optional(),
        snapshot: z.string().optional(),
        diff: z.string().optional(),
      })
      .optional(),
  })

Concurrency Control — Runner Mechanism

Each SessionID is bound to a Runner instance created by Effect’s Runner.make(). The Runner internally maintains a task queue, ensuring that operations on the same Session execute serially:

const runners = new Map<string, Runner<MessageV2.WithParts>>()
const getRunner = (runners, sessionID) => {
  const existing = runners.get(sessionID)
  if (existing) return existing
  const runner = Runner.make<MessageV2.WithParts>(scope, {
    onIdle: Effect.gen(function* () {
      runners.delete(sessionID)   // Auto-cleanup when idle, releasing resources
      yield* status.set(sessionID, { type: "idle" })
    }),
    onBusy: status.set(sessionID, { type: "busy" }),
    onInterrupt: lastAssistant(sessionID),
    busy: () => { throw new Session.BusyError(sessionID) },
  })
  runners.set(sessionID, runner)
  return runner
}

Key design points:

  • Serialization guarantee: All operations on the same Session are queued through the Runner, with no concurrent race conditions
  • Auto-cleanup: The Runner is removed from the Map when entering idle state, avoiding memory leaks
  • Interrupt callback: onInterrupt triggers lastAssistant() on cancellation, ensuring consistent state after interruption
  • Busy rejection: If the Session is busy and doesn’t support queuing, it throws BusyError directly

Race Condition — SyncEvent Event Source

Message writes don’t operate on the database directly, but execute synchronously through the SyncEvent event source:

const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
  Effect.gen(function* () {
    yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
    return msg
  })

This design eliminates the possibility of race conditions:

  • SyncEvent.run() executes synchronously: first writes to SQLite WAL, then publishes BusEvent
  • Effect’s single-threaded coroutine model guarantees operation atomicity
  • Operations on the same Session are already serialized by the Runner, so there are no multi-threaded races
  • Part updates have delta optimization: streaming text only sends BusEvent without writing to DB, reducing IO overhead

Core Flow — Session Execution Loop

Complete Call Chain

User input -> SessionPrompt.prompt()
         -> SessionPrompt.loop()
           |-- First message? -> ensureTitle() generates title asynchronously
           |-- Pending subtask? -> TaskTool executes directly
           |-- Pending compaction? -> SessionCompaction.process()
           |-- Context overflow? -> SessionCompaction.create() -> continue
           `-- Normal processing:
              |-- SessionProcessor.create()
              |-- resolveSystemPrompt() assembles system prompt
              |-- resolveTools() registers built-in + MCP tools
              `-- processor.process()
                 `-- LLM.stream()
                    `-- ai.streamText() (Vercel AI SDK)
                 `-- for await (stream.fullStream) process streaming events
                    |-- text-start / text-delta / text-end -> Text Part
                    |-- reasoning-start / reasoning-delta / reasoning-end -> Reasoning Part
                    |-- tool-input-start / tool-call / tool-result / tool-error -> Tool Part
                    |-- start-step / finish-step -> Snapshot tracking + token billing
                    `-- Error? -> SessionRetry.retryable() determines whether to retry

SessionPrompt.loop() — Main Loop in Detail

loop() is the heart of the entire system. It is called within SessionPrompt.prompt(), registering an AbortController via start() for cancellation control. Key loop logic:

  1. Read message stream: MessageV2.filterCompacted(MessageV2.stream(sessionID)) traverses messages in reverse, skipping compacted history
  2. Check loop exit conditions: If the last Assistant message’s finish is not "tool-calls" or "unknown", and its ID is greater than the last User message ID, exit the loop
  3. Prioritize pending tasks: Check if the last message’s parts contain compaction or subtask type Parts
  4. Context overflow detection: Check whether compaction is needed via SessionCompaction.isOverflow()
  5. Normal processing: Create Assistant message, resolve tools, call processor.process()

SessionProcessor.process() — Streaming Event Handling

SessionProcessor is a stateful object that holds the current Assistant message, tool call mapping table, and snapshot reference. The process() method contains a while(true) loop internally:

async process(streamInput: LLM.StreamInput) {
  while (true) {
    const stream = await LLM.stream(streamInput)
    for await (const value of stream.fullStream) {
      abort.throwIfAborted()
      switch (value.type) {
        // Text stream -> create/append TextPart
        // Reasoning stream -> create/append ReasoningPart
        // Tool call -> create ToolPart (pending -> running -> completed/error)
        // Step tracking -> Snapshot.track() + StepStartPart/StepFinishPart
      }
      if (needsCompaction) break  // Break on context overflow
    }
    // Error handling -> retryable? continue loop : mark error and exit
    // Normal end -> return "continue" | "stop" | "compact"
  }
}

LLM.stream() — Vercel AI SDK Wrapper

LLM.stream() is a wrapper around the Vercel AI SDK streamText(), responsible for:

  1. Getting the language model: Provider.getLanguage(model) returns an AI SDK-compatible LanguageModel
  2. Assembling the system prompt: Merges Agent prompt, Provider default prompt, and user-customized system prompt by priority
  3. Parameter merging pipeline: base -> model.options -> agent.options -> variant, with each layer overriding the previous
  4. Tool resolution: resolveTools() filters tools based on the user’s disable list and permission rules
  5. Calling streamText(): Passes in wrapLanguageModel() middleware for message format transformation
export async function stream(input: StreamInput) {
  const [language, cfg, provider, auth] = await Promise.all([
    Provider.getLanguage(input.model),
    Config.get(),
    Provider.getProvider(input.model.providerID),
    Auth.get(input.model.providerID),
  ])
  // ... system prompt assembly, parameter merging ...
  return streamText({
    model: wrapLanguageModel({ model: language, middleware: [...] }),
    messages: [...system.map(x => ({ role: "system", content: x })), ...input.messages],
    tools,
    abortSignal: input.abort,
    maxRetries: input.retries ?? 0,
    // ... other parameters
  })
}

Doom Loop Detection Mechanism

SessionProcessor implements Doom Loop detection internally to prevent the LLM from entering an infinite loop of repeatedly calling the same tool:

const DOOM_LOOP_THRESHOLD = 3

// In the tool-call event handler
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
  lastThree.length === DOOM_LOOP_THRESHOLD &&
  lastThree.every(
    (p) =>
      p.type === "tool" &&
      p.tool === value.toolName &&
      p.state.status !== "pending" &&
      JSON.stringify(p.state.input) === JSON.stringify(value.input),
  )
) {
  await PermissionNext.ask({
    permission: "doom_loop",
    patterns: [value.toolName],
    sessionID: input.assistantMessage.sessionID,
    // ...
  })
}

Detection condition: the last 3 tool calls are all the same tool name with the same input parameters. When triggered, PermissionNext.ask() requests user confirmation. The user can choose to “always allow” doom loops for that tool.

Subtask Execution Model

Subtasks do not create new Sessions. Instead, they create new Assistant Messages + Tool Parts within the current Session:

const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input) {
  // 1. Create a standalone assistant message (mode = task.agent)
  // 2. Create a tool part (status: "running")
  // 3. Execute taskTool.execute() within the same session
  //    Uses the sub-agent's permission rules
  //    Passes the current session's message history
})

Key design points:

  • Subtasks reuse the current Session’s message history, avoiding redundant loading
  • Uses the sub-Agent’s permission rules (merged with Session-level permissions), rather than the primary Agent’s permissions
  • If triggered via /command, a summary User Message is automatically injected upon completion, letting the primary Agent know the subtask’s execution result
  • Tool Part lifecycle: pending -> running -> completed/error, consistent with other tool calls

Plugin Hook System

The Session execution loop injects Plugin Hooks at multiple points, allowing external extensions:

Hook PointTrigger TimingPurpose
chat.system.transformDuring system prompt constructionTransform/inject system prompt content
chat.paramsBefore LLM parameters are passedModify temperature, maxTokens, and other parameters
tool.execute.beforeBefore tool executionIntercept, log, modify tool input
tool.execute.afterAfter tool executionPost-process tool output, audit logging
command.execute.beforeBefore command executionIntercept/modify command parameters
chat.messageDuring message constructionTransform/augment message content
shell.envDuring shell command executionInject environment variables

These hooks enable Plugins to intervene in the execution flow without modifying core code. For example, an audit Plugin can log all tool call inputs via tool.execute.before, or inject project-specific environment variables for shell commands via shell.env.

Context Compaction — Two-Layer Optimization

Compaction is divided into two phases, each with fine-grained protection mechanisms:

1. Overflow Detection (SessionCompaction.isOverflow()):

// overflow.ts
export function isOverflow(input) {
  const reserved = input.cfg.compaction?.reserved ?? Math.min(20_000, maxOutputTokens(model))
  const usable = input.model.limit.input
    ? input - reserved
    : context - maxOutputTokens(model)
  return count >= usable
}
  • Calculates total token count of input + output + cache.read + cache.write
  • Usable space = model.limit.input - reserved (reserved defaults to min(20000, maxOutputTokens))
  • Triggers compaction when token usage >= usable space

2. Message Pruning (SessionCompaction.prune()) — Two-Layer Protection:

First layer — minimum threshold:

  • PRUNE_MINIMUM = 20,000 tokens; pruning is not triggered below this value
  • Avoids meaningless pruning operations on short conversations

Second layer — protection band:

  • PRUNE_PROTECT = 40,000 tokens, protecting recent tool outputs from being pruned
  • PRUNE_PROTECTED_TOOLS = ["skill"], tool outputs marked as protected are never pruned
  • Traverses from the latest messages backward, skipping the most recent 2 rounds of conversation

Pruning execution:

  • Tool outputs exceeding the threshold are marked as compacted (setting state.time.compacted = Date.now())
  • Subsequent calls to toModelMessages() replace pruned outputs with [Old tool result content cleared]
  • Executed asynchronously at the end of each prompt loop (Effect.forkIn(scope)), without blocking the main loop

3. Summary Generation (SessionCompaction.process()):

  • Uses the compaction Agent (with all tools disabled)
  • Sends the entire message history + summary instructions to the LLM
  • The generated summary message is marked summary: true; subsequent calls to filterCompacted() use this as the pruning point
  • If auto-compaction succeeds, a “Continue” message is automatically appended to resume the conversation

Error Handling and Edge Cases

LLM Call Retry Strategy

The SessionRetry module implements a complete retry strategy:

Retryable Error Classification (retryable()):

  • APIError with isRetryable === true: includes 429 rate limiting and 500 server errors
  • ContextOverflowError: not retried, triggers compaction directly
  • FreeUsageLimitError: returns a prompt message, not retried
  • Errors with response bodies containing "exhausted" / "unavailable" / "rate_limit"

Backoff Algorithm (delay()):

  • First reads response headers retry-after-ms (milliseconds) or retry-after (seconds/HTTP date)
  • Without headers, uses exponential backoff: 2000ms * 2^(attempt-1), capped at 30 seconds
  • With headers, the cap is 2^31 - 1 (maximum 32-bit signed integer)
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000

In the catch block of SessionProcessor.process():

const error = MessageV2.fromError(e, { providerID: input.model.providerID })
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
  attempt++
  const delay = SessionRetry.delay(attempt, ...)
  SessionStatus.set(sessionID, { type: "retry", attempt, message: retry, next: ... })
  await SessionRetry.sleep(delay, input.abort)
  continue  // Return to top of while(true) to retry
}

Tool Execution Error Handling

When tool execution fails (tool-error event), the handler takes different actions based on error type:

  • PermissionNext.RejectedError: User rejected the permission request; based on configuration, decides whether to break the loop
  • Question.RejectedError: User rejected the question confirmation; similarly decides based on configuration
  • Other errors: Logs the error information, sets tool status to error, continues processing subsequent events

After the loop ends, all tools in pending or running state are marked with the "Tool execution aborted" error.

Message Revert — Complete Mechanism

The SessionRevert module uses the Effect Service pattern, providing three operations. The core of revert lies in precisely restoring file system state:

revert() Complete Flow:

  1. Traverses messages and Parts to locate the user-specified revert point
  2. Starting from the revert point, collects all subsequent Patch Parts (file edit operations)
  3. Captures the current file system Snapshot as the revert baseline
  4. Applies the reverse Patch operations: snap.revert(patches) undoes all file modifications in reverse order
  5. Writes revert metadata (messageID, partID, snapshot, diff) to the Session.revert field
// Simplified core logic
const revert = Effect.fn("SessionRevert.revert")(function* (input) {
  // 1. Locate revert point
  // 2. Collect subsequent patches
  // 3. Capture current snapshot
  // 4. snap.revert(patches) — reverse-apply all edits
  // 5. Update session.revert metadata
})
  • revert(): Reverts to the specified message/Part, restores file system state via Snapshot, calculates diff
  • unrevert(): Undoes the revert, restores to the pre-revert snapshot
  • cleanup(): Cleans up revert state at the start of the next conversation (within prompt() call), deleting all messages after the revert point

This design ensures that revert is reversible — unrevert can precisely restore to the pre-revert state, because snapshots and diffs are saved at every step.

Degradation on Provider Unavailability

When Provider authentication fails, MessageV2.fromError() generates an AuthError. SessionRetry.retryable() returns undefined for authentication errors (not retryable), and the loop terminates immediately. Context overflow errors (ContextOverflowError) are also not retried; instead, they are detected via isOverflow() in loop() to trigger automatic compaction.

Precise Cost Calculation

Cost calculation in Session has multiple details requiring precise handling:

  • Cache write tokens: Extracted from Provider-specific fields across multiple sources including anthropic, vertex, bedrock, venice, etc.
  • Cached token deduplication: AI SDK v6’s inputTokens already includes cached tokens; they must be subtracted to avoid double billing
  • Pricing above 200K: Uses differential pricing via the experimentalOver200K field
  • Reasoning tokens: Billed at output price (rather than separately priced)
  • Precision guarantee: Uses Decimal.js for precise floating-point calculations, avoiding JavaScript’s native floating-point errors

Tool Call Repair Mechanism

Tool call names returned by the LLM may have case errors. experimental_repairToolCall provides automatic repair:

async experimental_repairToolCall(failed) {
  const lower = failed.toolCall.toolName.toLowerCase()
  // Case repair: convert tool name to lowercase for matching
  // If still unmatched -> route to "invalid" tool
  // "invalid" tool returns a friendly error message
}

This prevents tool call failures caused by the model outputting Read_File instead of read_file — the system automatically attempts case-insensitive matching, and if that fails, the “invalid” tool provides a clear error message.

State Management

InstanceState Cache

The Agent list implements lazy-loaded caching via Instance.state():

const state = Instance.state(async () => {
  const cfg = await Config.get()
  // ... build Agent list ...
  return result as Record<string, Agent.Info>
})

export async function get(agent: string) {
  return state().then((x) => x[agent])
}

Instance.state() returns an async function that executes the factory function on first call and caches the result. Subsequent calls return the cached value directly. The cache is automatically cleaned up when the Instance is destroyed.

Session State Tri-State

SessionStatus manages each Session’s state through an Effect Service:

export const Info = z.union([
  z.object({ type: z.literal("idle") }),
  z.object({ type: z.literal("busy") }),
  z.object({ type: z.literal("retry"), attempt: z.number(), message: z.string(), next: z.number() }),
])

State changes are broadcast via Bus.publish(Event.Status, ...), and the UI layer subscribes to these events to update the interface.

Cancellation Control and Precise Interruption

SessionPrompt.loop() registers an AbortController for each Session via start():

function start(sessionID: string) {
  const controller = new AbortController()
  s[sessionID] = { abort: controller, callbacks: [] }
  return controller.signal
}

cancel() calls controller.abort() to interrupt all in-progress LLM calls and tool executions. If the same Session has queued requests (callbacks), they are rejected.

AbortController Precise Interruption Points: The stream method of LLM.Service creates an AbortController via Effect.acquireRelease, performing precise resource cleanup on interruption:

  1. Incomplete snapshots: Computes a patch for the current file system state, saving completed edits
  2. Incomplete text: Saves streaming text that has been received but not yet written
  3. Reasoning parts: Cleans up incomplete reasoning content
  4. Tool markers: All tools in running / pending state are marked as error("Tool execution aborted")

This fine-grained interruption handling ensures that cancellation operations don’t leave inconsistent state — incomplete edits are rolled back or saved, and tool calls are gracefully terminated.

Call Chain Examples

Chain 1: User Sends Message -> LLM Call -> Tool Execution -> Response Rendering

1. SessionPrompt.prompt({ sessionID, parts: [{ type: "text", text: "read main.ts" }] })
   |-- createUserMessage() -> write User message + TextPart to Storage
   `-- loop(sessionID)
      |-- start() -> register AbortController
      |-- MessageV2.filterCompacted(stream) -> load uncompacted message history
      |-- Agent.get("build") -> get build Agent configuration
      |-- SessionProcessor.create() -> create empty Assistant message
      |-- resolveSystemPrompt() -> [header, agentPrompt + environment + custom]
      |-- resolveTools() -> register ReadTool, BashTool, EditTool and other built-in tools + MCP tools
      `-- processor.process()
         |-- LLM.stream() -> ai.streamText() initiates streaming request
         |-- Streaming events:
         |  |-- text-delta -> write TextPart, Bus broadcast PartUpdated
         |  |-- tool-input-start -> create ToolPart (status: "pending")
         |  |-- tool-call -> update ToolPart (status: "running", input: { filePath: "main.ts" })
         |  |-- ReadTool.execute() -> read file contents
         |  `-- tool-result -> update ToolPart (status: "completed", output: file contents)
         `-- return "continue" (finish reason is not tool-calls)

2. UI layer subscribes to MessageV2.Event.PartUpdated via Bus for real-time rendering
3. loop() next iteration detects Assistant is complete -> break -> return final message

Chain 2: Context Compaction Triggered -> Summary Generation -> Message Pruning

1. finish-step event in processor.process():
   |-- Session.getUsage() -> calculate token usage: { input: 180000, output: 8000, ... }
   |-- SessionCompaction.isOverflow() -> 180000 + 8000 >= 190000 -> true
   `-- needsCompaction = true -> break interrupts streaming

2. process() returns "compact" -> loop() continues
3. loop() next iteration:
   |-- Detects isOverflow() -> true
   `-- SessionCompaction.create() -> write User message + CompactionPart

4. loop() iterates again:
   |-- Detects pending compaction task
   `-- SessionCompaction.process()
      |-- Agent.get("compaction") -> get compaction-dedicated Agent
      |-- Create Assistant message (mode: "compaction", summary: true)
      |-- SessionProcessor.create()
      `-- processor.process()
         |-- Historical messages + summary instructions -> LLM generates structured summary
         |  (Goal / Instructions / Discoveries / Accomplished / Relevant files)
         `-- return "continue"

5. Subsequent filterCompacted() uses the summary:true message as the pruning point
6. SessionCompaction.prune() -> clean up old tool outputs (preserving the most recent 40000 tokens)

Design Tradeoffs

Why is Agent a Configuration Instead of a Class?

The TypeScript version changed Agent from the Go version’s “interface + implementation” pattern to a pure configuration object. Reasons:

  1. Eliminate code duplication: In the Go version, the Coder Agent and Task Agent had nearly identical execution loops, differing only in their tool sets. In the TS version, all Agents share the SessionPrompt.loop() -> SessionProcessor.process() execution path
  2. Declarative composition: Permissions are combined via PermissionNext.merge(), tools are filtered via ToolRegistry.enabled(), model/temperature are overridden via configuration — no subclassing needed
  3. Runtime extensibility: The generate() method can dynamically create new Agents via LLM, and user configurations can disable built-in Agents or add custom Agents

Why Vercel AI SDK?

The ai package (Vercel AI SDK) provides a unified streamText() API that abstracts away protocol differences between providers (Anthropic, OpenAI, Google, etc.). OpenCode builds on this by injecting its own message transformation logic via wrapLanguageModel() middleware (ProviderTransform.message()), and handling provider-specific parameter formats via ProviderTransform.providerOptions().

Why is prompt.ts So Large (~580 Lines)?

prompt.ts consolidates responsibilities that were spread across agent.go, tools.go, and session.go in the Go version:

  • User message construction (file reading, Agent reference resolution, directory listing)
  • Tool resolution and registration (built-in tools + MCP tools + permission filtering)
  • Subtask direct execution
  • Command (/command) template expansion
  • Shell command execution
  • Automatic title generation

Relationships with Other Modules

  • Provider (src/provider/): LLM.stream() obtains an AI SDK-compatible LanguageModel via Provider.getLanguage(); obtains model metadata (context window, pricing, etc.) via Provider.getModel()
  • Config (src/config/): The Agent list’s state() reads Config.get() to get user-customized Agent configurations and permission overrides
  • Permission (src/permission/): resolveTools() calls PermissionNext.disabled() to filter disabled tools; Doom Loop detection requests user confirmation via PermissionNext.ask()
  • Storage (src/storage/): All messages, Parts, and Sessions are persisted via Storage.write/update/read
  • Bus (src/bus/): Message updates, Part updates, and state changes are all broadcast via Bus; the UI layer subscribes to these events to drive rendering
  • Snapshot (src/snapshot/): SessionProcessor tracks file system snapshots at each step (step-start/step-finish) for diff calculation and revert
  • ToolRegistry (src/tool/): resolveTools() gets the built-in tool list from ToolRegistry.tools(), merging the Agent’s tool enable configuration via ToolRegistry.enabled()
  • MCP (src/mcp/): resolveTools() gets external tools from MCP.tools(), wrapping them in AI SDK tool() format
  • Plugin (src/plugin/): Provides hook points at multiple stages including system prompt construction, before/after tool execution, and chat parameters
  • Instance (src/project/): Instance.state() provides lifecycle management for the Agent list cache; Instance.directory/worktree provides working directory information