Skip to content

Commit 117160f

Browse files
committed
Unify repository instruction loading across agent ingresses
1 parent ab086ed commit 117160f

8 files changed

Lines changed: 222 additions & 52 deletions

File tree

api/server/services/ToolService.js

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const {
4949
isFatalAgentInitializationError,
5050
codeExecutionAuthHeaders,
5151
createAttachedWorkspaceBashTool,
52-
createRepositoryInstructionLoader,
52+
createRepositoryInstructionSource,
5353
resolveAttachedWorkspaceCommandTimeoutMax,
5454
createGitIdentityProgrammaticBashTool,
5555
resolveCodeExecutionContext,
@@ -106,7 +106,6 @@ const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/p
106106
const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileSearch');
107107
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
108108
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
109-
const loadRepositoryInstructions = createRepositoryInstructionLoader();
110109
const { createOnSearchResults } = require('~/server/services/Tools/search');
111110
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
112111
const {
@@ -1563,6 +1562,12 @@ async function loadToolDefinitionsWrapper({
15631562
primedCodeFiles,
15641563
oauthActionToolNames,
15651564
codeExecutionContext: resolvedCodeExecutionContext,
1565+
repositoryInstructionSource: createRepositoryInstructionSource({
1566+
enabled: codeExecutionEnabled,
1567+
context: resolvedCodeExecutionContext,
1568+
principalId: JSON.stringify([getTenantId(), req.user.id]),
1569+
getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
1570+
}),
15661571
};
15671572
}
15681573

@@ -1759,23 +1764,11 @@ async function loadAgentTools({
17591764
getAppConfig,
17601765
});
17611766

1762-
const repositoryInstructionBlock = await loadRepositoryInstructions({
1763-
assertContent: (content) =>
1764-
assertModelBoundContent({
1765-
filters: appConfig.filters,
1766-
agents: [{ instructions: content }],
1767-
onTraversalFailure: reportLocatorTraversalFailure,
1768-
}),
1767+
const repositoryInstructionSource = createRepositoryInstructionSource({
17691768
enabled: codeExecutionEnabled,
17701769
context: codeExecutionContext,
1771-
mode: agent.repositoryInstructions,
17721770
principalId: JSON.stringify([getTenantId(), req.user.id]),
1773-
signal,
1774-
authHeaders: () =>
1775-
codeExecutionAuthHeaders(
1776-
(bridgeWorkerId) => getCodeApiAuthHeaders(req, bridgeWorkerId),
1777-
codeExecutionContext,
1778-
),
1771+
getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
17791772
});
17801773
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
17811774
agent,
@@ -1879,7 +1872,7 @@ async function loadAgentTools({
18791872

18801873
if (preparedActionSnapshot == null) {
18811874
return {
1882-
repositoryInstructionBlock,
1875+
repositoryInstructionSource,
18831876
toolRegistry,
18841877
requestScopedConnections: getMCPRequestContext(req, res),
18851878
userMCPAuthMap,
@@ -1901,7 +1894,7 @@ async function loadAgentTools({
19011894
logger.warn(`No tools found for ${_agentTools.length} specified tool call(s)`);
19021895
}
19031896
return {
1904-
repositoryInstructionBlock,
1897+
repositoryInstructionSource,
19051898
toolRegistry,
19061899
requestScopedConnections: getMCPRequestContext(req, res),
19071900
userMCPAuthMap,
@@ -2034,7 +2027,7 @@ async function loadAgentTools({
20342027
toolRegistry,
20352028
requestScopedConnections: getMCPRequestContext(req, res),
20362029
toolContextMap,
2037-
repositoryInstructionBlock,
2030+
repositoryInstructionSource,
20382031
dynamicToolContextMap,
20392032
userMCPAuthMap,
20402033
toolDefinitions,

client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,29 @@ function IdentityForm({
7171
<output data-testid="identity">{JSON.stringify(methods.watch('git_identity'))}</output>
7272
<output data-testid="workspace-default">{methods.watch('code_workspace_id')}</output>
7373
<output data-testid="machine-default">{methods.watch('code_environment_id')}</output>
74+
<output data-testid="repository-mode">{methods.watch('repositoryInstructions')}</output>
7475
</FormProvider>
7576
);
7677
}
7778

79+
test('repository instruction mode defaults to prefer and retains an explicit off choice', async () => {
80+
HTMLElement.prototype.scrollIntoView = jest.fn();
81+
render(<IdentityForm />);
82+
expect(
83+
screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }),
84+
).toHaveTextContent('com_ui_repository_instructions_prefer');
85+
fireEvent.click(screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }));
86+
fireEvent.click(
87+
await screen.findByRole('option', { name: 'com_ui_repository_instructions_off' }),
88+
);
89+
expect(screen.getByTestId('repository-mode')).toHaveTextContent('off');
90+
fireEvent.click(screen.getByText('Toggle Dialog'));
91+
fireEvent.click(screen.getByText('Toggle Dialog'));
92+
expect(
93+
screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }),
94+
).toHaveTextContent('com_ui_repository_instructions_off');
95+
});
96+
7897
test('saves a workspace default bound to the selected machine and permits clearing it', async () => {
7998
HTMLElement.prototype.scrollIntoView = jest.fn();
8099
render(<IdentityForm />);

packages/api/src/agents/__tests__/initialize.test.ts

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jest.mock('@librechat/agents', () => ({
3333
}));
3434

3535
import { Providers } from '@librechat/agents';
36+
import { createHash } from 'node:crypto';
3637
import {
3738
Tools,
3839
Constants,
@@ -287,29 +288,79 @@ describe('initializeAgent — execution context', () => {
287288
jest.clearAllMocks();
288289
});
289290

290-
it('places loaded repository instructions after agent instructions in the stable block', async () => {
291-
const { agent, req, res, loadTools, db } = createMocks();
292-
agent.instructions = 'Agent conventions';
293-
loadTools.mockResolvedValue({
294-
tools: [],
295-
toolDefinitions: [],
296-
repositoryInstructionBlock: 'Repository conventions',
297-
});
298-
const result = await initializeAgent(
299-
{
300-
req,
301-
res,
302-
agent,
303-
loadTools,
304-
endpointOption: { endpoint: EModelEndpoint.agents },
305-
allowedProviders: new Set([agent.provider]),
306-
isInitialAgent: true,
307-
},
308-
db,
309-
);
310-
expect(result.instructions).toBe('Agent conventions\n\nRepository conventions');
311-
expect(result.additional_instructions ?? '').not.toContain('Repository conventions');
312-
});
291+
it.each(['prefer', 'defer', 'off'] as const)(
292+
'uses saved repository instruction mode %s in definitions-only initialization',
293+
async (mode) => {
294+
const { agent, req, res, loadTools, db } = createMocks();
295+
agent.instructions = 'Agent conventions';
296+
agent.repositoryInstructions = mode;
297+
const content = 'Repository conventions';
298+
const authHeaders = jest.fn(async () => ({}));
299+
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
300+
new Response(
301+
JSON.stringify({
302+
protocolVersion: 1,
303+
operation: 'read_file',
304+
workspaceId: 'primary',
305+
path: 'AGENTS.md',
306+
content,
307+
startLine: 1,
308+
endLine: 1,
309+
truncated: false,
310+
}),
311+
),
312+
);
313+
loadTools.mockResolvedValue({
314+
toolDefinitions: [],
315+
repositoryInstructionSource: {
316+
enabled: true,
317+
principalId: `test-${mode}`,
318+
context: {
319+
environmentType: 'attached',
320+
baseUrl: 'https://code.example/v1',
321+
codeWorkspace: {
322+
workspaceId: 'primary',
323+
operations: ['read_file'],
324+
instructions: [
325+
{
326+
path: 'AGENTS.md',
327+
bytes: Buffer.byteLength(content),
328+
sha256: createHash('sha256').update(content).digest('hex'),
329+
truncated: false,
330+
},
331+
],
332+
},
333+
},
334+
authHeaders,
335+
},
336+
});
337+
const result = await initializeAgent(
338+
{
339+
req,
340+
res,
341+
agent,
342+
loadTools,
343+
endpointOption: { endpoint: EModelEndpoint.agents },
344+
allowedProviders: new Set([agent.provider]),
345+
isInitialAgent: true,
346+
},
347+
db,
348+
);
349+
if (mode === 'off') {
350+
expect(result.instructions).toBe('Agent conventions');
351+
expect(authHeaders).not.toHaveBeenCalled();
352+
expect(fetchSpy).not.toHaveBeenCalled();
353+
} else {
354+
expect(result.instructions).toContain('Repository conventions');
355+
expect(result.instructions).toMatch(/^Agent conventions\n\nRepository-provided/);
356+
expect(result.instructions).toContain(
357+
mode === 'defer' ? 'unless they conflict' : 'prefer these instructions',
358+
);
359+
}
360+
expect(result.additional_instructions ?? '').not.toContain('Repository conventions');
361+
fetchSpy.mockRestore();
362+
},
363+
);
313364

314365
it('carries request-resolved Azure identity to the run without changing persisted agent fields', async () => {
315366
const { agent, req, res, loadTools, db } = createMocks({

packages/api/src/agents/initialize.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import type {
5757
import type { LCAvailableTools, RequestScopedMCPConnectionStore } from '../mcp/types';
5858
import type { ContentTraversalLimitError } from '../protection/adapters/nested';
5959
import type { SkillContentInput } from '../protection/adapters/submissions';
60+
import type { RepositoryInstructionSource } from '../code/instructions';
6061
import type { TextContentFragment } from '../protection/types';
6162
import type { CheckAccessParams } from '../middleware/access';
6263
import type { MCPToolAlias } from '~/tools/classification';
@@ -112,6 +113,7 @@ import { assertModelBoundContent } from '../middleware/modelBoundContent';
112113
import { isImplicitStatefulCodeRouteAvailable } from '../code/config';
113114
import { registerMemoryTools, memoryToolUsageGuard } from './memory';
114115
import { applyIntentLabels, sanitizeIntentLabels } from './intent';
116+
import { loadRepositoryInstructions } from '../code/instructions';
115117
import { ContentFilterError } from '../middleware/contentFilter';
116118
import { resolveToolRoleGrants } from '~/tools/rolePermissions';
117119
import { createRequestAgentExecutionContext } from './runtime';
@@ -861,7 +863,7 @@ export interface InitializeAgentParams {
861863
primedCodeFiles?: import('@librechat/agents').CodeEnvFile[];
862864
/** Live workspace binding resolved by the execution-side loader. */
863865
codeExecutionContext?: CodeExecutionContext;
864-
repositoryInstructionBlock?: string;
866+
repositoryInstructionSource?: RepositoryInstructionSource;
865867
} | null>;
866868
/** Endpoint option (contains model_parameters and endpoint info) */
867869
endpointOption?: Partial<TEndpointOption>;
@@ -1965,7 +1967,7 @@ export async function initializeAgent(
19651967
tools: structuredTools,
19661968
primedCodeFiles,
19671969
codeExecutionContext: loadedCodeExecutionContext,
1968-
repositoryInstructionBlock,
1970+
repositoryInstructionSource,
19691971
} = loadToolsResult ?? {
19701972
tools: [],
19711973
toolContextMap: {},
@@ -1981,7 +1983,7 @@ export async function initializeAgent(
19811983
oauthActionToolNames: undefined,
19821984
primedCodeFiles: undefined,
19831985
codeExecutionContext: undefined,
1984-
repositoryInstructionBlock: undefined,
1986+
repositoryInstructionSource: undefined,
19851987
};
19861988
const trustedCodeExecutionContext = loadedCodeExecutionContext ?? codeExecutionContext;
19871989
const attachedWorkspaceOperations =
@@ -2264,6 +2266,18 @@ export async function initializeAgent(
22642266
}
22652267
}
22662268

2269+
const repositoryInstructionBlock = repositoryInstructionSource
2270+
? await loadRepositoryInstructions({
2271+
...repositoryInstructionSource,
2272+
mode: agent.repositoryInstructions,
2273+
signal: params.signal,
2274+
assertContent: (content) =>
2275+
assertModelBoundContent({
2276+
filters: appConfig?.filters,
2277+
agents: [{ instructions: content }],
2278+
}),
2279+
})
2280+
: undefined;
22672281
if (repositoryInstructionBlock) {
22682282
agent.instructions = [agent.instructions, repositoryInstructionBlock]
22692283
.filter(Boolean)

packages/api/src/agents/management.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ const persistedAgent = {
3939
};
4040

4141
describe('Agent Management contract', () => {
42+
it('preserves repository instruction mode through API updates and response projection', () => {
43+
for (const mode of ['prefer', 'defer', 'off'] as const) {
44+
expect(agentManagementUpdateSchema.parse({ repositoryInstructions: mode })).toEqual({
45+
repositoryInstructions: mode,
46+
});
47+
expect(
48+
projectAgentManagementResponse({ ...persistedAgent, repositoryInstructions: mode })
49+
.repositoryInstructions,
50+
).toBe(mode);
51+
}
52+
expect(
53+
agentManagementUpdateSchema.safeParse({ repositoryInstructions: 'allow-all' }).success,
54+
).toBe(false);
55+
});
4256
describe('inputs', () => {
4357
it('keeps create and update fields aligned with the browser Agent validators', () => {
4458
expect(

packages/api/src/agents/management.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export function projectAgentManagementResponse(
280280
stateful_code_environment: source.stateful_code_environment,
281281
code_environment_id: source.code_environment_id,
282282
code_workspace_id: source.code_workspace_id,
283+
repositoryInstructions: source.repositoryInstructions,
283284
git_identity: source.git_identity,
284285
artifacts: source.artifacts,
285286
recursion_limit: source.recursion_limit,

packages/api/src/code/instructions.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ const response = () =>
3737
);
3838

3939
describe('repository instruction loading', () => {
40+
it('bounds optional authorization waits and preserves explicit cancellation', async () => {
41+
jest.useFakeTimers();
42+
try {
43+
const load = createRepositoryInstructionLoader();
44+
const args = {
45+
enabled: true,
46+
context,
47+
principalId: 'test',
48+
assertContent: jest.fn(),
49+
authHeaders: () => new Promise<Record<string, string>>(() => {}),
50+
};
51+
const pending = load(args);
52+
await jest.advanceTimersByTimeAsync(2000);
53+
expect(await pending).toBeUndefined();
54+
expect(
55+
await load({
56+
...args,
57+
authHeaders: async () => {
58+
throw new Error('unavailable');
59+
},
60+
}),
61+
).toBeUndefined();
62+
const controller = new AbortController();
63+
const cancelled = load({ ...args, signal: controller.signal });
64+
const assertion = expect(cancelled).rejects.toThrow('cancelled');
65+
controller.abort(new Error('cancelled'));
66+
await assertion;
67+
} finally {
68+
jest.useRealTimers();
69+
}
70+
});
71+
4072
it('checks authorization and content policy on cache hits, without cross-principal reuse', async () => {
4173
const load = createRepositoryInstructionLoader();
4274
const fetchImpl = jest.fn(async () => response());

0 commit comments

Comments
 (0)