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
54 changes: 51 additions & 3 deletions src/node/services/aiService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,17 @@ function stubCommonStreamMessageDependencies(args: {
useRequestedModelString?: boolean;
onPlanPayloadMessageIds?: (messageIds: string[]) => void;
onBuildStreamSystemContext?: (
args: Parameters<typeof streamContextBuilder.buildStreamSystemContext>[0]
args: Parameters<typeof streamContextBuilder.buildStreamSystemContext>[0],
instructionSources: Awaited<
ReturnType<typeof streamContextBuilder.buildStreamSystemContext>
>["instructionSources"]
) => void;
onPrepareMessagesForProvider?: (
args: Parameters<typeof messagePipeline.prepareMessagesForProvider>[0]
) => void;
onExtractToolInstructions?: (
sources: Parameters<typeof systemMessageModule.extractToolInstructionsFromSources>[0]
) => void;
}): ReturnType<typeof spyOn<typeof toolsModule, "getToolsForModel">> {
spyOn(agentResolution, "resolveAgentForStream").mockResolvedValue(
resolvedAgentResultFor(args.metadata)
Expand All @@ -322,8 +328,10 @@ function stubCommonStreamMessageDependencies(args: {
});
});
spyOn(streamContextBuilder, "buildStreamSystemContext").mockImplementation((contextArgs) => {
args.onBuildStreamSystemContext?.(contextArgs);
const instructionSources = { global: [], context: [] };
args.onBuildStreamSystemContext?.(contextArgs, instructionSources);
return Promise.resolve({
instructionSources,
agentSystemPromptSections: ["test-agent-prompt"],
systemMessage: "test-system-message",
systemMessageTokens: 1,
Expand All @@ -343,7 +351,10 @@ function stubCommonStreamMessageDependencies(args: {
const getToolsForModelSpy = spyOn(toolsModule, "getToolsForModel").mockResolvedValue(
args.allTools ?? {}
);
spyOn(systemMessageModule, "readToolInstructions").mockResolvedValue({});
spyOn(systemMessageModule, "extractToolInstructionsFromSources").mockImplementation((sources) => {
args.onExtractToolInstructions?.(sources);
return {};
});

const providerModelFactory = Reflect.get(args.service, "providerModelFactory") as
| ProviderModelFactory
Expand Down Expand Up @@ -1442,6 +1453,43 @@ describe("AIService.streamMessage compaction boundary slicing", () => {
mock.restore();
});

it("reuses the stream context instruction snapshot for tool extraction", async () => {
using xumHome = new DisposableTempDir("ai-service-instruction-snapshot");
const projectPath = path.join(xumHome.path, "project");
await fs.mkdir(projectPath, { recursive: true });

const workspaceId = "workspace-instruction-snapshot";
const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath);
const { config, historyService, initStateManager, service } = createBasicAIService(
xumHome.path
);
let builtSources: unknown;
let extractedSources: unknown;
stubCommonStreamMessageDependencies({
service,
config,
historyService,
initStateManager,
metadata,
onBuildStreamSystemContext: (_args, instructionSources) => {
builtSources = instructionSources;
},
onExtractToolInstructions: (instructionSources) => {
extractedSources = instructionSources;
},
});

const result = await service.streamMessage({
messages: [createMuxMessage("latest-user", "user", "hello")],
workspaceId,
modelString: "openai:gpt-5.2",
thinkingLevel: "off",
});

expect(result.success).toBe(true);
expect(extractedSources).toBe(builtSources);
});

it("keeps set_goal disabled for one-shot streams that do not opt into agent-created goals", async () => {
using xumHome = new DisposableTempDir("ai-service-set-goal-disabled");
const projectPath = path.join(xumHome.path, "project");
Expand Down
28 changes: 18 additions & 10 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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 { extractToolInstructionsFromSources } from "./systemMessage";
import {
effectiveAdditionalSystemContext,
mergeAdditionalSystemInstructions,
Expand Down Expand Up @@ -176,7 +176,11 @@ import { DEVTOOLS_RUN_METADATA_ID_HEADER } from "./devToolsHeaderCapture";
import { ProviderModelFactory, modelCostsIncluded } from "./providerModelFactory";
import { prepareMessagesForProvider } from "./messagePipeline";
import { getLegacyModeForAgentMetadata, resolveAgentForStream } from "./agentResolution";
import { buildPlanInstructions, buildStreamSystemContext } from "./streamContextBuilder";
import {
buildPlanInstructions,
buildStreamSystemContext,
type StreamSystemContextResult,
} from "./streamContextBuilder";
import { getTokenizerForModel } from "@/node/utils/main/tokenizer";
import {
normalizeUsageModelKey,
Expand Down Expand Up @@ -2142,12 +2146,16 @@ export class AIService extends EventEmitter {
// service); tool policy may still strip the tool, which forces a rebuild
// below so the prompt never advertises an absent tool.
const memoryToolEligible = memoryExperimentEnabled && this.memoryService !== undefined;
const startupInstructionSources: {
current?: StreamSystemContextResult["instructionSources"];
} = {};
const buildStreamSystemContextForToolset = (
toolset: { advisorToolAvailable: boolean; memoryToolAvailable: boolean },
modelStringForSystem: string = modelString,
contextForModel: MemorySessionContext | undefined = memoryContext
) =>
buildStreamSystemContext({
instructionSources: startupInstructionSources.current,
runtime,
metadata,
workspacePath,
Expand Down Expand Up @@ -2180,8 +2188,10 @@ export class AIService extends EventEmitter {
advisorToolAvailable: advisorToolEligible,
memoryToolAvailable: memoryToolEligible,
});
startupInstructionSources.current = prePolicyStreamSystemContext.instructionSources;
recordStartupPhaseTiming("buildStreamSystemContextMs", buildStreamSystemContextStartedAt);
const {
instructionSources,
agentSystemPromptSections,
agentDefinitions,
availableSkills,
Expand Down Expand Up @@ -2250,16 +2260,14 @@ export class AIService extends EventEmitter {
const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime);
recordStartupPhaseTiming("createTempDirForStreamMs", createTempDirForStreamStartedAt);

// Extract tool-specific instructions from AGENTS.md files and agent definition
// Extract tool-specific instructions from the same source snapshot used
// for the system message so instruction files are not read twice.
const readToolInstructionsStartedAt = Date.now();
const toolInstructions = await readToolInstructions(
metadata,
runtime,
workspacePath,
const toolInstructions = extractToolInstructionsFromSources(
instructionSources,
capabilityModelString,
agentSystemPromptSections,
cfg.projects,
claudeSkillsCompatExperimentEnabled
metadata,
agentSystemPromptSections
);
recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt);

Expand Down
45 changes: 45 additions & 0 deletions src/node/services/streamContextBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getPlanFilePath } from "@/common/utils/planStorage";
import { LocalRuntime } from "@/node/runtime/LocalRuntime";
import { DisposableTempDir } from "@/node/services/tempDir";

import { extractToolInstructionsFromSources } from "./systemMessage";
import { buildPlanInstructions, buildStreamSystemContext } from "./streamContextBuilder";

class TestRuntime extends LocalRuntime {
Expand Down Expand Up @@ -262,6 +263,50 @@ class RestrictedTestRuntime extends TestRuntime {
}

describe("buildStreamSystemContext", () => {
test("returns the instruction source snapshot used to build the system message", async () => {
using tempRoot = new DisposableTempDir("stream-system-context-instruction-snapshot");

const projectPath = path.join(tempRoot.path, "project");
const xumHome = path.join(tempRoot.path, "xum-home");
await fs.mkdir(path.join(projectPath, ".xum"), { recursive: true });
await fs.mkdir(xumHome, { recursive: true });
await fs.writeFile(
path.join(projectPath, ".xum", "AGENTS.md"),
["Project prompt guidance.", "", "## Tool: bash", "Use the project bash workflow.", ""].join(
"\n"
)
);

const metadata = createWorkspaceMetadata({
id: "instruction-snapshot-ws",
name: "instruction-snapshot-workspace",
projectName: "project",
projectPath,
});
const cfg = createProjectsConfig({
projectPath,
workspaces: [{ id: metadata.id, name: metadata.name }],
});

const result = await buildSystemContextForTest({
runtime: new TestRuntime(projectPath, xumHome),
metadata,
workspacePath: projectPath,
cfg,
isSubagentWorkspace: false,
});

expect(result.systemMessage).toContain("Project prompt guidance.");
expect(
extractToolInstructionsFromSources(
result.instructionSources,
"openai:gpt-5.2",
metadata,
result.agentSystemPromptSections
).bash
).toContain("Use the project bash workflow.");
});

test("includes proactive memory guidance only when the memory tool is available", async () => {
using tempRoot = new DisposableTempDir("stream-system-context-memory-guidance");

Expand Down
28 changes: 22 additions & 6 deletions src/node/services/streamContextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { DesktopCapability } from "@/common/types/desktop";
import type { ProjectsConfig } from "@/common/types/project";
import type { XumToolScope } from "@/common/types/toolScope";
import type { AgentDefinitionScope } from "@/common/types/agentDefinition";
import type { InstructionSources } from "@/common/types/instructions";
import type { WorkspaceMetadata } from "@/common/types/workspace";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import type { TaskSettings } from "@/common/types/tasks";
Expand All @@ -43,7 +44,7 @@ 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 { buildSystemMessageFromSources, loadWorkspaceInstructionSources } from "./systemMessage";
import { getTokenizerForModel } from "@/node/utils/main/tokenizer";
import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries";
import { log } from "./log";
Expand Down Expand Up @@ -227,6 +228,8 @@ export async function buildPlanInstructions(

/** Options for building the system message context. */
export interface BuildStreamSystemContextOptions {
/** Reuse a source snapshot across policy/model-driven context rebuilds in one startup. */
instructionSources?: InstructionSources;
runtime: Runtime;
metadata: WorkspaceMetadata;
workspacePath: string;
Expand All @@ -249,7 +252,7 @@ export interface BuildStreamSystemContextOptions {
modelString: string;
cfg: ProjectsConfig;
providersConfig?: ProvidersConfigMap | null;
mcpServers: Parameters<typeof buildSystemMessage>[5];
mcpServers: Parameters<typeof buildSystemMessageFromSources>[5];
xumScope?: XumToolScope;
loadDesktopCapability?: () => Promise<DesktopCapability>;
/** Whether the advisor tool is available for the current agent */
Expand Down Expand Up @@ -284,6 +287,8 @@ export interface StreamSystemContextResult {
* trailing scoped heading in one section swallow the next section's text.
*/
agentSystemPromptSections: string[];
/** Instruction sources loaded once for both prompt and tool-scoped extraction. */
instructionSources: InstructionSources;
/** Full system message string. */
systemMessage: string;
/** Token count of the system message. */
Expand Down Expand Up @@ -626,10 +631,22 @@ export async function buildStreamSystemContext(
effectiveAdditionalInstructions
);

// Load once so prompt assembly and tool-scoped extraction observe the same
// instruction snapshot without reading workspace files twice per startup.
const instructionSources =
opts.instructionSources ??
(await loadWorkspaceInstructionSources(
metadata,
runtime,
workspacePath,
cfg.projects,
opts.claudeSkillsCompatEnabled
));

// Build system message from workspace metadata
let systemMessage = await buildSystemMessage(
let systemMessage = buildSystemMessageFromSources(
metadata,
runtime,
instructionSources,
workspacePath,
mergedAdditionalInstructions,
modelString,
Expand All @@ -642,8 +659,6 @@ export async function buildStreamSystemContext(
{
agentSystemPromptSections,
modes: [effectiveMode, agentDefinition.id],
projectConfigs: cfg.projects,
claudeSkillsCompatEnabled: opts.claudeSkillsCompatEnabled,
}
);

Expand All @@ -662,6 +677,7 @@ export async function buildStreamSystemContext(

return {
agentSystemPromptSections,
instructionSources,
systemMessage,
systemMessageTokens,
agentDefinitions,
Expand Down
Loading