Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,7 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'

- When Plan Mode is requested, assume the user wants the actual completed plan; do not merely describe how you would devise one.
- Attach a net LoC estimate (product code only) to each recommended approach.

## Tool: status_set

- Set status url to the Pull Request once opened
95 changes: 57 additions & 38 deletions docs/instruction-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,73 +11,65 @@ Priority within each location: `AGENTS.md` → `AGENT.md` → `CLAUDE.md` (first

> **Note:** mux strips HTML-style markdown comments (`<!-- ... -->`) from instruction files before sending them to the model. Use these comments for editor-only metadata—they will not reach the agent.

## Mode Prompts
## Scoped Instructions

> Use mode-specific sections to optimize context and customize the behavior specific modes.
mux supports **Scoped Instructions** that activate only in specific contexts. You define them using special headings in your instruction files:

mux reads mode context from sections inside your instruction files. Add a heading titled:
- `Mode: <mode>` — Active only in specific interaction modes (e.g., plan, exec).
- `Model: <regex>` — Active only for specific models (e.g., GPT-4, Claude).
- `Tool: <tool_name>` — Appended to the description of specific tools.

- `Mode: <mode>` (case-insensitive), at any heading level (`#` .. `######`)
### General Rules

Rules:
- **Precedence**: Workspace instructions (`<workspace>/AGENTS.md`) are checked first, then global instructions (`~/.mux/AGENTS.md`).
- **First Match Wins**: Only the _first_ matching section found is used. Overriding global defaults is as simple as defining the same section in your workspace.
- **Isolation**: These sections are **stripped** from the general `<custom-instructions>` block. Their content is injected only where it belongs (e.g., into a specific tool's description or a special XML tag).
- **Boundaries**: A section's content includes everything until the next heading of the same or higher level.

- Workspace instructions are checked first, then global instructions
- The first matching section wins (at most one section is used)
- The section's content is everything until the next heading of the same or higher level
- Mode sections are stripped from the general `<custom-instructions>` block; only the active mode's content is re-sent via its `<mode>` tag.
- Missing sections are ignored (no error)
---

<!-- Note to developers: This behavior is implemented in src/services/systemMessage.ts (search for extractModeSection). Keep this documentation in sync with code changes. -->
### Mode Prompts

Example (in either `~/.mux/AGENTS.md` or `my-project/AGENTS.md`):
Use mode-specific sections to optimize context and customize behavior for specific workflow stages. The active mode's content is injected via a `<mode>` tag.

**Syntax**: `Mode: <mode>` (case-insensitive)

**Example**:

```markdown
# General Instructions

- Be concise
- Prefer TDD

## Mode: Plan

When planning:

- Focus on goals, constraints, and trade-offs
- Focus on goals and trade-offs
- Propose alternatives with pros/cons
- Defer implementation detail unless asked

## Mode: Compact

When compacting conversation history:

- Preserve key decisions and their rationale
- Keep code snippets that are still relevant
- Maintain context about ongoing tasks
- Be extremely concise—prioritize information density
- Preserve key decisions
- Be extremely concise
```

### Available modes

- **exec** - Default mode for normal operations
- **plan** - Activated when the user toggles plan mode in the UI
- **compact** - Automatically used during `/compact` operations to guide how the AI summarizes conversation history

Customizing the `compact` mode is particularly useful for controlling what information is preserved during automatic history compaction.
**Available modes**:

## Model Prompts
- **exec** (default) — Normal operations.
- **plan** — Active in Plan Mode.
- **compact** — Used during `/compact` to guide history summarization.

Similar to modes, mux reads headings titled `Model: <regex>` to scope instructions to specific models or families. The `<regex>` is matched against the full model identifier (for example, `openai:gpt-5.1-codex`).
### Model Prompts

Rules:
Scope instructions to specific models or families using regex matching. The matched content is injected via a `<model-...>` tag.

- Workspace instructions are evaluated before global instructions; the first matching section wins.
- Regexes are case-insensitive by default. Use `/pattern/flags` syntax to opt into custom flags (e.g., `/openai:.*codex/i`).
- Invalid regex patterns are ignored instead of breaking the parse.
- Model sections are also removed from `<custom-instructions>`; only the first regex match (if any) is injected via its `<model-…>` tag.
- Only the content under the first matching heading is injected.
**Syntax**: `Model: <regex>`

<!-- Developers: See extractModelSection in src/node/utils/main/markdown.ts for the implementation. -->
- Regexes are case-insensitive by default.
- Use `/pattern/flags` for custom flags (e.g., `/openai:.*codex/i`).

Example:
**Example**:

```markdown
## Model: sonnet
Expand All @@ -89,6 +81,33 @@ Be terse and to the point.
Use status reporting tools every few minutes.
```

### Tool Prompts

Customize how the AI uses specific tools by appending instructions to their descriptions.

**Syntax**: `Tool: <tool_name>`

- Tool names must match exactly (case-insensitive).
- Only tools available for the active model are augmented.

**Example**:

```markdown
## Tool: bash

- Use `rg` instead of `grep` for file searching

## Tool: file_edit_replace_string

- Run `prettier --write` after editing files

# Tool: status_set

- Set status url to the Pull Request once opened
```

**Available tools**: `bash`, `file_read`, `file_edit_replace_string`, `file_edit_insert`, `propose_plan`, `todo_write`, `todo_read`, `status_set`, `web_search`.

## Practical layout

```
Expand Down
50 changes: 45 additions & 5 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ export interface ToolConfiguration {
*/
export type ToolFactory = (config: ToolConfiguration) => Tool;

/**
* Augment a tool's description with additional instructions from "Tool: <name>" sections
* Mutates the base tool in place to append the instructions to its description.
* This preserves any provider-specific metadata or internal state on the tool object.
* @param baseTool The original tool to augment
* @param additionalInstructions Additional instructions to append to the description
* @returns The same tool instance with the augmented description
*/
function augmentToolDescription(baseTool: Tool, additionalInstructions: string): Tool {
// Access the tool as a record to get its properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseToolRecord = baseTool as any as Record<string, unknown>;
const originalDescription =
typeof baseToolRecord.description === "string" ? baseToolRecord.description : "";
const augmentedDescription = `${originalDescription}\n\n${additionalInstructions}`;

// Mutate the description in place to preserve other properties (e.g. provider metadata)
baseToolRecord.description = augmentedDescription;

return baseTool;
}

/**
* Get tools available for a specific model with configuration
*
Expand All @@ -46,13 +68,15 @@ export type ToolFactory = (config: ToolConfiguration) => Tool;
* @param config Required configuration for tools
* @param workspaceId Workspace ID for init state tracking (required for runtime tools)
* @param initStateManager Init state manager for runtime tools to wait for initialization
* @param toolInstructions Optional map of tool names to additional instructions from "Tool: <name>" sections
* @returns Promise resolving to record of tools available for the model
*/
export async function getToolsForModel(
modelString: string,
config: ToolConfiguration,
workspaceId: string,
initStateManager: InitStateManager
initStateManager: InitStateManager,
toolInstructions?: Record<string, string>
): Promise<Record<string, Tool>> {
const [provider, modelId] = modelString.split(":");

Expand Down Expand Up @@ -89,21 +113,23 @@ export async function getToolsForModel(

// Try to add provider-specific web search tools if available
// Lazy-load providers to avoid loading all AI SDKs at startup
let allTools = baseTools;
try {
switch (provider) {
case "anthropic": {
const { anthropic } = await import("@ai-sdk/anthropic");
return {
allTools = {
...baseTools,
web_search: anthropic.tools.webSearch_20250305({ maxUses: 1000 }),
};
break;
}

case "openai": {
// Only add web search for models that support it
if (modelId.includes("gpt-5") || modelId.includes("gpt-4")) {
const { openai } = await import("@ai-sdk/openai");
return {
allTools = {
...baseTools,
web_search: openai.tools.webSearch({
searchContextSize: "high",
Expand All @@ -119,9 +145,23 @@ export async function getToolsForModel(
// - https://ai.google.dev/gemini-api/docs/function-calling?example=meeting#native-tools
}
} catch (error) {
// If tools aren't available, just return base tools
// If tools aren't available, just use base tools
log.error(`No web search tools available for ${provider}:`, error);
}

return baseTools;
// Apply tool-specific instructions if provided
if (toolInstructions) {
const augmentedTools: Record<string, Tool> = {};
for (const [toolName, baseTool] of Object.entries(allTools)) {
const instructions = toolInstructions[toolName];
if (instructions) {
augmentedTools[toolName] = augmentToolDescription(baseTool, instructions);
} else {
augmentedTools[toolName] = baseTool;
}
}
return augmentedTools;
}

return allTools;
}
13 changes: 11 additions & 2 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
import { applyCacheControl } from "@/common/utils/ai/cacheStrategy";
import type { HistoryService } from "./historyService";
import type { PartialService } from "./partialService";
import { buildSystemMessage } from "./systemMessage";
import { buildSystemMessage, readToolInstructions } from "./systemMessage";
import { getTokenizerForModel } from "@/node/utils/main/tokenizer";
import { buildProviderOptions } from "@/common/utils/ai/providerOptions";
import type { ThinkingLevel } from "@/common/types/thinking";
Expand Down Expand Up @@ -746,6 +746,14 @@ export class AIService extends EventEmitter {
const streamToken = this.streamManager.generateStreamToken();
const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime);

// Extract tool-specific instructions from AGENTS.md files
const toolInstructions = await readToolInstructions(
metadata,
runtime,
workspacePath,
modelString
);

// Get model-specific tools with workspace path (correct for local or remote)
const allTools = await getToolsForModel(
modelString,
Expand All @@ -756,7 +764,8 @@ export class AIService extends EventEmitter {
runtimeTempDir,
},
workspaceId,
this.initStateManager
this.initStateManager,
toolInstructions
);

// Apply tool policy to filter tools (if policy provided)
Expand Down
90 changes: 86 additions & 4 deletions src/node/services/systemMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
import {
extractModeSection,
extractModelSection,
extractToolSection,
stripScopedInstructionSections,
} from "@/node/utils/main/markdown";
import type { Runtime } from "@/node/runtime/Runtime";
import { getMuxHome } from "@/common/constants/paths";
import { getAvailableTools } from "@/common/utils/tools/toolDefinitions";

// NOTE: keep this in sync with the docs/models.md file

Expand Down Expand Up @@ -77,6 +79,85 @@ function getSystemDirectory(): string {
return getMuxHome();
}

/**
* Extract tool-specific instructions from instruction sources.
* Searches context (workspace/project) first, then falls back to global instructions.
*
* @param globalInstructions Global instructions from ~/.mux/AGENTS.md
* @param contextInstructions Context instructions from workspace/project AGENTS.md
* @param modelString Active model identifier to determine available tools
* @returns Map of tool names to their additional instructions
*/
export function extractToolInstructions(
globalInstructions: string | null,
contextInstructions: string | null,
modelString: string
): Record<string, string> {
const availableTools = getAvailableTools(modelString);
const toolInstructions: Record<string, string> = {};

for (const toolName of availableTools) {
// Try context instructions first, then global
const content =
(contextInstructions && extractToolSection(contextInstructions, toolName)) ??
(globalInstructions && extractToolSection(globalInstructions, toolName)) ??
null;

if (content) {
toolInstructions[toolName] = content;
}
}

return toolInstructions;
}

/**
* Read instruction sources and extract tool-specific instructions.
* Convenience wrapper that combines readInstructionSources and extractToolInstructions.
*
* @param metadata - Workspace metadata (contains projectPath)
* @param runtime - Runtime for reading workspace files (supports SSH)
* @param workspacePath - Workspace directory path
* @param modelString - Active model identifier to determine available tools
* @returns Map of tool names to their additional instructions
*/
export async function readToolInstructions(
metadata: WorkspaceMetadata,
runtime: Runtime,
workspacePath: string,
modelString: string
): Promise<Record<string, string>> {
const [globalInstructions, contextInstructions] = await readInstructionSources(
metadata,
runtime,
workspacePath
);

return extractToolInstructions(globalInstructions, contextInstructions, modelString);
}

/**
* Read instruction sets from global and context sources.
* Internal helper for buildSystemMessage and extractToolInstructions.
*
* @param metadata - Workspace metadata (contains projectPath)
* @param runtime - Runtime for reading workspace files (supports SSH)
* @param workspacePath - Workspace directory path
* @returns Tuple of [globalInstructions, contextInstructions]
*/
async function readInstructionSources(
metadata: WorkspaceMetadata,
runtime: Runtime,
workspacePath: string
): Promise<[string | null, string | null]> {
const globalInstructions = await readInstructionSet(getSystemDirectory());
const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath);
const contextInstructions =
workspaceInstructions ?? (await readInstructionSet(metadata.projectPath));

return [globalInstructions, contextInstructions];
}

/**
* Builds a system message for the AI model by combining instruction sources.
*
Expand Down Expand Up @@ -108,10 +189,11 @@ export async function buildSystemMessage(
if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required");

// Read instruction sets
const globalInstructions = await readInstructionSet(getSystemDirectory());
const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath);
const contextInstructions =
workspaceInstructions ?? (await readInstructionSet(metadata.projectPath));
const [globalInstructions, contextInstructions] = await readInstructionSources(
metadata,
runtime,
workspacePath
);

// Combine: global + context (workspace takes precedence over project) after stripping scoped sections
const sanitizeScopedInstructions = (input?: string | null): string | undefined => {
Expand Down
Loading