Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/node/services/aiService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ function stubCommonStreamMessageDependencies(args: {
agentDefinitions: undefined,
availableSkills: undefined,
ancestorPlanFilePaths: [],
instructionSources: { global: null, context: [] },
});
});
spyOn(messagePipeline, "prepareMessagesForProvider").mockImplementation((pipelineArgs) => {
Expand All @@ -343,6 +344,7 @@ function stubCommonStreamMessageDependencies(args: {
const getToolsForModelSpy = spyOn(toolsModule, "getToolsForModel").mockResolvedValue(
args.allTools ?? {}
);
spyOn(systemMessageModule, "toolInstructionsFromSources").mockReturnValue({});
spyOn(systemMessageModule, "readToolInstructions").mockResolvedValue({});

const providerModelFactory = Reflect.get(args.service, "providerModelFactory") as
Expand Down
112 changes: 61 additions & 51 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import { sumUsageHistory, getTotalCost } from "@/common/utils/tokens/usageAggreg
import { createDisplayUsage } from "@/common/utils/tokens/displayUsage";
import { normalizeToCanonical } from "@/common/utils/ai/models";
import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks";
import { readToolInstructions } from "./systemMessage";
import { toolInstructionsFromSources } from "./systemMessage";
import {
effectiveAdditionalSystemContext,
mergeAdditionalSystemInstructions,
Expand Down Expand Up @@ -1664,6 +1664,7 @@ export class AIService extends EventEmitter {
agentDefinitions,
availableSkills,
ancestorPlanFilePaths,
instructionSources,
} = prePolicyStreamSystemContext;
let systemMessageTokens = prePolicyStreamSystemContext.systemMessageTokens;
let systemMessage = prePolicyStreamSystemContext.systemMessage;
Expand All @@ -1676,32 +1677,68 @@ export class AIService extends EventEmitter {
// Generate stream token and create temp directory for tools
const streamToken = this.streamManager.generateStreamToken();

let mcpTools: Record<string, Tool> | undefined;
let mcpStats: MCPWorkspaceStats | undefined;
let mcpSetupDurationMs = 0;

if (this.mcpServerManager) {
const mcpToolSetupStartedAt = Date.now();
try {
const result = await this.mcpServerManager.getToolsForWorkspace({
workspaceId,
projectPath: metadata.projectPath,
runtime,
workspacePath,
trusted: projectTrusted,
overrides: mcpOverrides,
projectSecrets: await secretsToRecord(projectSecrets, this.opResolver),
});
const mcpToolSetupStartedAt = Date.now();
const createTempDirForStreamStartedAt = Date.now();
const readToolInstructionsStartedAt = Date.now();
const loadSessionUsageStartedAt = Date.now();
const toolInstructions = toolInstructionsFromSources(
instructionSources,
metadata,
capabilityModelString,
agentSystemPromptSections
);
recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt);
const [mcpSetupResult, runtimeTempDir, sessionCostsUsd] = await Promise.all([
this.mcpServerManager
? (async (): Promise<{
tools: Record<string, Tool> | undefined;
stats: MCPWorkspaceStats | undefined;
}> => {
try {
const result = await this.mcpServerManager!.getToolsForWorkspace({
workspaceId,
projectPath: metadata.projectPath,
runtime,
workspacePath,
trusted: projectTrusted,
overrides: mcpOverrides,
projectSecrets: await secretsToRecord(projectSecrets, this.opResolver),
});
return { tools: result.tools, stats: result.stats };
} catch (error) {
workspaceLog.error("Failed to start MCP servers", { error });
return { tools: undefined, stats: undefined };
} finally {
mcpSetupDurationMs = Date.now() - mcpToolSetupStartedAt;
startupPhaseTimingsMs.mcpToolSetupMs = mcpSetupDurationMs;
}
})()
: Promise.resolve({ tools: undefined, stats: undefined }),
this.streamManager.createTempDirForStream(streamToken, runtime).then((tempDir) => {
recordStartupPhaseTiming("createTempDirForStreamMs", createTempDirForStreamStartedAt);
return tempDir;
}),
(async (): Promise<number | undefined> => {
try {
if (!this.sessionUsageService) {
return undefined;
}
const sessionUsage = await this.sessionUsageService.getSessionUsage(workspaceId);
if (!sessionUsage) {
return undefined;
}
const allUsage = sumUsageHistory(Object.values(sessionUsage.byModel));
return getTotalCost(allUsage);
} finally {
recordStartupPhaseTiming("loadSessionUsageMs", loadSessionUsageStartedAt);
}
})(),
]);

mcpTools = result.tools;
mcpStats = result.stats;
} catch (error) {
workspaceLog.error("Failed to start MCP servers", { error });
} finally {
mcpSetupDurationMs = Date.now() - mcpToolSetupStartedAt;
startupPhaseTimingsMs.mcpToolSetupMs = mcpSetupDurationMs;
}
}
const mcpTools = mcpSetupResult.tools;
const mcpStats = mcpSetupResult.stats;

// Tool search (tool-search experiment): assembly-time gate. The runtime
// holder makes getToolsForModel create the tool_catalog_search tool; its `state`
Expand All @@ -1711,33 +1748,6 @@ export class AIService extends EventEmitter {
const toolSearchRuntime: ToolSearchRuntime | undefined =
toolSearchExperimentEnabled && Object.keys(mcpTools ?? {}).length > 0 ? {} : undefined;

const createTempDirForStreamStartedAt = Date.now();
const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime);
recordStartupPhaseTiming("createTempDirForStreamMs", createTempDirForStreamStartedAt);

// Extract tool-specific instructions from AGENTS.md files and agent definition
const readToolInstructionsStartedAt = Date.now();
const toolInstructions = await readToolInstructions(
metadata,
runtime,
workspacePath,
capabilityModelString,
agentSystemPromptSections
);
recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt);

// Calculate cumulative session costs for MUX_COSTS_USD env var
let sessionCostsUsd: number | undefined;
const loadSessionUsageStartedAt = Date.now();
if (this.sessionUsageService) {
const sessionUsage = await this.sessionUsageService.getSessionUsage(workspaceId);
if (sessionUsage) {
const allUsage = sumUsageHistory(Object.values(sessionUsage.byModel));
sessionCostsUsd = getTotalCost(allUsage);
}
}
recordStartupPhaseTiming("loadSessionUsageMs", loadSessionUsageStartedAt);

// Get model-specific tools with workspace path (correct for local or remote)
emitStartupBreadcrumb("loading_tools");
const getToolsForModelStartedAt = Date.now();
Expand Down
159 changes: 72 additions & 87 deletions src/node/services/streamContextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/age
import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain";
import { discoverAgentSkills } from "@/node/services/agentSkills/agentSkillsService";
import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext";
import { buildSystemMessage } from "./systemMessage";
import { buildSystemMessage, loadInstructionSources } from "./systemMessage";
import { resolveWorkspaceRootPath } from "@/node/runtime/runtimeHelpers";
import type { InstructionSources } from "@/common/types/instructions";
import { getTokenizerForModel } from "@/node/utils/main/tokenizer";
import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries";
import { log } from "./log";
Expand Down Expand Up @@ -217,7 +219,6 @@ export async function buildPlanInstructions(
}
}
}

return { effectiveAdditionalInstructions, planFilePath, planContentForTransition };
}

Expand Down Expand Up @@ -292,6 +293,7 @@ export interface StreamSystemContextResult {
availableSkills: Awaited<ReturnType<typeof discoverAgentSkills>> | undefined;
/** Exact ancestor plan files surfaced in the prompt and forwarded through tool configuration. */
ancestorPlanFilePaths: string[];
instructionSources: InstructionSources;
}

const MAX_ANCESTOR_PLAN_PATH_HOPS = 32;
Expand Down Expand Up @@ -441,7 +443,6 @@ function resolveAncestorPlanContext(args: {
});
ancestorPlanFilePaths.push(normalizedPlanFilePath);
}

