diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js
index 3afc2ba13ef..a26096816c9 100644
--- a/api/server/services/ToolService.js
+++ b/api/server/services/ToolService.js
@@ -49,6 +49,8 @@ const {
isFatalAgentInitializationError,
codeExecutionAuthHeaders,
createAttachedWorkspaceBashTool,
+ createRepositoryInstructionSource,
+ createRepositoryInstructionLoader,
resolveAttachedWorkspaceCommandTimeoutMax,
createGitIdentityProgrammaticBashTool,
resolveCodeExecutionContext,
@@ -105,6 +107,7 @@ const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/p
const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileSearch');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
+const repositoryInstructionLoader = createRepositoryInstructionLoader();
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const {
@@ -1561,6 +1564,13 @@ async function loadToolDefinitionsWrapper({
primedCodeFiles,
oauthActionToolNames,
codeExecutionContext: resolvedCodeExecutionContext,
+ repositoryInstructionSource: createRepositoryInstructionSource({
+ load: repositoryInstructionLoader,
+ enabled: codeExecutionEnabled,
+ context: resolvedCodeExecutionContext,
+ principalId: JSON.stringify([getTenantId(), req.user.id]),
+ getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
+ }),
};
}
@@ -1757,6 +1767,13 @@ async function loadAgentTools({
getAppConfig,
});
+ const repositoryInstructionSource = createRepositoryInstructionSource({
+ load: repositoryInstructionLoader,
+ enabled: codeExecutionEnabled,
+ context: codeExecutionContext,
+ principalId: JSON.stringify([getTenantId(), req.user.id]),
+ getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
+ });
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
agent,
signal,
@@ -1859,6 +1876,7 @@ async function loadAgentTools({
if (preparedActionSnapshot == null) {
return {
+ repositoryInstructionSource,
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
userMCPAuthMap,
@@ -1880,6 +1898,7 @@ async function loadAgentTools({
logger.warn(`No tools found for ${_agentTools.length} specified tool call(s)`);
}
return {
+ repositoryInstructionSource,
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
userMCPAuthMap,
@@ -2012,6 +2031,7 @@ async function loadAgentTools({
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
toolContextMap,
+ repositoryInstructionSource,
dynamicToolContextMap,
userMCPAuthMap,
toolDefinitions,
diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts
index 5cbaa759410..72834a7585d 100644
--- a/client/src/common/agents-types.ts
+++ b/client/src/common/agents-types.ts
@@ -53,6 +53,7 @@ export type AgentForm = {
/** Operator-configured managed or attached execution environment. */
code_environment_id?: string | null;
code_workspace_id?: string;
+ repositoryInstructions?: 'prefer' | 'defer' | 'off';
/** Git authorship applied to sandboxed commands for this agent. */
git_identity?: Agent['git_identity'];
provider?: AgentProvider | OptionWithIcon;
diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
index 783f1f00a2e..be03a3da891 100644
--- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
+++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
@@ -95,6 +95,7 @@ function EnvironmentWorkspaces({
isSelected: (workspaceId: string) => boolean;
onSelect: (selection: CodeWorkspaceSelection) => void;
}) {
+ const localize = useLocalize();
return (
} className={headingClasses}>
@@ -123,6 +124,18 @@ function EnvironmentWorkspaces({
{descriptor.name && (
{descriptor.id}
)}
+ {descriptor.instructions !== undefined && (
+
+ {descriptor.instructions.length === 0
+ ? localize('com_ui_repository_instructions_none')
+ : descriptor.instructions
+ .map(
+ (file) =>
+ `${file.path} · ${(file.bytes / 1024).toFixed(1)} KB${file.truncated ? ` · ${localize('com_ui_repository_instructions_truncated')}` : ''}`,
+ )
+ .join(', ')}
+
+ )}
{(descriptor.environment?.repo || descriptor.environment?.ref) && (
{[descriptor.environment.repo, descriptor.environment.ref]
diff --git a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
index 40934c0e304..79a8a27f58c 100644
--- a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
+++ b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
@@ -88,6 +88,19 @@ function renderMenu(ui: React.ReactElement) {
}
describe('CodeWorkspaceMenu', () => {
+ test('shows the instruction file and truncation reported by the worker', async () => {
+ const state = workspace();
+ state.environments[0].workspaces[0].instructions = [
+ { path: 'AGENTS.md', bytes: 32768, sha256: 'a'.repeat(64), truncated: true },
+ ];
+ renderMenu(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('code-workspace'));
+ expect(
+ await screen.findByText('AGENTS.md · 32.0 KB · com_ui_repository_instructions_truncated'),
+ ).toBeInTheDocument();
+ });
test.each([
['example/app', 'example/app · dev'],
[undefined, 'dev'],
diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx
index 1161266aa5e..4ee102939bc 100644
--- a/client/src/components/SidePanel/Agents/AgentPanel.tsx
+++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx
@@ -91,6 +91,7 @@ export function composeAgentUpdatePayload(
stateful_code_sessions,
stateful_code_environment,
code_environment_id,
+ repositoryInstructions,
code_workspace_id,
git_identity,
recursion_limit,
@@ -162,6 +163,7 @@ export function composeAgentUpdatePayload(
stateful_code_sessions: normalizedStatefulCodeSessions,
stateful_code_environment: normalizedStatefulCodeEnvironment,
code_environment_id: agent_id ? code_environment_id : (code_environment_id ?? undefined),
+ repositoryInstructions,
code_workspace_id,
git_identity: normalizedGitIdentity,
recursion_limit,
@@ -667,6 +669,7 @@ export default function AgentPanel() {
create.mutate({
...basePayload,
git_identity: basePayload.git_identity ?? undefined,
+ repositoryInstructions: basePayload.repositoryInstructions,
model,
tools,
provider,
diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx
index 7efe63d7056..e112c121173 100644
--- a/client/src/components/SidePanel/Agents/AgentSelect.tsx
+++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx
@@ -101,6 +101,7 @@ function AgentSelect({
avatar_action: null,
stateful_code_environment: fullAgent.stateful_code_environment ?? 'user',
code_environment_id: fullAgent.code_environment_id,
+ repositoryInstructions: fullAgent.repositoryInstructions,
code_workspace_id: fullAgent.code_workspace_id,
git_identity: fullAgent.git_identity,
};
diff --git a/client/src/components/SidePanel/Agents/Code/Settings.tsx b/client/src/components/SidePanel/Agents/Code/Settings.tsx
index 291edf8043b..57adc4b1399 100644
--- a/client/src/components/SidePanel/Agents/Code/Settings.tsx
+++ b/client/src/components/SidePanel/Agents/Code/Settings.tsx
@@ -308,6 +308,37 @@ export default function CodeSettings() {
)}
{showGitIdentity && (
+
+
+
+ {localize('com_ui_repository_instructions_description')}
+
{localize('com_ui_agent_git_identity')}
diff --git a/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx
index c8cfaaffbaa..81ef613fa9c 100644
--- a/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx
+++ b/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx
@@ -71,10 +71,29 @@ function IdentityForm({
+
);
}
+test('repository instruction mode defaults to prefer and retains an explicit off choice', async () => {
+ HTMLElement.prototype.scrollIntoView = jest.fn();
+ render(
);
+ expect(
+ screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }),
+ ).toHaveTextContent('com_ui_repository_instructions_prefer');
+ fireEvent.click(screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }));
+ fireEvent.click(
+ await screen.findByRole('option', { name: 'com_ui_repository_instructions_off' }),
+ );
+ expect(screen.getByTestId('repository-mode')).toHaveTextContent('off');
+ fireEvent.click(screen.getByText('Toggle Dialog'));
+ fireEvent.click(screen.getByText('Toggle Dialog'));
+ expect(
+ screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }),
+ ).toHaveTextContent('com_ui_repository_instructions_off');
+});
+
test('saves a workspace default bound to the selected machine and permits clearing it', async () => {
HTMLElement.prototype.scrollIntoView = jest.fn();
render(
);
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 446501702ba..b3b9abb8061 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1,4 +1,11 @@
{
+ "com_ui_repository_instructions": "Repository instructions",
+ "com_ui_repository_instructions_prefer": "Prefer repository conventions",
+ "com_ui_repository_instructions_defer": "Prefer agent conventions",
+ "com_ui_repository_instructions_off": "Off",
+ "com_ui_repository_instructions_description": "Load AGENTS.md, or CLAUDE.md when absent, from the selected workspace when its owner enables discovery. Tool permissions remain unchanged.",
+ "com_ui_repository_instructions_none": "No repository instructions",
+ "com_ui_repository_instructions_truncated": "Truncated",
"com_ui_code_workspace_default": "Default workspace for new chats",
"com_ui_code_workspace_last_used": "Last used workspace",
"com_ui_code_workspace_default_description": "New chats use this workspace. Without a default, reuse your last available choice for this agent and machine in this browser. Existing chats keep their workspace.",
diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts
index a6792435b80..70129505c2b 100644
--- a/packages/api/src/agents/__tests__/initialize.test.ts
+++ b/packages/api/src/agents/__tests__/initialize.test.ts
@@ -33,6 +33,8 @@ jest.mock('@librechat/agents', () => ({
}));
import { Providers } from '@librechat/agents';
+import { createHash } from 'node:crypto';
+import { createRepositoryInstructionLoader } from '../../code/instructions';
import {
Tools,
Constants,
@@ -287,6 +289,81 @@ describe('initializeAgent — execution context', () => {
jest.clearAllMocks();
});
+ it.each(['prefer', 'defer', 'off'] as const)(
+ 'uses saved repository instruction mode %s in definitions-only initialization',
+ async (mode) => {
+ const { agent, req, res, loadTools, db } = createMocks();
+ agent.instructions = 'Agent conventions';
+ agent.repositoryInstructions = mode;
+ const content = 'Repository conventions';
+ const authHeaders = jest.fn(async () => ({}));
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ protocolVersion: 1,
+ operation: 'read_file',
+ workspaceId: 'primary',
+ path: 'AGENTS.md',
+ content,
+ startLine: 1,
+ endLine: 1,
+ truncated: false,
+ }),
+ ),
+ );
+ loadTools.mockResolvedValue({
+ toolDefinitions: [],
+ repositoryInstructionSource: {
+ load: createRepositoryInstructionLoader(),
+ enabled: true,
+ principalId: `test-${mode}`,
+ context: {
+ environmentType: 'attached',
+ baseUrl: 'https://code.example/v1',
+ codeWorkspace: {
+ workspaceId: 'primary',
+ operations: ['read_file'],
+ instructions: [
+ {
+ path: 'AGENTS.md',
+ bytes: Buffer.byteLength(content),
+ sha256: createHash('sha256').update(content).digest('hex'),
+ truncated: false,
+ },
+ ],
+ },
+ },
+ authHeaders,
+ },
+ });
+ const result = await initializeAgent(
+ {
+ req,
+ res,
+ agent,
+ loadTools,
+ endpointOption: { endpoint: EModelEndpoint.agents },
+ allowedProviders: new Set([agent.provider]),
+ isInitialAgent: true,
+ },
+ db,
+ );
+ if (mode === 'off') {
+ expect(result.instructions).toBe('Agent conventions');
+ expect(authHeaders).not.toHaveBeenCalled();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ } else {
+ expect(result.instructions).toContain('Repository conventions');
+ expect(result.instructions).toMatch(/^Agent conventions\n\nRepository-provided/);
+ expect(result.instructions).toContain(
+ mode === 'defer' ? 'unless they conflict' : 'prefer these instructions',
+ );
+ }
+ expect(result.additional_instructions ?? '').not.toContain('Repository conventions');
+ fetchSpy.mockRestore();
+ },
+ );
+
it('carries request-resolved Azure identity to the run without changing persisted agent fields', async () => {
const { agent, req, res, loadTools, db } = createMocks({
provider: EModelEndpoint.azureOpenAI,
diff --git a/packages/api/src/agents/execution.ts b/packages/api/src/agents/execution.ts
index 65e01681766..5e55e9ff3b0 100644
--- a/packages/api/src/agents/execution.ts
+++ b/packages/api/src/agents/execution.ts
@@ -41,6 +41,7 @@ export interface CodeExecutionContext {
/** Live, server-validated directory selection. Never derive session reuse from this field. */
codeWorkspace?: CodeWorkspaceSelection & {
operations: CodeWorkspaceOperation[];
+ instructions?: CodeWorkspaceDescriptor['instructions'];
environment?: CodeWorkspaceDescriptor['environment'];
};
}
diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts
index 7f9cc7391ac..94f1a15bd3e 100644
--- a/packages/api/src/agents/initialize.ts
+++ b/packages/api/src/agents/initialize.ts
@@ -57,6 +57,7 @@ import type {
import type { LCAvailableTools, RequestScopedMCPConnectionStore } from '../mcp/types';
import type { ContentTraversalLimitError } from '../protection/adapters/nested';
import type { SkillContentInput } from '../protection/adapters/submissions';
+import type { RepositoryInstructionSource } from '../code/instructions';
import type { TextContentFragment } from '../protection/types';
import type { CheckAccessParams } from '../middleware/access';
import type { MCPToolAlias } from '~/tools/classification';
@@ -861,6 +862,7 @@ export interface InitializeAgentParams {
primedCodeFiles?: import('@librechat/agents').CodeEnvFile[];
/** Live workspace binding resolved by the execution-side loader. */
codeExecutionContext?: CodeExecutionContext;
+ repositoryInstructionSource?: RepositoryInstructionSource;
} | null>;
/** Endpoint option (contains model_parameters and endpoint info) */
endpointOption?: Partial
;
@@ -1964,6 +1966,7 @@ export async function initializeAgent(
tools: structuredTools,
primedCodeFiles,
codeExecutionContext: loadedCodeExecutionContext,
+ repositoryInstructionSource,
} = loadToolsResult ?? {
tools: [],
toolContextMap: {},
@@ -1979,6 +1982,7 @@ export async function initializeAgent(
oauthActionToolNames: undefined,
primedCodeFiles: undefined,
codeExecutionContext: undefined,
+ repositoryInstructionSource: undefined,
};
const trustedCodeExecutionContext = loadedCodeExecutionContext ?? codeExecutionContext;
const attachedWorkspaceOperations =
@@ -2261,6 +2265,25 @@ export async function initializeAgent(
}
}
+ const repositoryInstructionBlock = repositoryInstructionSource
+ ? await repositoryInstructionSource.load({
+ ...repositoryInstructionSource,
+ mode: agent.repositoryInstructions,
+ signal: params.signal,
+ timeoutMs: appConfig?.endpoints?.agents?.repositoryInstructions?.timeoutMs,
+ assertContent: (content) =>
+ assertModelBoundContent({
+ filters: appConfig?.filters,
+ agents: [{ instructions: content }],
+ }),
+ })
+ : undefined;
+ if (repositoryInstructionBlock) {
+ agent.instructions = [agent.instructions, repositoryInstructionBlock]
+ .filter(Boolean)
+ .join('\n\n');
+ }
+
if (typeof agent.artifacts === 'string' && agent.artifacts !== '') {
const artifactsPromptResult = generateArtifactsPrompt({
endpoint: agent.provider,
diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts
index a1073a763bd..a2fadc80599 100644
--- a/packages/api/src/agents/management.spec.ts
+++ b/packages/api/src/agents/management.spec.ts
@@ -39,6 +39,20 @@ const persistedAgent = {
};
describe('Agent Management contract', () => {
+ it('preserves repository instruction mode through API updates and response projection', () => {
+ for (const mode of ['prefer', 'defer', 'off'] as const) {
+ expect(agentManagementUpdateSchema.parse({ repositoryInstructions: mode })).toEqual({
+ repositoryInstructions: mode,
+ });
+ expect(
+ projectAgentManagementResponse({ ...persistedAgent, repositoryInstructions: mode })
+ .repositoryInstructions,
+ ).toBe(mode);
+ }
+ expect(
+ agentManagementUpdateSchema.safeParse({ repositoryInstructions: 'allow-all' }).success,
+ ).toBe(false);
+ });
describe('inputs', () => {
it('keeps create and update fields aligned with the browser Agent validators', () => {
expect(
diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts
index 3c297b9741a..f45e9bc6d17 100644
--- a/packages/api/src/agents/management.ts
+++ b/packages/api/src/agents/management.ts
@@ -280,6 +280,7 @@ export function projectAgentManagementResponse(
stateful_code_environment: source.stateful_code_environment,
code_environment_id: source.code_environment_id,
code_workspace_id: source.code_workspace_id,
+ repositoryInstructions: source.repositoryInstructions,
git_identity: source.git_identity,
artifacts: source.artifacts,
recursion_limit: source.recursion_limit,
diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts
index fb52df7bc8a..49ada6e2121 100644
--- a/packages/api/src/agents/validation.ts
+++ b/packages/api/src/agents/validation.ts
@@ -508,6 +508,7 @@ export const agentBaseSchema: z.ZodObject<
stateful_code_environment: z.ZodOptional>;
code_environment_id: z.ZodOptional;
code_workspace_id: z.ZodOptional;
+ repositoryInstructions: z.ZodOptional>;
git_identity: typeof agentGitIdentitySchema;
artifacts: z.ZodOptional;
recursion_limit: z.ZodOptional;
@@ -585,6 +586,7 @@ export const agentBaseSchema: z.ZodObject<
stateful_code_environment: z.enum(['user', 'agent-user', 'conversation']).optional(),
code_environment_id: agentCodeEnvironmentIdSchema.optional(),
code_workspace_id: agentCodeWorkspaceIdSchema.optional(),
+ repositoryInstructions: z.enum(['prefer', 'defer', 'off']).optional(),
git_identity: agentGitIdentitySchema,
artifacts: z.string().optional(),
recursion_limit: z.number().optional(),
@@ -686,6 +688,7 @@ export const agentCreateSchema: z.ZodObject<
code_environment_id: z.ZodOptional;
git_identity: typeof agentGitIdentitySchema;
code_workspace_id: z.ZodOptional;
+ repositoryInstructions: z.ZodOptional>;
artifacts: z.ZodOptional;
recursion_limit: z.ZodOptional;
conversation_starters: z.ZodOptional>;
@@ -821,6 +824,7 @@ export const agentUpdateSchema: z.ZodObject<
stateful_code_environment: z.ZodOptional>;
code_environment_id: z.ZodOptional>;
code_workspace_id: z.ZodOptional;
+ repositoryInstructions: z.ZodOptional>;
git_identity: typeof agentGitIdentityUpdateSchema;
artifacts: z.ZodOptional;
recursion_limit: z.ZodOptional;
@@ -907,6 +911,7 @@ export const agentUpdateSchema: z.ZodObject<
avatar: z.union([agentAvatarSchema, z.null()]).optional(),
code_environment_id: agentCodeEnvironmentIdSchema.nullable().optional(),
code_workspace_id: agentCodeWorkspaceIdSchema.optional(),
+ repositoryInstructions: z.enum(['prefer', 'defer', 'off']).optional(),
git_identity: agentGitIdentityUpdateSchema,
provider: z.string().optional(),
model: z.string().nullable().optional(),
diff --git a/packages/api/src/code/bridge.ts b/packages/api/src/code/bridge.ts
index 6b5dd940d21..63db1a238b5 100644
--- a/packages/api/src/code/bridge.ts
+++ b/packages/api/src/code/bridge.ts
@@ -4,6 +4,7 @@ import {
CODE_WORKSPACE_MAX_COUNT,
CODE_WORKSPACE_OPERATIONS,
isCodeWorkspaceEnvironment,
+ isRepositoryInstructionDescriptor,
} from 'librechat-data-provider';
import type { CodeWorkspaceDescriptor, CodeWorkspaceOperation } from 'librechat-data-provider';
@@ -185,11 +186,20 @@ function validWorkspaceCapabilities(value: unknown): value is {
const workspace = value as Record;
if (
Object.keys(workspace).some(
- (key) => key !== 'id' && key !== 'name' && key !== 'operations' && key !== 'environment',
+ (key) =>
+ key !== 'id' &&
+ key !== 'name' &&
+ key !== 'operations' &&
+ key !== 'environment' &&
+ key !== 'instructions',
) ||
typeof workspace.id !== 'string' ||
!CODE_WORKSPACE_ID_PATTERN.test(workspace.id) ||
ids.has(workspace.id) ||
+ (workspace.instructions !== undefined &&
+ (!Array.isArray(workspace.instructions) ||
+ workspace.instructions.length > 1 ||
+ !workspace.instructions.every(isRepositoryInstructionDescriptor))) ||
(workspace.environment !== undefined && !isCodeWorkspaceEnvironment(workspace.environment)) ||
(workspace.name !== undefined &&
(typeof workspace.name !== 'string' ||
diff --git a/packages/api/src/code/capabilities.ts b/packages/api/src/code/capabilities.ts
index 74a7e40469b..249c958ab18 100644
--- a/packages/api/src/code/capabilities.ts
+++ b/packages/api/src/code/capabilities.ts
@@ -178,6 +178,7 @@ export async function resolveCodeExecutionWorkspaceContext({
codeWorkspace: {
...selection,
operations: [...(workspace.operations ?? status.operations)],
+ ...(workspace.instructions ? { instructions: workspace.instructions } : {}),
...(workspace.environment ? { environment: workspace.environment } : {}),
},
};
diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts
index 219208b0698..6bf116a5a1c 100644
--- a/packages/api/src/code/index.ts
+++ b/packages/api/src/code/index.ts
@@ -8,3 +8,4 @@ export * from './command';
export * from './capabilities';
export * from './decision';
export * from './errors';
+export * from './instructions';
diff --git a/packages/api/src/code/instructions.spec.ts b/packages/api/src/code/instructions.spec.ts
new file mode 100644
index 00000000000..9a26bf9391b
--- /dev/null
+++ b/packages/api/src/code/instructions.spec.ts
@@ -0,0 +1,138 @@
+import { createHash } from 'node:crypto';
+import type { CodeExecutionContext } from '~/agents/execution';
+import { createRepositoryInstructionLoader } from './instructions';
+
+const content = 'Use the project test command.\n';
+const sha256 = createHash('sha256').update(content).digest('hex');
+const context: CodeExecutionContext = {
+ baseUrl: 'https://code.example/v1',
+ codeSessionKey: 'session',
+ executionProfile: 'stateful',
+ statefulSessions: true,
+ environmentType: 'attached',
+ environmentId: 'machine',
+ bridgeWorkerId: 'worker',
+ codeWorkspace: {
+ environmentId: 'machine',
+ workspaceId: 'primary',
+ operations: ['read_file'],
+ instructions: [
+ { path: 'AGENTS.md', bytes: Buffer.byteLength(content), sha256, truncated: false },
+ ],
+ },
+};
+const response = () =>
+ new Response(
+ JSON.stringify({
+ protocolVersion: 1,
+ operation: 'read_file',
+ workspaceId: 'primary',
+ path: 'AGENTS.md',
+ content,
+ startLine: 1,
+ endLine: 2,
+ truncated: false,
+ }),
+ { status: 200 },
+ );
+
+describe('repository instruction loading', () => {
+ it('bounds optional authorization waits and preserves explicit cancellation', async () => {
+ jest.useFakeTimers();
+ try {
+ const load = createRepositoryInstructionLoader();
+ const args = {
+ enabled: true,
+ context,
+ principalId: 'test',
+ assertContent: jest.fn(),
+ authHeaders: () => new Promise>(() => {}),
+ };
+ const pending = load(args);
+ await jest.advanceTimersByTimeAsync(2000);
+ expect(await pending).toBeUndefined();
+ let settled = false;
+ const slower = load({ ...args, timeoutMs: 5000 }).then((result) => {
+ settled = true;
+ return result;
+ });
+ await jest.advanceTimersByTimeAsync(2000);
+ expect(settled).toBe(false);
+ await jest.advanceTimersByTimeAsync(3000);
+ expect(await slower).toBeUndefined();
+ expect(
+ await load({
+ ...args,
+ authHeaders: async () => {
+ throw new Error('unavailable');
+ },
+ }),
+ ).toBeUndefined();
+ const controller = new AbortController();
+ const cancelled = load({ ...args, signal: controller.signal });
+ controller.abort(new Error('cancelled'));
+ await expect(cancelled).rejects.toThrow('cancelled');
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('checks authorization and content policy on cache hits, without cross-principal reuse', async () => {
+ const load = createRepositoryInstructionLoader();
+ const fetchImpl = jest.fn(async () => response());
+ const authHeaders = jest.fn(async () => ({ Authorization: 'Bearer test' }));
+ const assertContent = jest.fn();
+ const input = {
+ enabled: true,
+ context,
+ principalId: 'alice',
+ fetchImpl,
+ authHeaders,
+ assertContent,
+ };
+ const first = await load(input);
+ expect(first).toContain('Use the project test command.');
+ expect(await load(input)).toBe(first);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ expect(authHeaders).toHaveBeenCalledTimes(2);
+ expect(assertContent).toHaveBeenCalledTimes(2);
+ await load({ ...input, principalId: 'bob' });
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ expect(await load({ ...input, mode: 'off' })).toBeUndefined();
+ expect(await load({ ...input, enabled: false })).toBeUndefined();
+ expect(await load({ ...input, mode: 'defer' })).toContain('unless they conflict');
+ });
+
+ it('omits changed, missing and unauthorized instruction snapshots', async () => {
+ const load = createRepositoryInstructionLoader();
+ const input = {
+ enabled: true,
+ context,
+ principalId: 'alice',
+ authHeaders: async () => ({}),
+ assertContent: jest.fn(),
+ };
+ expect(
+ await load({ ...input, fetchImpl: async () => new Response('{}', { status: 404 }) }),
+ ).toBeUndefined();
+ expect(
+ await load({
+ ...input,
+ context: {
+ ...context,
+ codeWorkspace: {
+ ...context.codeWorkspace!,
+ instructions: [{ ...context.codeWorkspace!.instructions![0], sha256: 'a'.repeat(64) }],
+ },
+ },
+ fetchImpl: async () => response(),
+ }),
+ ).toBeUndefined();
+ expect(
+ await load({
+ ...input,
+ context: { ...context, codeWorkspace: { ...context.codeWorkspace!, operations: [] } },
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/api/src/code/instructions.ts b/packages/api/src/code/instructions.ts
new file mode 100644
index 00000000000..e34027eaf62
--- /dev/null
+++ b/packages/api/src/code/instructions.ts
@@ -0,0 +1,142 @@
+import { createHash } from 'node:crypto';
+import type { CodeExecutionContext } from '~/agents/execution';
+import type { CodeBridgeFetch } from './bridge';
+import { codeExecutionAuthHeaders } from '~/agents/execution';
+import { executeWorkspaceTool } from './workspace';
+
+export interface RepositoryInstructionSource {
+ enabled: boolean;
+ context: CodeExecutionContext;
+ principalId: string;
+ authHeaders: () => Promise>;
+ load: ReturnType;
+}
+
+/** Transport adapters provide authority; initialization supplies the saved agent preference. */
+export function createRepositoryInstructionSource({
+ getAuthHeaders,
+ ...source
+}: Omit & {
+ getAuthHeaders: (workerId?: string) => Promise>;
+}): RepositoryInstructionSource {
+ return {
+ ...source,
+ authHeaders: () => codeExecutionAuthHeaders(getAuthHeaders, source.context),
+ };
+}
+
+/** Bound header acquisition too, even when an upstream credential provider hangs. */
+async function abortable(promise: Promise, signal: AbortSignal): Promise {
+ let abort: () => void = () => {};
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_resolve, reject) => {
+ abort = () => reject(signal.reason);
+ signal.addEventListener('abort', abort, { once: true });
+ if (signal.aborted) abort();
+ }),
+ ]);
+ } finally {
+ signal.removeEventListener('abort', abort);
+ }
+}
+
+/** Bounded process-local content cache. Authorization is supplied fresh for each load. */
+export function createRepositoryInstructionLoader() {
+ const cache = new Map();
+ return async ({
+ context,
+ mode,
+ enabled,
+ principalId,
+ authHeaders,
+ signal,
+ fetchImpl,
+ assertContent,
+ timeoutMs = 2000,
+ }: {
+ enabled: boolean;
+ context: CodeExecutionContext;
+ mode?: 'prefer' | 'defer' | 'off';
+ principalId: string;
+ authHeaders: () => Promise>;
+ signal?: AbortSignal;
+ fetchImpl?: CodeBridgeFetch;
+ assertContent: (content: string) => void;
+ timeoutMs?: number;
+ }): Promise => {
+ const workspace = context.codeWorkspace;
+ if (
+ !enabled ||
+ mode === 'off' ||
+ context.environmentType !== 'attached' ||
+ !workspace?.operations.includes('read_file')
+ )
+ return;
+ const descriptor = workspace.instructions?.[0];
+ if (!descriptor) return;
+ if (signal?.aborted) throw signal.reason;
+ const key = JSON.stringify([
+ principalId,
+ context.baseUrl,
+ context.bridgeWorkerId,
+ workspace.workspaceId,
+ descriptor.path,
+ descriptor.sha256,
+ ]);
+ let content = cache.get(key);
+ const deadline = new AbortController();
+ const budget =
+ Number.isSafeInteger(timeoutMs) && timeoutMs >= 100 && timeoutMs <= 30_000 ? timeoutMs : 2000;
+ const timer = setTimeout(() => deadline.abort(), budget);
+ const readSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
+ try {
+ const headers = await abortable(authHeaders(), readSignal);
+ if (content === undefined) {
+ const result = await executeWorkspaceTool({
+ baseURL: context.baseUrl,
+ authHeaders: headers,
+ signal: readSignal,
+ fetchImpl,
+ request: {
+ protocolVersion: 1,
+ operation: 'read_file',
+ workspaceId: workspace.workspaceId,
+ path: descriptor.path,
+ instructionSha256: descriptor.sha256,
+ },
+ });
+ if (
+ result.operation !== 'read_file' ||
+ Buffer.byteLength(result.content) !== descriptor.bytes ||
+ createHash('sha256').update(result.content).digest('hex') !== descriptor.sha256 ||
+ result.truncated !== descriptor.truncated
+ )
+ return;
+ content = result.content;
+ if (cache.size >= 64) cache.delete(cache.keys().next().value!);
+ cache.set(key, content);
+ }
+ } catch {
+ if (signal?.aborted) throw signal.reason;
+ return;
+ } finally {
+ clearTimeout(timer);
+ }
+ assertContent(content);
+ const preference =
+ mode === 'defer'
+ ? 'Apply these repository conventions unless they conflict with the agent instructions.'
+ : 'For repository conventions, prefer these instructions over conflicting agent preferences.';
+ const source = JSON.stringify({
+ source: descriptor.path,
+ sha: descriptor.sha256,
+ workspace: workspace.workspaceId,
+ project: workspace.environment?.repo,
+ });
+ /** Quote untrusted content and escape markup delimiters without changing cached bytes. */
+ const quotedContent = JSON.stringify(content).replace(/\n${quotedContent}\n${descriptor.truncated ? '\n[Repository instructions truncated at 32 KiB.]' : ''}`;
+ };
+}
diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts
index 5deb991b176..c461ca71e0d 100644
--- a/packages/api/src/code/workspace.ts
+++ b/packages/api/src/code/workspace.ts
@@ -98,6 +98,7 @@ export interface WorkspaceReadRequest {
path: string;
startLine?: number;
maxLines?: number;
+ instructionSha256?: string;
}
export interface WorkspaceSearchRequest {
@@ -447,6 +448,14 @@ function isValidRequest(request: WorkspaceToolRequest): boolean {
return false;
}
if (request.operation === 'read_file') {
+ if (request.instructionSha256 !== undefined) {
+ return (
+ /^[a-f0-9]{64}$/.test(request.instructionSha256) &&
+ (request.path === 'AGENTS.md' || request.path === 'CLAUDE.md') &&
+ request.startLine === undefined &&
+ request.maxLines === undefined
+ );
+ }
return (
isSafePath(request.path) &&
(request.startLine == null ||
@@ -518,6 +527,18 @@ function isValidResult(
return false;
}
if (request.operation === 'read_file') {
+ if (request.instructionSha256 !== undefined) {
+ return (
+ hasOnlyKeys(value, READ_RESULT_KEYS) &&
+ value.path === request.path &&
+ typeof value.content === 'string' &&
+ Buffer.byteLength(value.content) <= 32768 &&
+ value.startLine === 1 &&
+ value.endLine === value.content.split('\n').length &&
+ typeof value.truncated === 'boolean' &&
+ value.nextStartLine === undefined
+ );
+ }
const startLine = request.startLine ?? 1;
const maxLines = request.maxLines ?? 200;
const content = typeof value.content === 'string' ? value.content : null;
diff --git a/packages/data-provider/src/code/workspace.ts b/packages/data-provider/src/code/workspace.ts
index 60665cb7cb8..f1d2b2d1d90 100644
--- a/packages/data-provider/src/code/workspace.ts
+++ b/packages/data-provider/src/code/workspace.ts
@@ -33,6 +33,7 @@ export type CodeEnvironmentMode = (typeof CODE_ENVIRONMENT_MODES)[number];
export interface CodeWorkspaceDescriptor {
id: string;
name?: string;
+ instructions?: RepositoryInstructionDescriptor[];
/** Omitted when every worker-level operation applies to this workspace. */
operations?: CodeWorkspaceOperation[];
environment?: {
@@ -43,6 +44,32 @@ export interface CodeWorkspaceDescriptor {
};
}
+export type RepositoryInstructionMode = 'prefer' | 'defer' | 'off';
+export interface RepositoryInstructionDescriptor {
+ path: 'AGENTS.md' | 'CLAUDE.md';
+ bytes: number;
+ sha256: string;
+ truncated: boolean;
+}
+export function isRepositoryInstructionDescriptor(
+ value: unknown,
+): value is RepositoryInstructionDescriptor {
+ if (value == null || typeof value !== 'object') return false;
+ const descriptor = value as Record;
+ return (
+ Object.keys(descriptor).every((key) =>
+ ['path', 'bytes', 'sha256', 'truncated'].includes(key),
+ ) &&
+ (descriptor.path === 'AGENTS.md' || descriptor.path === 'CLAUDE.md') &&
+ Number.isSafeInteger(descriptor.bytes) &&
+ Number(descriptor.bytes) >= 0 &&
+ Number(descriptor.bytes) <= 32768 &&
+ typeof descriptor.sha256 === 'string' &&
+ /^[a-f0-9]{64}$/.test(descriptor.sha256) &&
+ typeof descriptor.truncated === 'boolean'
+ );
+}
+
export function isCodeWorkspaceEnvironment(
value: unknown,
): value is NonNullable {
diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts
index d097dc6bea1..96172839204 100644
--- a/packages/data-provider/src/config.spec.ts
+++ b/packages/data-provider/src/config.spec.ts
@@ -24,6 +24,24 @@ const endpointsConfig: TEndpointsConfig = {
Gemini: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
+describe('repository instruction configuration', () => {
+ it('defaults optional reads to two seconds and bounds operator overrides', () => {
+ expect(agentsEndpointSchema.parse({}).repositoryInstructions).toBeUndefined();
+ expect(
+ agentsEndpointSchema.parse({ repositoryInstructions: {} }).repositoryInstructions,
+ ).toEqual({ timeoutMs: 2000 });
+ expect(
+ agentsEndpointSchema.parse({ repositoryInstructions: { timeoutMs: 5000 } })
+ .repositoryInstructions,
+ ).toEqual({ timeoutMs: 5000 });
+ for (const timeoutMs of [0, 99, 30_001, 1.5]) {
+ expect(
+ agentsEndpointSchema.safeParse({ repositoryInstructions: { timeoutMs } }).success,
+ ).toBe(false);
+ }
+ });
+});
+
describe('ask user retained answers', () => {
it('leaves the block unconfigured by default and accepts an operator budget', () => {
expect(agentsEndpointSchema.parse({}).askUserQuestion).toBeUndefined();
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index cafd0e11454..151c66ba3bf 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -1218,6 +1218,12 @@ export const agentsEndpointSchema = baseEndpointSchema
/* agents specific */
recursionLimit: z.number().optional(),
disableBuilder: z.boolean().optional().default(false),
+ /** Optional workspace guidance acquisition budget, separate from command execution. */
+ repositoryInstructions: z
+ .object({
+ timeoutMs: z.number().int().min(100).max(30_000).optional().default(2000),
+ })
+ .optional(),
maxRecursionLimit: z.number().optional(),
/** Max cumulative bytes a single streamed tool call's arguments may reach before the run
* aborts. Defaults to 64 KiB in the agents SDK; `0` disables the guard. */
diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts
index 527f26ba95f..a43dfab2b11 100644
--- a/packages/data-provider/src/schemas.ts
+++ b/packages/data-provider/src/schemas.ts
@@ -389,6 +389,7 @@ export const defaultAgentFormValues = {
stateful_code_environment: 'user' as const,
code_environment_id: undefined as string | null | undefined,
code_workspace_id: undefined as string | undefined,
+ repositoryInstructions: undefined as 'prefer' | 'defer' | 'off' | undefined,
category: 'general',
support_contact: {
name: '',
diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts
index 65c39d9f8bd..2b26e8dabd9 100644
--- a/packages/data-provider/src/types/agents.ts
+++ b/packages/data-provider/src/types/agents.ts
@@ -1010,6 +1010,7 @@ export type Agent = {
code_environment_id?: string | null;
/** Default attached workspace for new chats; empty means no agent default. */
code_workspace_id?: string;
+ repositoryInstructions?: 'prefer' | 'defer' | 'off';
/** Non-secret Git authorship injected into this agent's sandboxed commands. */
git_identity?: AgentGitIdentity | null;
artifacts?: ArtifactModes;
@@ -1074,6 +1075,7 @@ export type AgentCreateParams = {
| 'stateful_code_environment'
| 'code_environment_id'
| 'code_workspace_id'
+ | 'repositoryInstructions'
| 'artifacts'
| 'recursion_limit'
| 'category'
@@ -1109,6 +1111,7 @@ export type AgentUpdateParams = {
| 'code_environment_id'
| 'git_identity'
| 'code_workspace_id'
+ | 'repositoryInstructions'
| 'artifacts'
| 'recursion_limit'
| 'category'
diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts
index e68c253a2fe..ceeaa354b0b 100644
--- a/packages/data-schemas/src/methods/agent.ts
+++ b/packages/data-schemas/src/methods/agent.ts
@@ -1434,6 +1434,7 @@ export function createAgentMethods(
projection.stateful_code_sessions = 1;
projection.code_environment_id = 1;
projection.code_workspace_id = 1;
+ projection.repositoryInstructions = 1;
projection.agent_ids = 1;
projection['edges.from'] = 1;
projection['edges.to'] = 1;
@@ -1601,6 +1602,7 @@ export function createAgentMethods(
for (const field of [
'code_environment_id',
'code_workspace_id',
+ 'repositoryInstructions',
'git_identity',
'skills_scope',
'skill_authoring_enabled',
diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts
index 2f5fdfe5394..c9c51429fe6 100644
--- a/packages/data-schemas/src/schema/agent.ts
+++ b/packages/data-schemas/src/schema/agent.ts
@@ -95,6 +95,7 @@ const agentSchema: Schema = new Schema(
type: String,
},
code_workspace_id: { type: String },
+ repositoryInstructions: { type: String, enum: ['prefer', 'defer', 'off'] },
git_identity: {
type: new Schema(
{
diff --git a/packages/data-schemas/src/schema/agentWorkspace.spec.ts b/packages/data-schemas/src/schema/agentWorkspace.spec.ts
index d51c452b422..a3d1fd00b02 100644
--- a/packages/data-schemas/src/schema/agentWorkspace.spec.ts
+++ b/packages/data-schemas/src/schema/agentWorkspace.spec.ts
@@ -1,6 +1,14 @@
import { model, models } from 'mongoose';
import agentSchema from './agent';
+it('persists repository instruction preference and rejects unknown modes', () => {
+ const Agent = models.WorkspaceDefaultAgent ?? model('WorkspaceDefaultAgent', agentSchema);
+ const agent = new Agent({ repositoryInstructions: 'off' });
+ expect(new Agent(agent.toObject()).repositoryInstructions).toBe('off');
+ agent.repositoryInstructions = 'invalid';
+ expect(agent.validateSync()?.errors.repositoryInstructions).toBeDefined();
+});
+
it('retains the agent workspace preference and explicit reset through serialization', () => {
const Agent = models.WorkspaceDefaultAgent ?? model('WorkspaceDefaultAgent', agentSchema);
const agent = new Agent({ code_environment_id: 'machine-a', code_workspace_id: 'project-a' });
diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts
index 6f277f3eaf6..9563be72a73 100644
--- a/packages/data-schemas/src/types/agent.ts
+++ b/packages/data-schemas/src/types/agent.ts
@@ -44,6 +44,7 @@ export interface IAgent extends Omit {
stateful_code_environment?: 'user' | 'agent-user' | 'conversation';
code_environment_id?: string;
code_workspace_id?: string;
+ repositoryInstructions?: 'prefer' | 'defer' | 'off';
git_identity?: AgentGitIdentity | null;
/** @deprecated Use edges instead */
agent_ids?: string[];