diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 262cbb4a8..b5ad51f6a 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -155,3 +155,7 @@ gh pr view --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 diff --git a/docs/instruction-files.md b/docs/instruction-files.md index 7f0caf589..3264ce731 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -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: ` — Active only in specific interaction modes (e.g., plan, exec). +- `Model: ` — Active only for specific models (e.g., GPT-4, Claude). +- `Tool: ` — Appended to the description of specific tools. -- `Mode: ` (case-insensitive), at any heading level (`#` .. `######`) +### General Rules -Rules: +- **Precedence**: Workspace instructions (`/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 `` 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 `` block; only the active mode's content is re-sent via its `` tag. -- Missing sections are ignored (no error) +--- - +### 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 `` tag. + +**Syntax**: `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: ` to scope instructions to specific models or families. The `` 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 `` 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 ``; only the first regex match (if any) is injected via its `` tag. -- Only the content under the first matching heading is injected. +**Syntax**: `Model: ` - +- Regexes are case-insensitive by default. +- Use `/pattern/flags` for custom flags (e.g., `/openai:.*codex/i`). -Example: +**Example**: ```markdown ## Model: sonnet @@ -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 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 ``` diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index a6f8f46da..1017be496 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -36,6 +36,28 @@ export interface ToolConfiguration { */ export type ToolFactory = (config: ToolConfiguration) => Tool; +/** + * Augment a tool's description with additional instructions from "Tool: " 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; + 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 * @@ -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: " 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 ): Promise> { const [provider, modelId] = modelString.split(":"); @@ -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", @@ -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 = {}; + 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; } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index fc2997256..6cb748f91 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -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"; @@ -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, @@ -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) diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 3a0b6e40c..6817c02ba 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -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 @@ -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 { + const availableTools = getAvailableTools(modelString); + const toolInstructions: Record = {}; + + 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> { + 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. * @@ -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 => { diff --git a/src/node/utils/main/markdown.test.ts b/src/node/utils/main/markdown.test.ts index b0129a4c3..ebd7131b6 100644 --- a/src/node/utils/main/markdown.test.ts +++ b/src/node/utils/main/markdown.test.ts @@ -1,4 +1,4 @@ -import { extractModeSection } from "./markdown"; +import { extractModeSection, extractToolSection, stripScopedInstructionSections } from "./markdown"; describe("extractModeSection", () => { describe("basic extraction", () => { @@ -259,3 +259,215 @@ const x = 1; }); }); }); + +describe("extractToolSection", () => { + describe("basic extraction", () => { + it("should extract content under Tool: bash heading", () => { + const markdown = ` +# General Instructions +Some general content + +# Tool: bash +Use bash conservatively +Prefer single commands + +# Other Section +Other content +`.trim(); + + const result = extractToolSection(markdown, "bash"); + expect(result).toBe("Use bash conservatively\nPrefer single commands"); + }); + + it("should return null when tool section doesn't exist", () => { + const markdown = ` +# General Instructions +Some content + +# Other Section +Other content +`.trim(); + + const result = extractToolSection(markdown, "bash"); + expect(result).toBeNull(); + }); + + it("should return null for empty markdown", () => { + expect(extractToolSection("", "bash")).toBeNull(); + }); + + it("should return null for empty tool name", () => { + expect(extractToolSection("# Tool: bash\nContent", "")).toBeNull(); + }); + }); + + describe("case insensitivity", () => { + it("should match case-insensitive heading", () => { + const markdown = "# TOOL: BASH\nContent here"; + const result = extractToolSection(markdown, "bash"); + expect(result).toBe("Content here"); + }); + + it("should match mixed case heading", () => { + const markdown = "# ToOl: BaSh\nContent here"; + const result = extractToolSection(markdown, "bash"); + expect(result).toBe("Content here"); + }); + + it("should match with case-insensitive tool name parameter", () => { + const markdown = "# Tool: bash\nContent here"; + const result = extractToolSection(markdown, "BASH"); + expect(result).toBe("Content here"); + }); + }); + + describe("multiple tools", () => { + it("should extract specific tool section", () => { + const markdown = ` +# Tool: bash +Bash instructions + +# Tool: file_read +File read instructions + +# Tool: propose_plan +Plan instructions +`.trim(); + + expect(extractToolSection(markdown, "bash")).toBe("Bash instructions"); + expect(extractToolSection(markdown, "file_read")).toBe("File read instructions"); + expect(extractToolSection(markdown, "propose_plan")).toBe("Plan instructions"); + }); + + it("should return only first matching section", () => { + const markdown = ` +# Tool: bash +First bash section + +# Other Section +Other content + +# Tool: bash +Second bash section (should be ignored) +`.trim(); + + const result = extractToolSection(markdown, "bash"); + expect(result).toBe("First bash section"); + expect(result).not.toContain("Second bash section"); + }); + }); + + describe("tool names with underscores", () => { + it("should handle file_read tool", () => { + const markdown = "# Tool: file_read\nRead instructions"; + expect(extractToolSection(markdown, "file_read")).toBe("Read instructions"); + }); + + it("should handle file_edit_replace_string tool", () => { + const markdown = "# Tool: file_edit_replace_string\nReplace instructions"; + expect(extractToolSection(markdown, "file_edit_replace_string")).toBe("Replace instructions"); + }); + }); +}); + +describe("stripScopedInstructionSections", () => { + it("should strip Mode sections", () => { + const markdown = ` +# General +General content + +# Mode: plan +Plan content + +# More General +More general content +`.trim(); + + const result = stripScopedInstructionSections(markdown); + expect(result).toContain("General content"); + expect(result).toContain("More general content"); + expect(result).not.toContain("Plan content"); + }); + + it("should strip Model sections", () => { + const markdown = ` +# General +General content + +# Model: gpt-4 +Model-specific content + +# More General +More general content +`.trim(); + + const result = stripScopedInstructionSections(markdown); + expect(result).toContain("General content"); + expect(result).toContain("More general content"); + expect(result).not.toContain("Model-specific content"); + }); + + it("should strip Tool sections", () => { + const markdown = ` +# General +General content + +# Tool: bash +Tool-specific content + +# More General +More general content +`.trim(); + + const result = stripScopedInstructionSections(markdown); + expect(result).toContain("General content"); + expect(result).toContain("More general content"); + expect(result).not.toContain("Tool-specific content"); + }); + + it("should strip all scoped sections together", () => { + const markdown = ` +# General +General content + +# Mode: plan +Plan content + +# Model: gpt-4 +Model content + +# Tool: bash +Tool content + +# More General +More general content +`.trim(); + + const result = stripScopedInstructionSections(markdown); + expect(result).toContain("General content"); + expect(result).toContain("More general content"); + expect(result).not.toContain("Plan content"); + expect(result).not.toContain("Model content"); + expect(result).not.toContain("Tool content"); + }); + + it("should return empty string for markdown with only scoped sections", () => { + const markdown = ` +# Mode: plan +Plan content + +# Model: gpt-4 +Model content + +# Tool: bash +Tool content +`.trim(); + + const result = stripScopedInstructionSections(markdown); + expect(result.trim()).toBe(""); + }); + + it("should handle empty markdown", () => { + expect(stripScopedInstructionSections("")).toBe(""); + }); +}); diff --git a/src/node/utils/main/markdown.ts b/src/node/utils/main/markdown.ts index ba4ee7542..14cc88c0f 100644 --- a/src/node/utils/main/markdown.ts +++ b/src/node/utils/main/markdown.ts @@ -130,11 +130,28 @@ export function extractModelSection(markdown: string, modelId: string): string | }); } +/** + * Extract the content under a heading titled "Tool: " (case-insensitive). + */ +export function extractToolSection(markdown: string, toolName: string): string | null { + if (!markdown || !toolName) return null; + + const expectedHeading = `tool: ${toolName}`.toLowerCase(); + return extractSectionByHeading( + markdown, + (headingText) => headingText.toLowerCase() === expectedHeading + ); +} + export function stripScopedInstructionSections(markdown: string): string { if (!markdown) return markdown; return removeSectionsByHeading(markdown, (headingText) => { const normalized = headingText.trim().toLowerCase(); - return normalized.startsWith("mode:") || normalized.startsWith("model:"); + return ( + normalized.startsWith("mode:") || + normalized.startsWith("model:") || + normalized.startsWith("tool:") + ); }); }