return {
entries: filteredEntries,
ancestorPlanFilePaths,
Expand Down Expand Up @@ -526,85 +527,79 @@ export async function buildStreamSystemContext(

const workspaceLog = log.withFields({ workspaceId, workspaceName: metadata.name });

// Resolve the body with inheritance (prompt.append merges with base).
// Use agentDefinition.id (may have fallen back to exec) instead of effectiveAgentId.
const resolvedBody = await resolveAgentBody(
agentDiscoveryRuntime,
agentDiscoveryPath,
agentDefinition.id,
{
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
}
);

let subagentAppendPrompt: string | undefined;
if (isSubagentWorkspace) {
try {
const resolvedFrontmatter = await resolveAgentFrontmatter(
agentDiscoveryRuntime,
agentDiscoveryPath,
agentDefinition.id,
{
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
}
);
subagentAppendPrompt = resolvedFrontmatter.subagent?.append_prompt;
} catch (error: unknown) {
workspaceLog.debug("Failed to resolve agent frontmatter for subagent append_prompt", {
agentId: agentDefinition.id,
error: getErrorMessage(error),
});
}
}
const skillCtx = resolveSkillStorageContext({
runtime,
workspacePath,
muxScope,
includeClaudeSkills: opts.claudeSkillsCompatEnabled,
});
const workspaceRootPath = metadata.subProjectPath?.trim()
? resolveWorkspaceRootPath(metadata, runtime)
: workspacePath;

const [
resolvedBody,
subagentAppendPrompt,
agentDefinitions,
availableSkills,
instructionSources,
] = await Promise.all([
Comment thread
ammar-agent marked this conversation as resolved.
resolveAgentBody(agentDiscoveryRuntime, agentDiscoveryPath, agentDefinition.id, {
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
}),
isSubagentWorkspace
? (async (): Promise<string | undefined> => {
try {
const resolvedFrontmatter = await resolveAgentFrontmatter(
agentDiscoveryRuntime,
agentDiscoveryPath,
agentDefinition.id,
{
skipScopesAbove: getSkipScopesAboveForKnownScope(agentDefinition.scope),
}
);
return resolvedFrontmatter.subagent?.append_prompt;
} catch (error: unknown) {
workspaceLog.debug("Failed to resolve agent frontmatter for subagent append_prompt", {
agentId: agentDefinition.id,
error: getErrorMessage(error),
});
return undefined;
}
})()
: Promise.resolve(undefined),
!isSubagentWorkspace
? discoverAvailableSubagentsForToolContext({
runtime: agentDiscoveryRuntime,
workspacePath: agentDiscoveryPath,
cfg,
loadDesktopCapability,
})
: Promise.resolve(undefined),
(async () => {
try {
return await discoverAgentSkills(skillCtx.runtime, skillCtx.workspacePath, {
roots: skillCtx.roots,
containment: skillCtx.containment,
includeClaudeSkills: opts.claudeSkillsCompatEnabled,
});
} catch (error) {
workspaceLog.warn("Failed to discover agent skills for tool description", { error });
return undefined;
}
})(),
loadInstructionSources(metadata, runtime, workspaceRootPath),
]);
const agentSystemPromptSections = [resolvedBody];
if (isSubagentWorkspace && subagentAppendPrompt) {
agentSystemPromptSections.push(subagentAppendPrompt);
}
if (advisorToolAvailable) {
// Keep prompt guidance in lockstep with actual tool availability for the agent.
agentSystemPromptSections.push(buildAdvisorGuidanceSection());
}
if (opts.memoryToolAvailable) {
// Same lockstep rule: the post-policy system-context rebuild strips this
// section when tool policy removes the memory tool.
agentSystemPromptSections.push(buildMemoryGuidanceSection());
}

// Discover available agent definitions for sub-agent context (only for top-level workspaces).
//
// NOTE: discoverAgentDefinitions returns disabled agents too, so Settings can surface them.
// For tool descriptions (task tool), filter to agents that are effectively enabled.
let agentDefinitions: Awaited<ReturnType<typeof discoverAgentDefinitions>> | undefined;
if (!isSubagentWorkspace) {
agentDefinitions = await discoverAvailableSubagentsForToolContext({
runtime: agentDiscoveryRuntime,
workspacePath: agentDiscoveryPath,
cfg,
loadDesktopCapability,
});
}

// Discover available skills for tool description context
const skillCtx = resolveSkillStorageContext({
runtime,
workspacePath,
muxScope,
includeClaudeSkills: opts.claudeSkillsCompatEnabled,
});

let availableSkills: Awaited<ReturnType<typeof discoverAgentSkills>> | undefined;
try {
availableSkills = await discoverAgentSkills(skillCtx.runtime, skillCtx.workspacePath, {
roots: skillCtx.roots,
containment: skillCtx.containment,
// Used only for the project-runtime default-roots fallback (skillCtx.roots undefined).
includeClaudeSkills: opts.claudeSkillsCompatEnabled,
});
} catch (error) {
workspaceLog.warn("Failed to discover agent skills for tool description", { error });
}

const ancestorPlanContext = resolveAncestorPlanContext({
metadata,
workspaceId,
Expand All @@ -618,43 +613,33 @@ export async function buildStreamSystemContext(
formatAncestorPlanPathInstructions(ancestorPlanContext.entries),
effectiveAdditionalInstructions
);

// Build system message from workspace metadata
let systemMessage = await buildSystemMessage(
metadata,
runtime,
workspacePath,
mergedAdditionalInstructions,
modelString,
mcpServers,
// "Mode: <mode>" sections in Mux-dedicated instruction sources match the
// effective mode (so "Mode: plan" also covers custom plan-like agents)
// and the agent id (so per-agent sections work). The effective mode names
// the injected <mode-...> tag; agentDefinition.id (may have fallen back
// to exec) is the prompt actually in effect.
{ agentSystemPromptSections, modes: [effectiveMode, agentDefinition.id] }
{
agentSystemPromptSections,
modes: [effectiveMode, agentDefinition.id],
instructionSources,
}
);

// Append the hot-memories block (memory-hot-set sub-experiment). Placed at
// the end of the system message so the most recent stable prompt prefix
// stays byte-identical for provider prompt caching. The memory index lives
// in the memory tool description (same disclosure mechanic as skills).
if (opts.memoryToolAvailable && opts.hotMemoriesBlock) {
systemMessage = `${systemMessage}\n\n${opts.hotMemoriesBlock}`;
}

// Count system message tokens for cost tracking
const metadataModel = resolveModelForMetadata(modelString, providersConfig ?? null);
const tokenizer = await getTokenizerForModel(modelString, metadataModel);
const systemMessageTokens = await tokenizer.countTokens(systemMessage);

return {
agentSystemPromptSections,
systemMessage,
systemMessageTokens,
agentDefinitions,
availableSkills,
ancestorPlanFilePaths: ancestorPlanContext.ancestorPlanFilePaths,
instructionSources,
};
}

Expand Down
Loading
Loading